instruktai-python-logger 0.4.5__tar.gz → 0.5.1__tar.gz

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.
Files changed (22) hide show
  1. {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/PKG-INFO +2 -5
  2. {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/README.md +1 -1
  3. {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/instrukt_ai_logging/__init__.py +9 -8
  4. {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/instrukt_ai_logging/cli.py +82 -61
  5. {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/instrukt_ai_logging/install.py +33 -7
  6. {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/instrukt_ai_logging/logging.py +73 -36
  7. {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/instruktai_python_logger.egg-info/PKG-INFO +2 -5
  8. {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/instruktai_python_logger.egg-info/SOURCES.txt +1 -1
  9. instruktai_python_logger-0.5.1/pyproject.toml +92 -0
  10. {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/tests/test_configure_logging.py +59 -27
  11. instruktai_python_logger-0.5.1/tests/test_install.py +131 -0
  12. instruktai_python_logger-0.5.1/tests/test_log_aggregation.py +293 -0
  13. {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/tests/test_trace_logging.py +29 -12
  14. instruktai_python_logger-0.4.5/instruktai_python_logger.egg-info/requires.txt +0 -4
  15. instruktai_python_logger-0.4.5/pyproject.toml +0 -33
  16. instruktai_python_logger-0.4.5/tests/test_log_aggregation.py +0 -158
  17. {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/instrukt_ai_logging/py.typed +0 -0
  18. {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/instruktai_python_logger.egg-info/dependency_links.txt +0 -0
  19. {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/instruktai_python_logger.egg-info/entry_points.txt +0 -0
  20. {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/instruktai_python_logger.egg-info/top_level.txt +0 -0
  21. {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/setup.cfg +0 -0
  22. {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/tests/test_cli_follow.py +0 -0
@@ -1,12 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: instruktai-python-logger
3
- Version: 0.4.5
3
+ Version: 0.5.1
4
4
  Summary: Centralized logging utilities for Python services.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
- Provides-Extra: dev
8
- Requires-Dist: pytest>=8; extra == "dev"
9
- Requires-Dist: ruff>=0.8; extra == "dev"
10
7
 
11
8
  # InstruktAI Python Logger
12
9
 
@@ -22,7 +19,7 @@ Publishing notes live in `docs/publishing.md`.
22
19
  From PyPI (recommended):
23
20
 
24
21
  ```bash
25
- pip install instrukt-ai-logger
22
+ pip install instruktai-python-logger
26
23
  ```
27
24
 
28
25
  From GitHub:
@@ -12,7 +12,7 @@ Publishing notes live in `docs/publishing.md`.
12
12
  From PyPI (recommended):
13
13
 
14
14
  ```bash
15
- pip install instrukt-ai-logger
15
+ pip install instruktai-python-logger
16
16
  ```
17
17
 
18
18
  From GitHub:
@@ -4,30 +4,31 @@ See `README.md` for usage and `docs/design.md` for design intent.
4
4
  """
5
5
 
6
6
  __all__ = [
7
+ "TRACE",
8
+ "InstruktAILogger",
9
+ "InstruktAILoggerProtocol",
7
10
  "__version__",
8
11
  "configure_logging",
9
12
  "get_logger",
10
- "InstruktAILogger",
11
- "InstruktAILoggerProtocol",
12
13
  "resolve_log_file",
13
14
  "resolve_log_files",
14
- "TRACE",
15
15
  ]
16
16
 
17
17
  try:
18
- from importlib.metadata import packages_distributions, version as _pkg_version
18
+ from importlib.metadata import packages_distributions
19
+ from importlib.metadata import version as _pkg_version
19
20
 
20
21
  _dists = packages_distributions().get(__name__, [])
21
22
  __version__ = _pkg_version(_dists[0]) if _dists else "0.0.0"
22
23
  except Exception: # pragma: no cover
23
24
  __version__ = "0.0.0"
24
25
 
25
- from instrukt_ai_logging.logging import ( # noqa: E402 (intentional re-export)
26
- configure_logging,
27
- get_logger,
26
+ from instrukt_ai_logging.logging import (
27
+ TRACE,
28
28
  InstruktAILogger,
29
29
  InstruktAILoggerProtocol,
30
+ configure_logging,
31
+ get_logger,
30
32
  resolve_log_file,
31
33
  resolve_log_files,
32
- TRACE,
33
34
  )
@@ -6,8 +6,8 @@ import re
6
6
  import sys
7
7
  import threading
8
8
  import time
9
+ from collections.abc import Iterator
9
10
  from pathlib import Path
10
- from typing import Iterator
11
11
 
12
12
  from instrukt_ai_logging.logging import (
13
13
  iter_recent_log_lines_merged,
@@ -102,6 +102,15 @@ def _parse_stems(value: str) -> list[str]:
102
102
  return [item.strip() for item in value.split(",") if item.strip()]
103
103
 
104
104
 
105
+ def _line_passes(line: str, *, keep: re.Pattern[str] | None, drop: re.Pattern[str] | None) -> bool:
106
+ """Apply the inclusive `--grep` keep filter then the `--exclude` drop filter."""
107
+ if keep is not None and not keep.search(line):
108
+ return False
109
+ if drop is not None and drop.search(line):
110
+ return False
111
+ return True
112
+
113
+
105
114
  def _stem_of(path: Path) -> str:
106
115
  """Return the stem before the first `.log` suffix (e.g. `cron.log.1` → `cron`)."""
107
116
  name = path.name
@@ -116,6 +125,64 @@ def _is_rotation_suffix(path: Path) -> bool:
116
125
  return idx > 0 and name[idx + len(".log") :] != ""
117
126
 
118
127
 
128
+ def _follow_files(
129
+ files: list[Path],
130
+ stems: list[str] | None,
131
+ *,
132
+ keep: re.Pattern[str] | None,
133
+ drop: re.Pattern[str] | None,
134
+ ) -> None:
135
+ """Follow each selected file concurrently, applying the keep/drop filters.
136
+
137
+ New lines from any file are written to stdout as they arrive — no cross-file
138
+ timestamp merge in follow mode, since real-time arrival ordering is its own
139
+ merge. For the explicit `--logs`/`--include` case, follow each named stem's
140
+ canonical `<stem>.log` so we don't follow rotated history.
141
+ """
142
+ follow_files: list[Path]
143
+ if stems is None:
144
+ follow_files = [p for p in files if not _is_rotation_suffix(p)]
145
+ else:
146
+ follow_files = []
147
+ for stem in stems:
148
+ for p in files:
149
+ if p.name == f"{stem}.log":
150
+ follow_files.append(p)
151
+ break
152
+
153
+ if not follow_files:
154
+ return
155
+
156
+ stop_event = threading.Event()
157
+
158
+ def _follow_one(path: Path) -> None:
159
+ try:
160
+ for line in iter_follow_lines(path, start_at_end=True):
161
+ if stop_event.is_set():
162
+ return
163
+ if not _line_passes(line, keep=keep, drop=drop):
164
+ continue
165
+ sys.stdout.write(line)
166
+ sys.stdout.flush()
167
+ except OSError:
168
+ return
169
+
170
+ threads = [
171
+ threading.Thread(target=_follow_one, args=(path,), daemon=True, name=f"follow-{path.name}")
172
+ for path in follow_files
173
+ ]
174
+ for t in threads:
175
+ t.start()
176
+
177
+ try:
178
+ while any(t.is_alive() for t in threads):
179
+ for t in threads:
180
+ t.join(timeout=0.25)
181
+ except KeyboardInterrupt:
182
+ stop_event.set()
183
+ return
184
+
185
+
119
186
  def main() -> None:
120
187
  from instrukt_ai_logging import __version__
121
188
 
@@ -126,22 +193,26 @@ def main() -> None:
126
193
  version=f"%(prog)s {__version__}",
127
194
  )
128
195
  parser.add_argument("app", help="App/service name (folder under /var/log/instrukt-ai/)")
129
- parser.add_argument(
130
- "--since", default="10m", help="Time window (e.g. 10m, 2h, 1d). Default: 10m"
131
- )
196
+ parser.add_argument("--since", default="10m", help="Time window (e.g. 10m, 2h, 1d). Default: 10m")
132
197
  parser.add_argument(
133
198
  "-f",
134
199
  "--follow",
135
200
  action="store_true",
136
201
  help="Follow the log (tail -f style) after printing the --since window",
137
202
  )
138
- parser.add_argument("--grep", default="", help="Regex to filter lines (optional)")
203
+ parser.add_argument("--grep", default="", help="Regex to keep only matching lines (optional)")
204
+ parser.add_argument(
205
+ "--exclude",
206
+ default="",
207
+ help="Regex to drop matching lines (applied after --grep; optional)",
208
+ )
139
209
  parser.add_argument(
140
210
  "--logs",
211
+ "--include",
141
212
  default="",
142
213
  help=(
143
214
  "Comma-separated list of stems to include (e.g. 'cron,docs-watch'). "
144
- "Default: all log files in the app's directory."
215
+ "Default: all log files in the app's directory. Alias: --include."
145
216
  ),
146
217
  )
147
218
  args = parser.parse_args()
@@ -152,6 +223,7 @@ def main() -> None:
152
223
  raise SystemExit(str(e)) from e
153
224
 
154
225
  pattern = re.compile(args.grep) if args.grep else None
226
+ exclude_pattern = re.compile(args.exclude) if args.exclude else None
155
227
  stems = _parse_stems(args.logs) or None
156
228
 
157
229
  files = resolve_log_files(args.app, stems=stems)
@@ -159,66 +231,15 @@ def main() -> None:
159
231
  if stems:
160
232
  available = sorted({_stem_of(p) for p in resolve_log_files(args.app)})
161
233
  available_str = ", ".join(available) if available else "(none)"
162
- raise SystemExit(
163
- f"No log files matched stems {stems} in app '{args.app}'. "
164
- f"Available: {available_str}"
165
- )
234
+ raise SystemExit(f"No log files matched stems {stems} in app '{args.app}'. Available: {available_str}")
166
235
  raise SystemExit(f"No log files found for app '{args.app}'")
167
236
 
168
237
  for line in iter_recent_log_lines_merged(files, since):
169
- if pattern and not pattern.search(line):
170
- continue
171
- sys.stdout.write(line)
238
+ if _line_passes(line, keep=pattern, drop=exclude_pattern):
239
+ sys.stdout.write(line)
172
240
 
173
241
  if not args.follow:
174
242
  return
175
243
 
176
244
  sys.stdout.flush()
177
-
178
- # Follow each selected file concurrently. New lines from any file are
179
- # written to stdout as they arrive — no cross-file timestamp merge in
180
- # follow mode, since real-time arrival ordering is its own merge.
181
- # Filter to files that actually exist for the canonical (default) glob —
182
- # for the explicit `--logs=` case, follow each named stem's canonical
183
- # file (the unsuffixed `<stem>.log`) so we don't follow rotated history.
184
- if stems is None:
185
- follow_files = [p for p in files if not _is_rotation_suffix(p)]
186
- else:
187
- follow_files = []
188
- for stem in stems:
189
- for p in files:
190
- if p.name == f"{stem}.log":
191
- follow_files.append(p)
192
- break
193
-
194
- if not follow_files:
195
- return
196
-
197
- stop_event = threading.Event()
198
-
199
- def _follow_one(path: Path) -> None:
200
- try:
201
- for line in iter_follow_lines(path, start_at_end=True):
202
- if stop_event.is_set():
203
- return
204
- if pattern and not pattern.search(line):
205
- continue
206
- sys.stdout.write(line)
207
- sys.stdout.flush()
208
- except OSError:
209
- return
210
-
211
- threads = [
212
- threading.Thread(target=_follow_one, args=(path,), daemon=True, name=f"follow-{path.name}")
213
- for path in follow_files
214
- ]
215
- for t in threads:
216
- t.start()
217
-
218
- try:
219
- while any(t.is_alive() for t in threads):
220
- for t in threads:
221
- t.join(timeout=0.25)
222
- except KeyboardInterrupt:
223
- stop_event.set()
224
- return
245
+ _follow_files(files, stems, keep=pattern, drop=exclude_pattern)
@@ -7,6 +7,9 @@ import os
7
7
  import sys
8
8
  from pathlib import Path
9
9
 
10
+ DEFAULT_NEWSYSLOG_CONF = Path("/etc/newsyslog.d/instrukt-ai.conf")
11
+ DEFAULT_LOGROTATE_CONF = Path("/etc/logrotate.d/instrukt-ai")
12
+
10
13
 
11
14
  def _write_file(path: Path, content: str, *, force: bool) -> None:
12
15
  if path.exists() and not force:
@@ -15,24 +18,47 @@ def _write_file(path: Path, content: str, *, force: bool) -> None:
15
18
  path.write_text(content, encoding="utf-8")
16
19
 
17
20
 
18
- def _install_newsyslog(log_root: Path, *, force: bool) -> Path:
19
- """Install a glob-based newsyslog rule covering every app's *.log file.
21
+ def _install_newsyslog(
22
+ log_root: Path,
23
+ *,
24
+ force: bool,
25
+ conf_path: Path = DEFAULT_NEWSYSLOG_CONF,
26
+ ) -> Path:
27
+ """Install newsyslog rules covering every app's *.log file.
20
28
 
21
29
  Format: path owner:group mode count size(KB) when flags
22
30
  Defaults: 50 MB rotation, 5 archives, gzip-compress archives (Z flag).
31
+
32
+ macOS newsyslog requires literal filenames — its config does NOT
33
+ expand globs at runtime. We therefore enumerate every existing
34
+ *.log file under log_root and emit one explicit line per file.
35
+ Re-run `instrukt-ai-log-setup --force` after a new producer creates
36
+ its first log file (which can be wired into the app's installer or
37
+ a daemon's periodic cleanup task).
23
38
  """
24
- conf_path = Path("/etc/newsyslog.d/instrukt-ai.conf")
25
- line = f"{log_root}/" + "*/*.log 640 5 50000 * Z\n"
26
- _write_file(conf_path, line, force=force)
39
+ if log_root.is_dir():
40
+ log_files = sorted(p for p in log_root.glob("*/*.log") if p.is_file())
41
+ else:
42
+ log_files = []
43
+ lines = "".join(f"{path} 640 5 50000 * Z\n" for path in log_files)
44
+ if not lines:
45
+ # No log files yet — write a placeholder comment so the conf is
46
+ # discoverable on disk for future re-runs.
47
+ lines = f"# instrukt-ai-log-setup: no *.log files found under {log_root}\n"
48
+ _write_file(conf_path, lines, force=force)
27
49
  return conf_path
28
50
 
29
51
 
30
- def _install_logrotate(log_root: Path, *, force: bool) -> Path:
52
+ def _install_logrotate(
53
+ log_root: Path,
54
+ *,
55
+ force: bool,
56
+ conf_path: Path = DEFAULT_LOGROTATE_CONF,
57
+ ) -> Path:
31
58
  """Install a glob-based logrotate rule covering every app's *.log file.
32
59
 
33
60
  Defaults: 50 MB rotation, 5 archives, gzip-compress archives.
34
61
  """
35
- conf_path = Path("/etc/logrotate.d/instrukt-ai")
36
62
  content = f"""{log_root}/*/*.log {{
37
63
  size 50M
38
64
  rotate 5
@@ -4,17 +4,18 @@ import heapq
4
4
  import logging
5
5
  import os
6
6
  import re
7
+ from collections.abc import Iterable, Iterator
7
8
  from dataclasses import dataclass
8
- from datetime import datetime, timedelta, timezone
9
+ from datetime import UTC, datetime, timedelta
9
10
  from logging.handlers import WatchedFileHandler
10
11
  from pathlib import Path
11
- from typing import Any, Iterable, Iterator, Protocol, cast, runtime_checkable
12
+ from typing import Any, Protocol, cast, runtime_checkable
12
13
 
13
14
  # Standard logging levels are: NOTSET=0, DEBUG=10, INFO=20, WARNING=30, ERROR=40, CRITICAL=50
14
15
  TRACE: int = 5
15
16
  logging.addLevelName(TRACE, "TRACE")
16
17
  # Monkey-patch logging so our _level_name_to_int helper finds it automatically.
17
- setattr(logging, "TRACE", TRACE)
18
+ logging.TRACE = TRACE # type: ignore[attr-defined]
18
19
 
19
20
 
20
21
  def _normalize_env_prefix(name: str) -> str:
@@ -68,12 +69,12 @@ def _parse_csv(value: str) -> list[str]:
68
69
 
69
70
 
70
71
  def _now_utc() -> datetime:
71
- return datetime.now(tz=timezone.utc)
72
+ return datetime.now(tz=UTC)
72
73
 
73
74
 
74
75
  class UtcMillisFormatter(logging.Formatter):
75
76
  def formatTime(self, record: logging.LogRecord, datefmt: str | None = None) -> str:
76
- dt = datetime.fromtimestamp(record.created, tz=timezone.utc)
77
+ dt = datetime.fromtimestamp(record.created, tz=UTC)
77
78
  if datefmt:
78
79
  return dt.strftime(datefmt)
79
80
  return dt.strftime("%Y-%m-%dT%H:%M:%S") + f".{int(record.msecs):03d}Z"
@@ -161,18 +162,17 @@ class LogfmtFormatter(UtcMillisFormatter):
161
162
  f"msg={_format_logfmt_string(str(message), max_chars=self.max_message_chars, force_quote=True)}",
162
163
  ]
163
164
 
164
- kv: object = getattr(record, "kv", None)
165
- if isinstance(kv, dict):
166
- for key in sorted(kv.keys(), key=lambda x: str(x)):
165
+ raw_kv: object = getattr(record, "kv", None)
166
+ if isinstance(raw_kv, dict):
167
+ kv = cast("dict[object, object]", raw_kv)
168
+ for key in sorted(kv.keys(), key=lambda k: str(k)):
167
169
  if key == "msg":
168
170
  continue
169
171
  if not isinstance(key, str):
170
172
  continue
171
173
  if not _SAFE_KEY.fullmatch(key):
172
174
  continue
173
- parts.append(
174
- f"{key}={_format_logfmt_value(kv[key], max_chars=self.max_message_chars)}"
175
- )
175
+ parts.append(f"{key}={_format_logfmt_value(kv[key], max_chars=self.max_message_chars)}")
176
176
 
177
177
  if record.exc_info:
178
178
  try:
@@ -216,9 +216,7 @@ class InstruktAILogger(logging.Logger):
216
216
  All `**kv` values are serialized to text by the formatter.
217
217
  """
218
218
 
219
- def _log_with_kv(
220
- self, level: int, msg: object, args: tuple[object, ...], **kwargs: Any
221
- ) -> None:
219
+ def _log_with_kv(self, level: int, msg: object, args: tuple[object, ...], **kwargs: Any) -> None:
222
220
  exc_info = kwargs.pop("exc_info", None)
223
221
  stack_info = kwargs.pop("stack_info", False)
224
222
  stacklevel = kwargs.pop("stacklevel", 1)
@@ -229,7 +227,7 @@ class InstruktAILogger(logging.Logger):
229
227
 
230
228
  merged_extra: dict[str, object] = {}
231
229
  if isinstance(extra, dict):
232
- merged_extra.update(extra)
230
+ merged_extra.update(cast("dict[str, object]", extra))
233
231
 
234
232
  existing_kv = merged_extra.get("kv")
235
233
  if isinstance(existing_kv, dict):
@@ -251,33 +249,33 @@ class InstruktAILogger(logging.Logger):
251
249
  if self.isEnabledFor(TRACE):
252
250
  self._log_with_kv(TRACE, msg, args, **kwargs)
253
251
 
254
- def debug(self, msg: object, *args: object, **kwargs: Any) -> None: # type: ignore[override]
252
+ def debug(self, msg: object, *args: object, **kwargs: Any) -> None:
255
253
  if self.isEnabledFor(logging.DEBUG):
256
254
  self._log_with_kv(logging.DEBUG, msg, args, **kwargs)
257
255
 
258
- def info(self, msg: object, *args: object, **kwargs: Any) -> None: # type: ignore[override]
256
+ def info(self, msg: object, *args: object, **kwargs: Any) -> None:
259
257
  if self.isEnabledFor(logging.INFO):
260
258
  self._log_with_kv(logging.INFO, msg, args, **kwargs)
261
259
 
262
- def warning(self, msg: object, *args: object, **kwargs: Any) -> None: # type: ignore[override]
260
+ def warning(self, msg: object, *args: object, **kwargs: Any) -> None:
263
261
  if self.isEnabledFor(logging.WARNING):
264
262
  self._log_with_kv(logging.WARNING, msg, args, **kwargs)
265
263
 
266
- def error(self, msg: object, *args: object, **kwargs: Any) -> None: # type: ignore[override]
264
+ def error(self, msg: object, *args: object, **kwargs: Any) -> None:
267
265
  if self.isEnabledFor(logging.ERROR):
268
266
  self._log_with_kv(logging.ERROR, msg, args, **kwargs)
269
267
 
270
- def critical(self, msg: object, *args: object, **kwargs: Any) -> None: # type: ignore[override]
268
+ def critical(self, msg: object, *args: object, **kwargs: Any) -> None:
271
269
  if self.isEnabledFor(logging.CRITICAL):
272
270
  self._log_with_kv(logging.CRITICAL, msg, args, **kwargs)
273
271
 
274
- def exception(self, msg: object, *args: object, **kwargs: Any) -> None: # type: ignore[override]
272
+ def exception(self, msg: object, *args: object, **kwargs: Any) -> None:
275
273
  kwargs.setdefault("exc_info", True)
276
274
  if self.isEnabledFor(logging.ERROR):
277
275
  self._log_with_kv(logging.ERROR, msg, args, **kwargs)
278
276
 
279
- def log(self, level: int, msg: object, *args: object, **kwargs: Any) -> None: # type: ignore[override]
280
- if not isinstance(level, int):
277
+ def log(self, level: int, msg: object, *args: object, **kwargs: Any) -> None:
278
+ if not isinstance(level, int): # pyright: ignore[reportUnnecessaryIsInstance]
281
279
  raise TypeError("level must be an int")
282
280
  if self.isEnabledFor(level):
283
281
  self._log_with_kv(level, msg, args, **kwargs)
@@ -313,7 +311,7 @@ class _ThirdPartySelectorFilter(logging.Filter):
313
311
  self.app_logger_prefix = app_logger_prefix
314
312
  self.spotlight_prefixes = spotlight_prefixes
315
313
 
316
- def filter(self, record: logging.LogRecord) -> bool: # noqa: A003
314
+ def filter(self, record: logging.LogRecord) -> bool:
317
315
  name = record.name
318
316
  if name == self.app_logger_prefix or name.startswith(self.app_logger_prefix + "."):
319
317
  return True
@@ -466,9 +464,7 @@ def configure_logging(
466
464
  handler.setLevel(logging.NOTSET)
467
465
  handler.setFormatter(formatter)
468
466
  handler.addFilter(
469
- _ThirdPartySelectorFilter(
470
- app_logger_prefix=app_logger_prefix, spotlight_prefixes=tuple(spotlight)
471
- )
467
+ _ThirdPartySelectorFilter(app_logger_prefix=app_logger_prefix, spotlight_prefixes=tuple(spotlight))
472
468
  )
473
469
 
474
470
  # Configure root.
@@ -532,11 +528,7 @@ def iter_recent_log_lines(log_file: Path, since: timedelta) -> list[str]:
532
528
  cutoff = _now_utc() - since
533
529
 
534
530
  candidates = sorted(
535
- [
536
- p
537
- for p in log_file.parent.glob(log_file.name + "*")
538
- if p.is_file() and not p.name.endswith(".gz")
539
- ],
531
+ [p for p in log_file.parent.glob(log_file.name + "*") if p.is_file() and not p.name.endswith(".gz")],
540
532
  key=lambda p: p.stat().st_mtime,
541
533
  )
542
534
 
@@ -556,6 +548,46 @@ def iter_recent_log_lines(log_file: Path, since: timedelta) -> list[str]:
556
548
  return lines
557
549
 
558
550
 
551
+ # Initial backward-probe size for locating the start of the --since window in a
552
+ # large file. A typical multi-minute window fits in one or two probes; the probe
553
+ # doubles when the window is larger, so worst case is still O(window), not O(file).
554
+ _WINDOW_PROBE_BYTES = 262144 # 256 KiB
555
+
556
+
557
+ def _window_start_offset(path: Path, cutoff: datetime) -> int:
558
+ """Return a byte offset at or before the first line newer than `cutoff`.
559
+
560
+ A log file is appended in timestamp order, so the window's start can be found
561
+ by probing backward from EOF in exponentially growing chunks instead of
562
+ scanning from byte 0: once a probe's earliest timestamped line is already
563
+ older than the cutoff, the window begins within that probe and everything
564
+ before it is skippable. Returns 0 when the window spans the whole file (or the
565
+ file is smaller than a single probe), which reproduces a full forward scan.
566
+ """
567
+ try:
568
+ size = path.stat().st_size
569
+ except OSError:
570
+ return 0
571
+ if size <= _WINDOW_PROBE_BYTES:
572
+ return 0
573
+
574
+ probe = _WINDOW_PROBE_BYTES
575
+ with path.open("rb") as fb:
576
+ while probe < size:
577
+ start = size - probe
578
+ fb.seek(start)
579
+ fb.readline() # discard the partial line spanning the probe boundary
580
+ first_ts: datetime | None = None
581
+ for raw in fb:
582
+ first_ts = parse_log_timestamp(raw.decode("utf-8", errors="replace"))
583
+ if first_ts is not None:
584
+ break
585
+ if first_ts is not None and first_ts < cutoff:
586
+ return start
587
+ probe *= 2
588
+ return 0
589
+
590
+
559
591
  def iter_recent_log_lines_merged(files: Iterable[Path], since: timedelta) -> Iterator[str]:
560
592
  """Yield lines newer than now-`since` merged across multiple files by timestamp.
561
593
 
@@ -575,12 +607,17 @@ def iter_recent_log_lines_merged(files: Iterable[Path], since: timedelta) -> Ite
575
607
 
576
608
  def _file_stream(path: Path) -> Iterator[tuple[datetime, str]]:
577
609
  try:
578
- f = path.open("r", encoding="utf-8", errors="replace")
610
+ fb = path.open("rb")
579
611
  except FileNotFoundError:
580
612
  return
581
613
  try:
614
+ offset = _window_start_offset(path, cutoff)
615
+ if offset:
616
+ fb.seek(offset)
617
+ fb.readline() # discard the partial line at the seek boundary
582
618
  last_ts: datetime | None = None
583
- for line in f:
619
+ for raw in fb:
620
+ line = raw.decode("utf-8", errors="replace")
584
621
  ts = parse_log_timestamp(line)
585
622
  if ts is None:
586
623
  if last_ts is None or last_ts < cutoff:
@@ -592,12 +629,12 @@ def iter_recent_log_lines_merged(files: Iterable[Path], since: timedelta) -> Ite
592
629
  continue
593
630
  yield (ts, line)
594
631
  finally:
595
- f.close()
632
+ fb.close()
596
633
 
597
634
  eligible: list[Path] = []
598
635
  for path in files:
599
636
  try:
600
- mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
637
+ mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=UTC)
601
638
  except OSError:
602
639
  continue
603
640
  if mtime < cutoff:
@@ -1,12 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: instruktai-python-logger
3
- Version: 0.4.5
3
+ Version: 0.5.1
4
4
  Summary: Centralized logging utilities for Python services.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
- Provides-Extra: dev
8
- Requires-Dist: pytest>=8; extra == "dev"
9
- Requires-Dist: ruff>=0.8; extra == "dev"
10
7
 
11
8
  # InstruktAI Python Logger
12
9
 
@@ -22,7 +19,7 @@ Publishing notes live in `docs/publishing.md`.
22
19
  From PyPI (recommended):
23
20
 
24
21
  ```bash
25
- pip install instrukt-ai-logger
22
+ pip install instruktai-python-logger
26
23
  ```
27
24
 
28
25
  From GitHub:
@@ -9,9 +9,9 @@ instruktai_python_logger.egg-info/PKG-INFO
9
9
  instruktai_python_logger.egg-info/SOURCES.txt
10
10
  instruktai_python_logger.egg-info/dependency_links.txt
11
11
  instruktai_python_logger.egg-info/entry_points.txt
12
- instruktai_python_logger.egg-info/requires.txt
13
12
  instruktai_python_logger.egg-info/top_level.txt
14
13
  tests/test_cli_follow.py
15
14
  tests/test_configure_logging.py
15
+ tests/test_install.py
16
16
  tests/test_log_aggregation.py
17
17
  tests/test_trace_logging.py