instruktai-python-logger 0.5.0__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 (19) hide show
  1. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.5.1}/PKG-INFO +1 -1
  2. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.5.1}/instrukt_ai_logging/logging.py +48 -3
  3. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.5.1}/instruktai_python_logger.egg-info/PKG-INFO +1 -1
  4. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.5.1}/pyproject.toml +1 -1
  5. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.5.1}/tests/test_log_aggregation.py +56 -0
  6. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.5.1}/README.md +0 -0
  7. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.5.1}/instrukt_ai_logging/__init__.py +0 -0
  8. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.5.1}/instrukt_ai_logging/cli.py +0 -0
  9. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.5.1}/instrukt_ai_logging/install.py +0 -0
  10. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.5.1}/instrukt_ai_logging/py.typed +0 -0
  11. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.5.1}/instruktai_python_logger.egg-info/SOURCES.txt +0 -0
  12. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.5.1}/instruktai_python_logger.egg-info/dependency_links.txt +0 -0
  13. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.5.1}/instruktai_python_logger.egg-info/entry_points.txt +0 -0
  14. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.5.1}/instruktai_python_logger.egg-info/top_level.txt +0 -0
  15. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.5.1}/setup.cfg +0 -0
  16. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.5.1}/tests/test_cli_follow.py +0 -0
  17. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.5.1}/tests/test_configure_logging.py +0 -0
  18. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.5.1}/tests/test_install.py +0 -0
  19. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.5.1}/tests/test_trace_logging.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: instruktai-python-logger
3
- Version: 0.5.0
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
@@ -548,6 +548,46 @@ def iter_recent_log_lines(log_file: Path, since: timedelta) -> list[str]:
548
548
  return lines
549
549
 
550
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
+
551
591
  def iter_recent_log_lines_merged(files: Iterable[Path], since: timedelta) -> Iterator[str]:
552
592
  """Yield lines newer than now-`since` merged across multiple files by timestamp.
553
593
 
@@ -567,12 +607,17 @@ def iter_recent_log_lines_merged(files: Iterable[Path], since: timedelta) -> Ite
567
607
 
568
608
  def _file_stream(path: Path) -> Iterator[tuple[datetime, str]]:
569
609
  try:
570
- f = path.open("r", encoding="utf-8", errors="replace")
610
+ fb = path.open("rb")
571
611
  except FileNotFoundError:
572
612
  return
573
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
574
618
  last_ts: datetime | None = None
575
- for line in f:
619
+ for raw in fb:
620
+ line = raw.decode("utf-8", errors="replace")
576
621
  ts = parse_log_timestamp(line)
577
622
  if ts is None:
578
623
  if last_ts is None or last_ts < cutoff:
@@ -584,7 +629,7 @@ def iter_recent_log_lines_merged(files: Iterable[Path], since: timedelta) -> Ite
584
629
  continue
585
630
  yield (ts, line)
586
631
  finally:
587
- f.close()
632
+ fb.close()
588
633
 
589
634
  eligible: list[Path] = []
590
635
  for path in files:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: instruktai-python-logger
3
- Version: 0.5.0
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "instruktai-python-logger"
7
- version = "0.5.0"
7
+ version = "0.5.1"
8
8
  description = "Centralized logging utilities for Python services."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -34,6 +34,9 @@ _MSG_GIT_ERROR = "git-error"
34
34
  _MSG_INFO = "info-line"
35
35
  _MSG_CRON = "cron-line"
36
36
  _MSG_DAEMON = "daemon-line"
37
+ _MSG_OLD_PREFIX = "msg=old-"
38
+ _MSG_LINE_PREFIX = "msg=line-"
39
+ _LARGE_LINE_COUNT = 20000
37
40
 
38
41
 
39
42
  @pytest.fixture()
@@ -176,6 +179,59 @@ def test_iter_recent_log_lines_merged_keeps_continuation_lines_with_parent(
176
179
  assert _MSG_AFTER in lines[3]
177
180
 
178
181
 
182
+ def test_iter_recent_log_lines_merged_reads_tail_window_of_large_file(
183
+ app_log_dir: Path,
184
+ ) -> None:
185
+ # A large body of old lines (well past the 256 KiB backward probe) followed by
186
+ # a recent window that includes a multi-line entry. The reader must return only
187
+ # the recent lines — proving it locates the window near EOF rather than scanning
188
+ # the whole file — and must keep the continuation line with its parent across
189
+ # the seek boundary.
190
+ daemon = app_log_dir / "demo-app.log"
191
+ old_block = "".join(
192
+ f"{_now_iso(-30)} level=INFO logger=demo.daemon {_MSG_OLD_PREFIX}{i}\n" for i in range(_LARGE_LINE_COUNT)
193
+ )
194
+ recent_block = (
195
+ f"{_now_iso(-1)} level=ERROR logger=demo.daemon msg={_MSG_BOOM}\n"
196
+ f"{_TRACEBACK_LINE}\n"
197
+ f"{_now_iso(0)} level=INFO logger=demo.daemon msg={_MSG_AFTER}\n"
198
+ )
199
+ daemon.write_text(old_block + recent_block, encoding="utf-8")
200
+ assert daemon.stat().st_size > 262144 # forces the backward-probe path (offset > 0)
201
+
202
+ files = resolve_log_files("demo-app")
203
+ lines = list(iter_recent_log_lines_merged(files, since=timedelta(minutes=5)))
204
+ assert len(lines) == 3
205
+ assert _MSG_BOOM in lines[0]
206
+ assert lines[1].startswith(_TRACEBACK_LINE)
207
+ assert _MSG_AFTER in lines[2]
208
+ assert all(_MSG_OLD_PREFIX not in line for line in lines)
209
+
210
+
211
+ def test_iter_recent_log_lines_merged_returns_whole_large_file_within_window(
212
+ app_log_dir: Path,
213
+ ) -> None:
214
+ # Every line is inside the window but the file exceeds one probe: the backward
215
+ # probe must grow to the file start (offset 0) and return all lines, not just
216
+ # the final probe's worth.
217
+ daemon = app_log_dir / "demo-app.log"
218
+ daemon.write_text(
219
+ "".join(
220
+ f"{_now_iso(-1)} level=INFO logger=demo.daemon {_MSG_LINE_PREFIX}{i}\n" for i in range(_LARGE_LINE_COUNT)
221
+ ),
222
+ encoding="utf-8",
223
+ )
224
+ assert daemon.stat().st_size > 262144
225
+
226
+ files = resolve_log_files("demo-app")
227
+ lines = list(iter_recent_log_lines_merged(files, since=timedelta(minutes=5)))
228
+ assert len(lines) == _LARGE_LINE_COUNT
229
+ first_marker = f"{_MSG_LINE_PREFIX}0"
230
+ last_marker = f"{_MSG_LINE_PREFIX}{_LARGE_LINE_COUNT - 1}"
231
+ assert first_marker in lines[0]
232
+ assert last_marker in lines[-1]
233
+
234
+
179
235
  def test_cli_exclude_drops_matching_lines(
180
236
  app_log_dir: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
181
237
  ) -> None: