|
| 1 | +"""Logging configuration for the datapilot CLI. |
| 2 | +
|
| 3 | +Verbose output is opt-in and can be turned on two ways: |
| 4 | +
|
| 5 | +- the ``DATAPILOT_DEBUG`` environment variable, which is the option to reach for |
| 6 | + in CI/CD pipelines where the command line is generated and hard to edit |
| 7 | +- the ``--debug`` flag on individual commands |
| 8 | +
|
| 9 | +Both raise the root logger to ``DEBUG``, which surfaces the HTTP status codes and |
| 10 | +API error bodies that ``APIClient`` already records but normally discards. |
| 11 | +""" |
| 12 | + |
| 13 | +import logging |
| 14 | +import os |
| 15 | +from typing import Optional |
| 16 | +from urllib.parse import urlsplit |
| 17 | +from urllib.parse import urlunsplit |
| 18 | + |
| 19 | +DEBUG_ENV_VAR = "DATAPILOT_DEBUG" |
| 20 | + |
| 21 | +# Values that count as "on" for DATAPILOT_DEBUG. Anything else (including "0", |
| 22 | +# "false" and the empty string) leaves debug logging off. |
| 23 | +_TRUTHY_VALUES = frozenset({"1", "true", "yes", "on"}) |
| 24 | + |
| 25 | +# Debug mode is sticky: a command-level `--debug` must not be undone by a later |
| 26 | +# call that happens to default to False. |
| 27 | +_debug_enabled = False |
| 28 | + |
| 29 | + |
| 30 | +def debug_enabled_via_env() -> bool: |
| 31 | + """Return True when DATAPILOT_DEBUG is set to a truthy value.""" |
| 32 | + return os.environ.get(DEBUG_ENV_VAR, "").strip().lower() in _TRUTHY_VALUES |
| 33 | + |
| 34 | + |
| 35 | +def configure_logging(debug: bool = False) -> bool: |
| 36 | + """Configure root logging for the CLI and return whether debug mode is on. |
| 37 | +
|
| 38 | + Safe to call more than once; the group callback and the command callback both |
| 39 | + call it, and the more verbose of the two wins. |
| 40 | + """ |
| 41 | + global _debug_enabled |
| 42 | + _debug_enabled = _debug_enabled or debug or debug_enabled_via_env() |
| 43 | + level = logging.DEBUG if _debug_enabled else logging.INFO |
| 44 | + |
| 45 | + root = logging.getLogger() |
| 46 | + # Only install a handler when nothing else has, so embedding applications |
| 47 | + # (and pytest's caplog) keep theirs. Setting the level is what actually |
| 48 | + # decides whether the DEBUG records get through. |
| 49 | + if not root.handlers: |
| 50 | + logging.basicConfig(level=level) |
| 51 | + root.setLevel(level) |
| 52 | + |
| 53 | + # urllib3 logs each request line in full, which for a presigned S3 upload means |
| 54 | + # the AWS key and signature. Our own client logs the status codes and request |
| 55 | + # params, so nothing diagnostic is lost by holding urllib3 at INFO. |
| 56 | + logging.getLogger("urllib3").setLevel(logging.INFO) |
| 57 | + |
| 58 | + return _debug_enabled |
| 59 | + |
| 60 | + |
| 61 | +def is_debug_enabled() -> bool: |
| 62 | + """Return whether debug logging is currently enabled.""" |
| 63 | + return _debug_enabled |
| 64 | + |
| 65 | + |
| 66 | +def redact_url(url: Optional[str]) -> str: |
| 67 | + """Strip a URL's query string so it is safe to log. |
| 68 | +
|
| 69 | + Presigned upload URLs carry AWS credentials and a signature in the query |
| 70 | + string, and debug output routinely gets pasted into support tickets. |
| 71 | + """ |
| 72 | + if not url: |
| 73 | + return "" |
| 74 | + |
| 75 | + parts = urlsplit(url) |
| 76 | + if not parts.query: |
| 77 | + return url |
| 78 | + |
| 79 | + return urlunsplit((parts.scheme, parts.netloc, parts.path, "<redacted>", "")) |
0 commit comments