ezpy_logs 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,108 @@
1
+ # ABOUTME: Compatibility shim for the 0.1 API (LoggerFactory.getLogger / setup_LoggerFactory).
2
+ # ABOUTME: Keeps the old implicit setup until 0.3; a new-style ezpy_logs.setup() always replaces it.
3
+ import logging
4
+ import typing
5
+ import warnings
6
+
7
+ from ezpy_logs._core import (
8
+ DEFAULT_LOG_DIR,
9
+ LevelFilter,
10
+ TqdmToLogger,
11
+ _configure,
12
+ _resolve,
13
+ current_files,
14
+ delete_old_logs,
15
+ )
16
+ from ezpy_logs import _core
17
+
18
+ __all__ = ["BASE_LOG_DIR", "LevelFilter", "LoggerFactory", "TqdmToLogger", "remove_logs_older_than_n_days"]
19
+
20
+ MIN_LEVEL_FOR_LOGGERS = logging.DEBUG
21
+ BASE_LOG_DIR = DEFAULT_LOG_DIR
22
+ _LEGACY_RETENTION_DAYS = 30
23
+
24
+
25
+ def remove_logs_older_than_n_days(root_dir: str, n_days: int = 30) -> int:
26
+ return delete_old_logs(root_dir, n_days)
27
+
28
+
29
+ class _LegacyState(type):
30
+ """Class-level read-only views of the live setup, so they never go stale."""
31
+
32
+ @property
33
+ def is_setup(cls) -> bool:
34
+ return _core._state is not None
35
+
36
+ @property
37
+ def base_logging_directory(cls) -> str | None:
38
+ return _core._state.config.log_dir.as_posix() if _core._state else None
39
+
40
+ @property
41
+ def output_files(cls) -> list[tuple[str, int, int, str]]:
42
+ """(path, min_level, max_level, mode) of the files currently written, as in 0.1."""
43
+ return [(p.as_posix(), low, high, mode) for p, low, high, mode in current_files()]
44
+
45
+
46
+ class LoggerFactory(metaclass=_LegacyState):
47
+ """Deprecated: use `ezpy_logs.setup()` and `ezpy_logs.get_logger()`.
48
+
49
+ Legacy setups never fight a new-style one: `setup()` replaces them, and a legacy setup
50
+ after `setup()` is a no-op. Between themselves, the first legacy setup wins (0.1 behaviour).
51
+ """
52
+
53
+ setup_loggers: typing.ClassVar[list[str]] = []
54
+ _warned: typing.ClassVar[bool] = False
55
+
56
+ @classmethod
57
+ def setup_LoggerFactory(cls, log_dir: str = BASE_LOG_DIR, clean_old_logs: bool = True):
58
+ if _core._state is None:
59
+ config = _resolve(
60
+ log_dir=log_dir,
61
+ level=None,
62
+ app_loggers=(),
63
+ app_level=logging.DEBUG,
64
+ run_id=None,
65
+ json=None,
66
+ tz=None,
67
+ latest=None,
68
+ retention_days=_LEGACY_RETENTION_DAYS if clean_old_logs else None,
69
+ redact_values=(),
70
+ )
71
+ _configure(config, legacy=True)
72
+
73
+ @classmethod
74
+ def getLogger(
75
+ cls,
76
+ name: str = "should be __name__",
77
+ level=MIN_LEVEL_FOR_LOGGERS,
78
+ ) -> logging.Logger:
79
+ if _core._state is None:
80
+ if not cls._warned:
81
+ cls._warned = True
82
+ warnings.warn(
83
+ "LoggerFactory.getLogger() set up logging implicitly in ./.logs; this "
84
+ "implicit setup goes away in ezpy_logs 0.3. Call ezpy_logs.setup() "
85
+ "from your application and use ezpy_logs.get_logger().",
86
+ DeprecationWarning,
87
+ stacklevel=2,
88
+ )
89
+ cls.setup_LoggerFactory()
90
+ logger = logging.getLogger(name)
91
+ logger.setLevel(level)
92
+ if name not in cls.setup_loggers:
93
+ cls.setup_loggers.append(name)
94
+ return logger
95
+
96
+ @classmethod
97
+ def get_TqdmToLogger(cls, logger: logging.Logger, level=MIN_LEVEL_FOR_LOGGERS) -> TqdmToLogger:
98
+ return TqdmToLogger(logger, level)
99
+
100
+ @classmethod
101
+ def change_level_of_all_loggers(cls, level):
102
+ for name in cls.setup_loggers:
103
+ logging.getLogger(name).setLevel(level)
104
+
105
+ @classmethod
106
+ def _reset(cls) -> None:
107
+ cls.setup_loggers = []
108
+ cls._warned = False
ezpy_logs/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ # ABOUTME: Public API of ezpy_logs: setup() once from the application, get_logger() everywhere.
2
+ # ABOUTME: LoggerFactory stays importable from ezpy_logs.LoggerFactory as a deprecated alias until 0.3.
3
+ from ezpy_logs._core import TqdmToLogger, delete_old_logs, get_logger, is_setup, setup
4
+
5
+ __all__ = ["TqdmToLogger", "delete_old_logs", "get_logger", "is_setup", "setup"]
ezpy_logs/_core.py ADDED
@@ -0,0 +1,298 @@
1
+ # ABOUTME: setup() / get_logger(): one set of handlers on the root logger, configured explicitly once.
2
+ # ABOUTME: Also owns file naming, retention of the library's own archive files, and the tqdm adapter.
3
+ import io
4
+ import logging
5
+ import os
6
+ import sys
7
+ import time
8
+ from collections.abc import Iterable
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+ from ezpy_logs._format import FILE_FORMAT, TERMINAL_FORMAT, JsonFormatter, TextFormatter, file_stamp
13
+ from ezpy_logs._redact import Redactor
14
+
15
+ DEFAULT_LOG_DIR = ".logs"
16
+ DEFAULT_ROOT_LEVEL = logging.WARNING
17
+ _OWN_SUFFIXES = (".log", ".jsonl")
18
+ _OWN_DIRS = ("archive", "archive_ERRORS")
19
+
20
+ _logger = logging.getLogger(__name__)
21
+
22
+
23
+ class LevelFilter(logging.Filter):
24
+ def __init__(self, low=logging.DEBUG, high=logging.CRITICAL) -> None:
25
+ """Filters by logging level.
26
+
27
+ Args:
28
+ low : Lowest passing level (** Included **)
29
+ high : Highest passing level (** Included **)
30
+ """
31
+ self._low = low
32
+ self._high = high
33
+ logging.Filter.__init__(self)
34
+
35
+ def filter(self, record):
36
+ return self._low <= record.levelno <= self._high
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Config:
41
+ log_dir: Path
42
+ level: int
43
+ app_loggers: tuple[str, ...]
44
+ app_level: int
45
+ run_id: str | None
46
+ json: bool
47
+ tz: str
48
+ latest: bool
49
+ retention_days: float | None
50
+ redact_values: tuple[str, ...] = field(repr=False)
51
+
52
+
53
+ @dataclass
54
+ class _State:
55
+ config: Config
56
+ legacy: bool
57
+ handlers: list[logging.Handler]
58
+ files: list[tuple[Path, int, int, str]] # (path, min_level, max_level, "append"|"replace")
59
+
60
+
61
+ _state: _State | None = None
62
+
63
+
64
+ def _env_level(value: str | int | None) -> int:
65
+ if value is None:
66
+ value = os.environ.get("EZPY_LOGS_LEVEL") or DEFAULT_ROOT_LEVEL
67
+ if isinstance(value, int):
68
+ return value
69
+ level = logging.getLevelName(value.upper())
70
+ if not isinstance(level, int):
71
+ raise ValueError(f"unknown log level: {value!r}")
72
+ return level
73
+
74
+
75
+ def _resolve(
76
+ log_dir, level, app_loggers, app_level, run_id, json, tz, latest, retention_days, redact_values
77
+ ) -> Config:
78
+ tz = (tz or os.environ.get("EZPY_LOGS_TZ") or "utc").lower()
79
+ if tz not in ("utc", "local"):
80
+ raise ValueError(f"tz must be 'utc' or 'local', not {tz!r}")
81
+ if json is None:
82
+ json = os.environ.get("EZPY_LOGS_JSON") == "1"
83
+ if latest is None:
84
+ latest = run_id is None
85
+ return Config(
86
+ log_dir=Path(log_dir or os.environ.get("EZPY_LOGS_DIR") or DEFAULT_LOG_DIR).absolute(),
87
+ level=_env_level(level),
88
+ app_loggers=tuple(app_loggers),
89
+ app_level=_env_level(app_level),
90
+ run_id=run_id,
91
+ json=bool(json),
92
+ tz=tz,
93
+ latest=bool(latest),
94
+ retention_days=retention_days,
95
+ redact_values=tuple(redact_values),
96
+ )
97
+
98
+
99
+ def setup(
100
+ log_dir: str | os.PathLike | None = None,
101
+ *,
102
+ level: str | int | None = None,
103
+ app_loggers: Iterable[str] = (),
104
+ app_level: str | int = logging.DEBUG,
105
+ run_id: str | None = None,
106
+ json: bool | None = None,
107
+ tz: str | None = None,
108
+ latest: bool | None = None,
109
+ retention_days: float | None = None,
110
+ redact_values: Iterable[str] = (),
111
+ ) -> None:
112
+ """Configures logging for the whole process. Call it once, from the application.
113
+
114
+ Args:
115
+ log_dir: where files go. Default: $EZPY_LOGS_DIR, else `.logs` in the cwd.
116
+ level: root level, i.e. the level of every logger you don't own (third-party
117
+ libraries). Default: $EZPY_LOGS_LEVEL, else WARNING.
118
+ app_loggers: names of your own loggers (usually your package name); they are set
119
+ to `app_level` so your DEBUG lines show without the libraries' DEBUG flood.
120
+ run_id: names the files `<log_dir>/<run_id>.log` / `.jsonl` instead of timestamped
121
+ archives, and turns `latest` off by default.
122
+ json: also write JSON lines. Default: $EZPY_LOGS_JSON == "1".
123
+ tz: "utc" (default, or $EZPY_LOGS_TZ) or "local" for file names and text lines.
124
+ JSON `ts` is always UTC.
125
+ latest: also write `Latest.log` / `Latest_ERRORS.log`, overwritten by each run.
126
+ Default: on, unless run_id is given.
127
+ retention_days: delete this library's own archive files older than this.
128
+ Default None: never delete anything.
129
+ redact_values: literal secret values to redact (shorter than 8 chars are ignored).
130
+
131
+ Calling setup again with the same arguments is a no-op; with different arguments it
132
+ raises. It always replaces a legacy `LoggerFactory` setup.
133
+ """
134
+ config = _resolve(
135
+ log_dir, level, app_loggers, app_level, run_id, json, tz, latest, retention_days, redact_values
136
+ )
137
+ if _state is not None and not _state.legacy:
138
+ if _state.config == config:
139
+ return
140
+ raise RuntimeError(
141
+ f"ezpy_logs is already set up with {_state.config}; refusing to reconfigure to {config}"
142
+ )
143
+ _configure(config, legacy=False)
144
+
145
+
146
+ def get_logger(name: str) -> logging.Logger:
147
+ """Returns `logging.getLogger(name)`. Never configures anything or creates files."""
148
+ return logging.getLogger(name)
149
+
150
+
151
+ def is_setup() -> bool:
152
+ return _state is not None
153
+
154
+
155
+ def current_files() -> list[tuple[Path, int, int, str]]:
156
+ """The files the current setup writes to, as (path, min_level, max_level, mode)."""
157
+ return list(_state.files) if _state else []
158
+
159
+
160
+ def _configure(config: Config, legacy: bool) -> None:
161
+ global _state
162
+ _teardown()
163
+ redactor = Redactor(config.redact_values)
164
+ root = logging.getLogger()
165
+ handlers: list[logging.Handler] = []
166
+ files: list[tuple[Path, int, int, str]] = []
167
+
168
+ def add(handler: logging.Handler, low: int, high: int) -> None:
169
+ handler.setLevel(logging.DEBUG)
170
+ handler.addFilter(LevelFilter(low, high))
171
+ root.addHandler(handler)
172
+ handlers.append(handler)
173
+
174
+ def add_file(path: Path, low: int, high: int, mode: str = "a", as_json: bool = False) -> None:
175
+ path.parent.mkdir(parents=True, exist_ok=True)
176
+ handler = logging.FileHandler(path, mode=mode, encoding="utf8")
177
+ handler.setFormatter(JsonFormatter(redactor) if as_json else TextFormatter(FILE_FORMAT, config.tz, redactor))
178
+ add(handler, low, high)
179
+ files.append((path, low, high, "replace" if mode == "w" else "append"))
180
+
181
+ try:
182
+ _add_all_handlers(config, redactor, add, add_file)
183
+ except BaseException:
184
+ # A half-built setup must not stay on root, untracked: a retry would double every line.
185
+ for handler in handlers:
186
+ root.removeHandler(handler)
187
+ handler.close()
188
+ raise
189
+
190
+ root.setLevel(config.level)
191
+ # The library's own records (retention count) stay visible under a WARNING root.
192
+ logging.getLogger("ezpy_logs").setLevel(logging.INFO)
193
+ for name in config.app_loggers:
194
+ logging.getLogger(name).setLevel(config.app_level)
195
+ _state = _State(config=config, legacy=legacy, handlers=handlers, files=files)
196
+
197
+ if redactor.ignored_literals:
198
+ _logger.debug(f"{redactor.ignored_literals} redact value(s) ignored: shorter than 8 characters")
199
+ if config.retention_days is not None:
200
+ delete_old_logs(config.log_dir, config.retention_days, keep=[f[0] for f in files])
201
+ _logger.debug(f"Logging setup complete ! log_dir = {config.log_dir}")
202
+
203
+
204
+ def _add_all_handlers(config: Config, redactor: Redactor, add, add_file) -> None:
205
+ # stdout takes everything below WARNING, custom levels (SUCCESS=25) included.
206
+ for stream, low, high in ((sys.stdout, logging.DEBUG, logging.WARNING - 1), (sys.stderr, logging.WARNING, logging.CRITICAL)):
207
+ handler = logging.StreamHandler(stream)
208
+ handler.setFormatter(TextFormatter(TERMINAL_FORMAT, config.tz, redactor, color=_use_color(stream)))
209
+ add(handler, low, high)
210
+
211
+ d = config.log_dir
212
+ if config.run_id is not None:
213
+ add_file(d / f"{config.run_id}.log", logging.DEBUG, logging.CRITICAL)
214
+ if config.json:
215
+ add_file(d / f"{config.run_id}.jsonl", logging.DEBUG, logging.CRITICAL, as_json=True)
216
+ else:
217
+ name = f"{file_stamp(time.time(), config.tz)}_{os.getpid()}"
218
+ add_file(d / "archive" / f"{name}.log", logging.DEBUG, logging.CRITICAL)
219
+ add_file(d / "archive_ERRORS" / f"{name}.log", logging.WARNING, logging.CRITICAL)
220
+ if config.json:
221
+ add_file(d / "archive" / f"{name}.jsonl", logging.DEBUG, logging.CRITICAL, as_json=True)
222
+ if config.latest:
223
+ add_file(d / "Latest.log", logging.DEBUG, logging.CRITICAL, mode="w")
224
+ add_file(d / "Latest_ERRORS.log", logging.WARNING, logging.CRITICAL, mode="w")
225
+
226
+
227
+ def _use_color(stream) -> bool:
228
+ if "NO_COLOR" in os.environ:
229
+ return False
230
+ try:
231
+ return stream.isatty()
232
+ except (AttributeError, ValueError):
233
+ return False
234
+
235
+
236
+ def _teardown() -> None:
237
+ """Removes and closes the handlers of the current setup, if any."""
238
+ global _state
239
+ if _state is None:
240
+ return
241
+ root = logging.getLogger()
242
+ for handler in _state.handlers:
243
+ root.removeHandler(handler)
244
+ handler.close()
245
+ _state = None
246
+
247
+
248
+ def delete_old_logs(log_dir: str | os.PathLike, n_days: float, keep: Iterable[Path] = ()) -> int:
249
+ """Deletes `*.log` / `*.jsonl` files older than n_days inside `<log_dir>/archive/` and
250
+ `<log_dir>/archive_ERRORS/`.
251
+
252
+ Only regular files directly inside the library's own archive directories are touched:
253
+ never directories, never symlinks, never anything else in log_dir, never `keep` (the
254
+ live files of the current setup). Returns the count.
255
+ """
256
+ cutoff = time.time() - n_days * 86400
257
+ keep = {Path(p).absolute() for p in keep}
258
+ deleted = 0
259
+ for archive in (Path(log_dir) / name for name in _OWN_DIRS):
260
+ if not archive.is_dir() or archive.is_symlink():
261
+ continue
262
+ for path in archive.iterdir():
263
+ if path.suffix not in _OWN_SUFFIXES or path.is_symlink() or not path.is_file():
264
+ continue
265
+ if path.absolute() in keep:
266
+ continue
267
+ try:
268
+ if path.stat().st_mtime <= cutoff:
269
+ path.unlink()
270
+ deleted += 1
271
+ except FileNotFoundError:
272
+ # Protect against race condition, ie: multiple cron jobs using ezpy_logs
273
+ pass
274
+ # INFO only when something went: a count of 0 on every startup is noise.
275
+ _logger.log(
276
+ logging.INFO if deleted else logging.DEBUG,
277
+ f"Retention: deleted {deleted} log file(s) older than {n_days} days in {log_dir}",
278
+ )
279
+ return deleted
280
+
281
+
282
+ class TqdmToLogger(io.StringIO):
283
+ """Output stream for TQDM which will output to logger module instead of the StdOut."""
284
+
285
+ def __init__(self, logger: logging.Logger, level: int | None = None) -> None:
286
+ super().__init__()
287
+ self.logger = logger
288
+ self.level = level or logging.INFO
289
+ self.buf = ""
290
+
291
+ def write(self, buf: str) -> int:
292
+ self.buf = buf.strip("\r\n\t ")
293
+ return len(buf)
294
+
295
+ def flush(self) -> None:
296
+ if self.buf:
297
+ self.logger.log(self.level, self.buf)
298
+ self.buf = ""
ezpy_logs/_format.py ADDED
@@ -0,0 +1,97 @@
1
+ # ABOUTME: Formatters for ezpy_logs sinks: terminal (optionally coloured), text file, JSON lines.
2
+ # ABOUTME: Each one redacts its output; timestamps are ISO 8601 with milliseconds and an explicit zone.
3
+ import json
4
+ import logging
5
+ from datetime import datetime, timezone
6
+
7
+ from ezpy_logs._redact import Redactor
8
+
9
+ TERMINAL_FORMAT = "%(levelname)-7s : %(asctime)s %(relativeCreated)7d ms [Thread %(thread)-5d] %(pathname)50s:%(lineno)-4s || %(message)s"
10
+ FILE_FORMAT = "%(levelname)s %(asctime)s | [%(relativeCreated)d] Thread %(thread)-5d || [%(pathname)s:%(lineno)s] %(funcName)s || %(message)s"
11
+
12
+ _RESET = "\x1b[39m"
13
+ _COLORS = {
14
+ logging.DEBUG: "\x1b[32m", # green
15
+ logging.INFO: "\x1b[34m", # blue
16
+ logging.WARNING: "\x1b[33m", # yellow
17
+ logging.ERROR: "\x1b[35m", # magenta
18
+ logging.CRITICAL: "\x1b[31m", # red
19
+ }
20
+
21
+ # Every attribute a bare LogRecord carries; anything else on a record came from extra={...}.
22
+ _RECORD_ATTRS = frozenset(vars(logging.LogRecord("", 0, "", 0, "", None, None))) | {
23
+ "message",
24
+ "asctime",
25
+ "taskName",
26
+ }
27
+
28
+
29
+ def iso_time(created: float, tz: str) -> str:
30
+ """ISO 8601 with milliseconds: `...Z` in UTC mode, `...-07:00` in local mode."""
31
+ if tz == "local":
32
+ return datetime.fromtimestamp(created).astimezone().isoformat(timespec="milliseconds")
33
+ stamp = datetime.fromtimestamp(created, timezone.utc).isoformat(timespec="milliseconds")
34
+ return stamp.replace("+00:00", "Z")
35
+
36
+
37
+ def file_stamp(created: float, tz: str) -> str:
38
+ """File-name-safe timestamp: `2026-09-28T21-31-28Z` or `2026-09-28T14-31-28-0700`."""
39
+ if tz == "local":
40
+ return datetime.fromtimestamp(created).astimezone().strftime("%Y-%m-%dT%H-%M-%S%z")
41
+ return datetime.fromtimestamp(created, timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ")
42
+
43
+
44
+ class TextFormatter(logging.Formatter):
45
+ """Plain or coloured text; the whole formatted line (traceback included) is redacted."""
46
+
47
+ def __init__(self, fmt: str, tz: str, redactor: Redactor, color: bool = False) -> None:
48
+ super().__init__(fmt)
49
+ self._tz = tz
50
+ self._redact = redactor
51
+ self._color = color
52
+
53
+ def formatTime(self, record: logging.LogRecord, datefmt: str | None = None) -> str:
54
+ return iso_time(record.created, self._tz)
55
+
56
+ def format(self, record: logging.LogRecord) -> str:
57
+ line = self._redact(super().format(record))
58
+ if self._color and record.levelno in _COLORS:
59
+ head, sep, tail = line.partition("||")
60
+ if sep:
61
+ return f"{_COLORS[record.levelno]}{head}{sep}{_RESET}{tail}"
62
+ return line
63
+
64
+
65
+ class JsonFormatter(logging.Formatter):
66
+ """One JSON object per record. `ts` is always UTC, whatever the text sinks use.
67
+
68
+ Values are redacted BEFORE serialisation: in the serialised line a newline is the two
69
+ characters `\\n`, which multi-line patterns (PEM blocks) would not match.
70
+ """
71
+
72
+ def __init__(self, redactor: Redactor) -> None:
73
+ super().__init__()
74
+ self._redact = redactor
75
+
76
+ def format(self, record: logging.LogRecord) -> str:
77
+ entry: dict[str, object] = {
78
+ "ts": iso_time(record.created, "utc"),
79
+ "level": record.levelname,
80
+ "logger": record.name,
81
+ "msg": record.getMessage(),
82
+ "file": record.pathname,
83
+ "line": record.lineno,
84
+ "func": record.funcName,
85
+ "pid": record.process,
86
+ "thread": record.thread,
87
+ }
88
+ if record.exc_info:
89
+ entry["exc"] = self.formatException(record.exc_info)
90
+ elif record.exc_text:
91
+ entry["exc"] = record.exc_text
92
+ if record.stack_info:
93
+ entry["stack"] = self.formatStack(record.stack_info)
94
+ for key, value in vars(record).items():
95
+ if key not in _RECORD_ATTRS and key not in entry:
96
+ entry[key] = value
97
+ return json.dumps(self._redact.value(entry), ensure_ascii=False)
ezpy_logs/_redact.py ADDED
@@ -0,0 +1,85 @@
1
+ # ABOUTME: Best-effort secret redaction applied by every ezpy_logs formatter.
2
+ # ABOUTME: Replaces known token shapes and caller-given literal values with [REDACTED:<kind>].
3
+ import math
4
+ import re
5
+ from collections.abc import Iterable
6
+
7
+ # Literal values shorter than this are ignored: redacting "1" or "true" everywhere
8
+ # would destroy the logs without protecting anything.
9
+ MIN_LITERAL_LENGTH = 8
10
+
11
+ _KV_KEYS = r"password|passwd|token|secret|api_key|apikey"
12
+
13
+ # Order matters: longer, more specific shapes first (sk-ant- before sk-).
14
+ _PATTERNS: list[tuple[str, re.Pattern[str]]] = [
15
+ (
16
+ "private_key",
17
+ re.compile(
18
+ r"-----BEGIN [A-Z ]*PRIVATE KEY-----(?:.*?-----END [A-Z ]*PRIVATE KEY-----|.*)",
19
+ re.DOTALL,
20
+ ),
21
+ ),
22
+ ("bearer", re.compile(r"(?i)\bbearer\s+[A-Za-z0-9\-._~+/]+=*")),
23
+ ("google_oauth", re.compile(r"\bya29\.[0-9A-Za-z\-_]+")),
24
+ ("github", re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})")),
25
+ ("huggingface", re.compile(r"\bhf_[A-Za-z0-9]{20,}")),
26
+ ("anthropic", re.compile(r"\bsk-ant-[A-Za-z0-9\-_]{20,}")),
27
+ ("openai", re.compile(r"\bsk-[A-Za-z0-9\-_]{20,}")),
28
+ ("aws_key", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b")),
29
+ ("basic_auth", re.compile(r"(?i)\bbasic\s+[A-Za-z0-9+/]{8,}=*")),
30
+ ]
31
+
32
+ # A sensitive key name, alone or inside a longer one: GITHUB_TOKEN, access_token,
33
+ # client_secret, AWS_SECRET_ACCESS_KEY, db-password. `_` counts as a separator.
34
+ # The only suffix allowed is _KEY / _ACCESS_KEY: token_count=512 is not a secret.
35
+ _KEY_NAME = rf"(?:[A-Za-z0-9]+[_-])*(?:{_KV_KEYS})(?:[_-](?:access[_-])?key)?"
36
+ _SENSITIVE_KEY = re.compile(rf"(?i){_KEY_NAME}")
37
+
38
+ # key=value / key: value / 'key': 'value' / key="quoted value with spaces".
39
+ # The key name is kept, the value never is.
40
+ _KV = re.compile(
41
+ rf"(?i)(?<![A-Za-z0-9])({_KEY_NAME})(?![A-Za-z0-9])(['\"]?\s*[=:]\s*)"
42
+ rf"(?!['\"]?\[REDACTED)(?:\"[^\"\n]*\"?|'[^'\n]*'?|[^\s,;&'\"}}\]]+)"
43
+ )
44
+
45
+
46
+ class Redactor:
47
+ def __init__(self, literals: Iterable[str] = ()) -> None:
48
+ kept = sorted(
49
+ {v for v in literals if isinstance(v, str) and len(v) >= MIN_LITERAL_LENGTH},
50
+ key=len,
51
+ reverse=True,
52
+ )
53
+ self.ignored_literals = sum(
54
+ 1 for v in literals if not isinstance(v, str) or len(v) < MIN_LITERAL_LENGTH
55
+ )
56
+ self._literals = re.compile("|".join(map(re.escape, kept))) if kept else None
57
+
58
+ def __call__(self, text: str) -> str:
59
+ if self._literals is not None:
60
+ text = self._literals.sub("[REDACTED:value]", text)
61
+ for kind, pattern in _PATTERNS:
62
+ text = pattern.sub(f"[REDACTED:{kind}]", text)
63
+ return _KV.sub(lambda m: f"{m.group(1)}{m.group(2)}[REDACTED:{m.group(1).lower()}]", text)
64
+
65
+ def value(self, obj: object) -> object:
66
+ """Redacts a JSON-bound value: strings directly, containers recursively,
67
+ anything else through its str() so nothing reaches json.dumps unredacted."""
68
+ if obj is None or isinstance(obj, (bool, int)):
69
+ return obj
70
+ if isinstance(obj, float):
71
+ # NaN / inf are not JSON: strict parsers reject the line.
72
+ return obj if math.isfinite(obj) else str(obj)
73
+ if isinstance(obj, str):
74
+ return self(obj)
75
+ if isinstance(obj, dict):
76
+ return {self(str(k)): self._keyed(str(k), v) for k, v in obj.items()}
77
+ if isinstance(obj, (list, tuple, set, frozenset)):
78
+ return [self.value(v) for v in obj]
79
+ return self(str(obj))
80
+
81
+ def _keyed(self, key: str, value: object) -> object:
82
+ """A value under a sensitive key name (extra={"password": ...}) is redacted whole."""
83
+ if value is not None and not isinstance(value, (dict, list, tuple)) and _SENSITIVE_KEY.fullmatch(key):
84
+ return f"[REDACTED:{key.lower()}]"
85
+ return self.value(value)
ezpy_logs/py.typed ADDED
File without changes
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.5
2
+ Name: ezpy_logs
3
+ Version: 0.2.0
4
+ Summary: EzPy Logs: swiss knife logging for personal use
5
+ Project-URL: Homepage, https://github.com/ezalos/ezpy_logs
6
+ Project-URL: Repository, https://github.com/ezalos/ezpy_logs
7
+ Author-email: ezalos <ezalos@github.com>
8
+ License: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
+ Classifier: Topic :: System :: Logging
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+
19
+ # EZPY Logs
20
+
21
+ Package for my personal use, swiss knife logging :
22
+ - When logging, has a nice formatter with file line and datetime (ISO 8601, UTC by default)
23
+ - Colorful logger (only on a terminal; `NO_COLOR` turns it off)
24
+ - Easy import / use: `setup()` once, `get_logger()` everywhere
25
+ - Save to files, with errors, and optionally JSON lines for machines
26
+ - Best-effort secret redaction in every sink
27
+ - Opt-in deletion of its own old logs
28
+
29
+ No runtime dependencies.
30
+
31
+ ## Install
32
+
33
+ ```
34
+ uv add ezpy-logs
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ```py
40
+ import ezpy_logs
41
+
42
+ logger = ezpy_logs.get_logger(__name__) # safe at module level: creates nothing
43
+
44
+ def main():
45
+ ezpy_logs.setup(app_loggers=["my_package"]) # once, from the application
46
+ logger.info("Hello from ezpy-logs!")
47
+
48
+ if __name__ == "__main__":
49
+ main()
50
+ ```
51
+
52
+ ```sh
53
+ uv run python hello.py
54
+ ```
55
+
56
+ Library code only calls `get_logger`. Nothing is configured and no file is written until
57
+ the application calls `setup()`.
58
+
59
+ ### `setup()` arguments
60
+
61
+ | Argument | Default | Meaning |
62
+ |---|---|---|
63
+ | `log_dir` | `$EZPY_LOGS_DIR`, else `.logs` | Where files go. |
64
+ | `level` | `$EZPY_LOGS_LEVEL`, else `WARNING` | Root level: every logger you don't own (urllib3, botocore...). |
65
+ | `app_loggers` | `()` | Your own logger names (usually your package); set to `app_level`. |
66
+ | `app_level` | `DEBUG` | Level of `app_loggers`. |
67
+ | `run_id` | `None` | Write `<log_dir>/<run_id>.log` (+ `.jsonl`) instead of timestamped archives. |
68
+ | `json` | `$EZPY_LOGS_JSON == "1"` | Also write JSON lines. |
69
+ | `tz` | `$EZPY_LOGS_TZ`, else `"utc"` | `"utc"` or `"local"` (with offset) for file names and text lines. JSON `ts` is always UTC. |
70
+ | `latest` | on, off with `run_id` | Also write `Latest.log` / `Latest_ERRORS.log`, overwritten by each run. |
71
+ | `retention_days` | `None` (never delete) | Delete this library's own `archive*/*.log`/`*.jsonl` older than N days. |
72
+ | `redact_values` | `()` | Literal secrets to redact (values under 8 characters are ignored). |
73
+
74
+ Calling `setup()` again with the same arguments does nothing; with different arguments it
75
+ raises `RuntimeError`.
76
+
77
+ Environment variables: `EZPY_LOGS_DIR`, `EZPY_LOGS_LEVEL`, `EZPY_LOGS_JSON=1`,
78
+ `EZPY_LOGS_TZ`, `NO_COLOR`.
79
+
80
+ ### What each sink looks like
81
+
82
+ Terminal (DEBUG/INFO on stdout, WARNING and above on stderr):
83
+
84
+ ```
85
+ INFO : 2026-09-28T21:46:49.393Z 14 ms [Thread 138181901391680] hello.py:12 || Hello from ezpy-logs!
86
+ WARNING : 2026-09-28T21:46:49.393Z 14 ms [Thread 138181901391680] hello.py:13 || token=[REDACTED:token] never reaches a sink
87
+ ```
88
+
89
+ Files, by default:
90
+
91
+ ```
92
+ .logs/archive/2026-09-28T21-46-49Z_1428568.log everything
93
+ .logs/archive/2026-09-28T21-46-49Z_1428568.jsonl everything, when json is on
94
+ .logs/archive_ERRORS/2026-09-28T21-46-49Z_1428568.log WARNING and above
95
+ .logs/Latest.log / Latest_ERRORS.log this run only
96
+ ```
97
+
98
+ A text file line:
99
+
100
+ ```
101
+ INFO 2026-09-28T21:46:49.393Z | [14] Thread 138181901391680 || [/path/hello.py:12] main || Hello from ezpy-logs!
102
+ ```
103
+
104
+ A JSON line (`extra={...}` fields are merged in; `exc` holds the traceback when there is one):
105
+
106
+ ```json
107
+ {"ts": "2026-09-28T21:46:49.393Z", "level": "WARNING", "logger": "__main__", "msg": "token=[REDACTED:token] never reaches a sink", "file": "/path/hello.py", "line": 13, "func": "main", "pid": 1428568, "thread": 138181901391680, "job": 7}
108
+ ```
109
+
110
+ ### Secret redaction is best effort
111
+
112
+ Every ezpy_logs sink redacts bearer tokens, `ya29.` Google OAuth tokens, `ghp_`/`github_pat_`,
113
+ `hf_`, `sk-`/`sk-ant-`, `AKIA` AWS keys, PEM private-key blocks, `password=`/`token=`/`secret=`
114
+ values, and the literal `redact_values`. Regexes cannot catch every secret, and a handler someone
115
+ else adds (Sentry, pytest's caplog) bypasses it: do not rely on it as a security boundary.
116
+
117
+ ### With tqdm
118
+
119
+ ```py
120
+ from tqdm import tqdm
121
+ import ezpy_logs
122
+
123
+ logger = ezpy_logs.get_logger(__name__)
124
+ for name in tqdm(files, file=ezpy_logs.TqdmToLogger(logger), miniters=1e4, maxinterval=float("inf")):
125
+ ...
126
+ ```
127
+
128
+ ### Migrating from 0.1
129
+
130
+ `from ezpy_logs.LoggerFactory import LoggerFactory` still works until 0.3, including its
131
+ implicit setup in `./.logs` (it emits a `DeprecationWarning` once). A `setup()` call always
132
+ replaces a `LoggerFactory` setup. See `CHANGELOG.md` for every behaviour change.
133
+
134
+ ## Dev
135
+
136
+ ### Local env setup
137
+
138
+ ```sh
139
+ uv sync
140
+ source .venv/bin/activate
141
+ ```
142
+
143
+ ### TESTING
144
+
145
+ ```sh
146
+ uv run pytest -v
147
+ ```
148
+
149
+ ### Publishing
150
+
151
+ Change version in `pyproject.toml`, add a `CHANGELOG.md` entry. `UV_PUBLISH_TOKEN` in `.envrc`
152
+ is a `pass://` ref (Agent vault, item `PIPY_TOKEN`), so publish through `secrets run`:
153
+
154
+ ```sh
155
+ rip dist
156
+ uv build && secrets run -- uv publish
157
+ ```
@@ -0,0 +1,9 @@
1
+ ezpy_logs/LoggerFactory.py,sha256=kitRzml3LP-ghYjRWZhz5MJTSHvzkK5z-uAegD5MxsQ,3640
2
+ ezpy_logs/__init__.py,sha256=73G0n9VTabN37tYFRR058E1tr8_g0upAUTxssf0OvLM,369
3
+ ezpy_logs/_core.py,sha256=hojIRas4Tkb2n5q_oYWki2flOTYk3va-25zlBQH_tBI,11226
4
+ ezpy_logs/_format.py,sha256=-arupWZcVO-QcUdC1o0fwwSrve0rX1XqPvv0dgoWS9c,3938
5
+ ezpy_logs/_redact.py,sha256=FEp7RDzIOTcS8WCihc0nb7101z8MlSWYBygjkqU3XcM,3890
6
+ ezpy_logs/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ ezpy_logs-0.2.0.dist-info/METADATA,sha256=Y-mhBQd4Sl861ldOdYya8LuxjxxPuwFbSHPXlsAbVuc,5391
8
+ ezpy_logs-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ ezpy_logs-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any