instruktai-python-logger 0.6.5__tar.gz → 0.6.7__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.6.5 → instruktai_python_logger-0.6.7}/PKG-INFO +1 -1
  2. {instruktai_python_logger-0.6.5 → instruktai_python_logger-0.6.7}/instrukt_ai_logging/__init__.py +17 -16
  3. {instruktai_python_logger-0.6.5 → instruktai_python_logger-0.6.7}/instrukt_ai_logging/cli.py +24 -0
  4. {instruktai_python_logger-0.6.5 → instruktai_python_logger-0.6.7}/instrukt_ai_logging/install.py +14 -11
  5. {instruktai_python_logger-0.6.5 → instruktai_python_logger-0.6.7}/instrukt_ai_logging/logging.py +89 -1
  6. {instruktai_python_logger-0.6.5 → instruktai_python_logger-0.6.7}/instruktai_python_logger.egg-info/PKG-INFO +1 -1
  7. {instruktai_python_logger-0.6.5 → instruktai_python_logger-0.6.7}/pyproject.toml +2 -2
  8. {instruktai_python_logger-0.6.5 → instruktai_python_logger-0.6.7}/tests/test_configure_logging.py +2 -3
  9. {instruktai_python_logger-0.6.5 → instruktai_python_logger-0.6.7}/tests/test_install.py +3 -9
  10. {instruktai_python_logger-0.6.5 → instruktai_python_logger-0.6.7}/tests/test_log_aggregation.py +2 -3
  11. {instruktai_python_logger-0.6.5 → instruktai_python_logger-0.6.7}/tests/test_trace_logging.py +2 -3
  12. {instruktai_python_logger-0.6.5 → instruktai_python_logger-0.6.7}/README.md +0 -0
  13. {instruktai_python_logger-0.6.5 → instruktai_python_logger-0.6.7}/instrukt_ai_logging/py.typed +0 -0
  14. {instruktai_python_logger-0.6.5 → instruktai_python_logger-0.6.7}/instruktai_python_logger.egg-info/SOURCES.txt +0 -0
  15. {instruktai_python_logger-0.6.5 → instruktai_python_logger-0.6.7}/instruktai_python_logger.egg-info/dependency_links.txt +0 -0
  16. {instruktai_python_logger-0.6.5 → instruktai_python_logger-0.6.7}/instruktai_python_logger.egg-info/entry_points.txt +0 -0
  17. {instruktai_python_logger-0.6.5 → instruktai_python_logger-0.6.7}/instruktai_python_logger.egg-info/top_level.txt +0 -0
  18. {instruktai_python_logger-0.6.5 → instruktai_python_logger-0.6.7}/setup.cfg +0 -0
  19. {instruktai_python_logger-0.6.5 → instruktai_python_logger-0.6.7}/tests/test_cli_follow.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: instruktai-python-logger
3
- Version: 0.6.5
3
+ Version: 0.6.7
4
4
  Summary: Centralized logging utilities for Python services.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -3,6 +3,19 @@
3
3
  See `README.md` for usage and `docs/design.md` for design intent.
4
4
  """
5
5
 
6
+ from importlib.metadata import PackageNotFoundError
7
+ from importlib.metadata import version as _pkg_version
8
+
9
+ from instrukt_ai_logging.logging import (
10
+ TRACE,
11
+ InstruktAILogger,
12
+ InstruktAILoggerProtocol,
13
+ configure_logging,
14
+ get_logger,
15
+ resolve_log_file,
16
+ resolve_log_files,
17
+ )
18
+
6
19
  __all__ = [
7
20
  "TRACE",
8
21
  "InstruktAILogger",
@@ -14,21 +27,9 @@ __all__ = [
14
27
  "resolve_log_files",
15
28
  ]
16
29
 
17
- try:
18
- from importlib.metadata import packages_distributions
19
- from importlib.metadata import version as _pkg_version
30
+ _DISTRIBUTION_NAME = "instruktai-python-logger"
20
31
 
21
- _dists = packages_distributions().get(__name__, [])
22
- __version__ = _pkg_version(_dists[0]) if _dists else "0.0.0"
23
- except Exception: # pragma: no cover
32
+ try:
33
+ __version__ = _pkg_version(_DISTRIBUTION_NAME)
34
+ except PackageNotFoundError:
24
35
  __version__ = "0.0.0"
25
-
26
- from instrukt_ai_logging.logging import (
27
- TRACE,
28
- InstruktAILogger,
29
- InstruktAILoggerProtocol,
30
- configure_logging,
31
- get_logger,
32
- resolve_log_file,
33
- resolve_log_files,
34
- )
@@ -7,10 +7,12 @@ import sys
7
7
  import threading
8
8
  import time
9
9
  from collections.abc import Iterator
10
+ from datetime import datetime
10
11
  from pathlib import Path
11
12
 
12
13
  from instrukt_ai_logging.logging import (
13
14
  iter_recent_log_lines_merged,
15
+ newest_log_timestamp,
14
16
  parse_since,
15
17
  resolve_log_files,
16
18
  )
@@ -98,6 +100,18 @@ def iter_follow_lines(
98
100
  pass
99
101
 
100
102
 
103
+ _TIMESTAMP_STRFTIME_FORMAT = "%Y-%m-%dT%H:%M:%S"
104
+ _TIMESTAMP_MS_SUFFIX_TEMPLATE = ".{ms:03d}Z"
105
+ _NO_TIMESTAMPED_ENTRY_LABEL = "no timestamped entry"
106
+ _EMPTY_RESULT_NOTE_TEMPLATE = "No lines matched. Resolved log root: {log_root}. Newest entry in that root: {newest}."
107
+
108
+
109
+ def _format_timestamp(ts: datetime) -> str:
110
+ """Format a timestamp the same way log lines carry theirs (ms precision, `Z`)."""
111
+ ms = int(ts.microsecond / 1000)
112
+ return ts.strftime(_TIMESTAMP_STRFTIME_FORMAT) + _TIMESTAMP_MS_SUFFIX_TEMPLATE.format(ms=ms)
113
+
114
+
101
115
  def _parse_stems(value: str) -> list[str]:
102
116
  return [item.strip() for item in value.split(",") if item.strip()]
103
117
 
@@ -236,11 +250,21 @@ def main() -> None:
236
250
  raise SystemExit(f"No log files matched stems {stems} in app '{args.app}'. Available: {available_str}")
237
251
  raise SystemExit(f"No log files found for app '{args.app}'")
238
252
 
253
+ wrote_a_line = False
239
254
  for line in iter_recent_log_lines_merged(files, since):
240
255
  if _line_passes(line, keep=pattern, drop=exclude_pattern):
241
256
  sys.stdout.write(line)
257
+ wrote_a_line = True
242
258
 
243
259
  if not args.follow:
260
+ if not wrote_a_line:
261
+ log_root = files[0].parent
262
+ newest = newest_log_timestamp(files)
263
+ newest_desc = _format_timestamp(newest) if newest is not None else _NO_TIMESTAMPED_ENTRY_LABEL
264
+ print(
265
+ _EMPTY_RESULT_NOTE_TEMPLATE.format(log_root=log_root, newest=newest_desc),
266
+ file=sys.stderr,
267
+ )
244
268
  return
245
269
 
246
270
  sys.stdout.flush()
@@ -17,6 +17,17 @@ from pathlib import Path
17
17
  _NEWSYSLOG_BIN = "/usr/sbin/newsyslog"
18
18
  _LAUNCHD_LABEL = "ai.instrukt.log-rotate"
19
19
  _SYSTEMD_UNIT_NAME = "instrukt-ai-logrotate"
20
+ _ARCHIVE_DEPTH = 30
21
+ _NEWSYSLOG_CONF_TEMPLATE = "{log_root}/*/*.log 640 {archive_depth} 50000 * ZG\n"
22
+ _LOGROTATE_CONF_TEMPLATE = """{log_root}/*/*.log {{
23
+ size 50M
24
+ rotate {archive_depth}
25
+ compress
26
+ delaycompress
27
+ missingok
28
+ notifempty
29
+ }}
30
+ """
20
31
 
21
32
 
22
33
  def _state_home_root() -> Path:
@@ -55,8 +66,8 @@ def _systemd_timer_path() -> Path:
55
66
 
56
67
 
57
68
  def _newsyslog_conf(log_root: Path) -> str:
58
- """One glob line: 5 gzipped archives at 50 MB, evaluated at rotation time (G flag)."""
59
- return f"{log_root}/*/*.log 640 5 50000 * ZG\n"
69
+ """One glob line for gzip-compressed archive retention."""
70
+ return _NEWSYSLOG_CONF_TEMPLATE.format(log_root=log_root, archive_depth=_ARCHIVE_DEPTH)
60
71
 
61
72
 
62
73
  def _launchd_plist(conf_path: Path) -> str:
@@ -87,15 +98,7 @@ def _launchd_plist(conf_path: Path) -> str:
87
98
 
88
99
  def _logrotate_conf(log_root: Path) -> str:
89
100
  """Default move+create rotation; WatchedFileHandler reopens on inode change."""
90
- return f"""{log_root}/*/*.log {{
91
- size 50M
92
- rotate 5
93
- compress
94
- delaycompress
95
- missingok
96
- notifempty
97
- }}
98
- """
101
+ return _LOGROTATE_CONF_TEMPLATE.format(log_root=log_root, archive_depth=_ARCHIVE_DEPTH)
99
102
 
100
103
 
101
104
  def _systemd_service(conf_path: Path, state_file: Path) -> str:
@@ -10,7 +10,7 @@ from dataclasses import dataclass
10
10
  from datetime import UTC, datetime, timedelta
11
11
  from logging.handlers import WatchedFileHandler
12
12
  from pathlib import Path
13
- from typing import Any, Protocol, cast, runtime_checkable
13
+ from typing import Any, Literal, Protocol, cast, runtime_checkable
14
14
 
15
15
  # Standard logging levels are: NOTSET=0, DEBUG=10, INFO=20, WARNING=30, ERROR=40, CRITICAL=50
16
16
  TRACE: int = 5
@@ -587,6 +587,94 @@ def _window_start_offset(path: Path, cutoff: datetime) -> int:
587
587
  return 0
588
588
 
589
589
 
590
+ _GZIP_SUFFIX = ".gz"
591
+ _BINARY_READ_MODE: Literal["rb"] = "rb"
592
+ _TEXT_DECODE_ENCODING = "utf-8"
593
+ _TEXT_DECODE_ERRORS = "replace"
594
+
595
+
596
+ def _tail_timestamp(path: Path) -> datetime | None:
597
+ """Return the newest parsable log timestamp near the tail of `path`, or `None`.
598
+
599
+ A log file is appended in timestamp order, so its newest entry is always at
600
+ the tail: probe backward in exponentially growing chunks (the same
601
+ `_WINDOW_PROBE_BYTES` doubling idiom `_window_start_offset` uses) until the
602
+ probed span holds at least one parsable timestamp, then return the last one
603
+ seen while scanning that span forward to EOF — ascending order makes that the
604
+ maximum. Falls back to a full scan only when no probe finds a timestamp at
605
+ all. Gzip archives have no seek and are streamed from the start; the caller's
606
+ mtime bound already governs whether this is called on one.
607
+
608
+ Only a vanished file (`FileNotFoundError`, the same race every sibling probe
609
+ in this module already tolerates) is treated as absence. A file that exists
610
+ but cannot be read or decompressed — permission denied, a truncated or
611
+ corrupt archive — propagates its exception rather than being reported as
612
+ holding no timestamp.
613
+ """
614
+ if path.name.endswith(_GZIP_SUFFIX):
615
+ newest: datetime | None = None
616
+ try:
617
+ with gzip.open(path, _BINARY_READ_MODE) as fb:
618
+ for raw in fb:
619
+ ts = parse_log_timestamp(raw.decode(_TEXT_DECODE_ENCODING, errors=_TEXT_DECODE_ERRORS))
620
+ if ts is not None:
621
+ newest = ts
622
+ except FileNotFoundError:
623
+ return None
624
+ return newest
625
+
626
+ try:
627
+ size = path.stat().st_size
628
+ except FileNotFoundError:
629
+ return None
630
+
631
+ probe = _WINDOW_PROBE_BYTES
632
+ with path.open(_BINARY_READ_MODE) as fb:
633
+ while True:
634
+ start = max(0, size - probe)
635
+ fb.seek(start)
636
+ if start:
637
+ fb.readline() # discard the partial line spanning the probe boundary
638
+ newest = None
639
+ for raw in fb:
640
+ ts = parse_log_timestamp(raw.decode(_TEXT_DECODE_ENCODING, errors=_TEXT_DECODE_ERRORS))
641
+ if ts is not None:
642
+ newest = ts
643
+ if newest is not None or start == 0:
644
+ return newest
645
+ probe *= 2
646
+
647
+
648
+ def newest_log_timestamp(files: Iterable[Path]) -> datetime | None:
649
+ """Return the newest parsable log timestamp across `files`, or `None`.
650
+
651
+ Examines files in descending mtime order. A file's entries never postdate
652
+ its own mtime — the bound `iter_recent_log_lines_merged` already relies on
653
+ to skip whole files — so once a remaining candidate's mtime can no longer
654
+ beat the best timestamp found so far, no later candidate can either and the
655
+ scan stops. A single file's mtime is not enough on its own to answer this:
656
+ the root holds independent per-stem streams and their rotations, so the
657
+ newest entry can live in a file that was not the most recently touched.
658
+ """
659
+ candidates: list[tuple[float, Path]] = []
660
+ for path in files:
661
+ try:
662
+ mtime = path.stat().st_mtime
663
+ except FileNotFoundError:
664
+ continue
665
+ candidates.append((mtime, path))
666
+ candidates.sort(key=lambda item: item[0], reverse=True)
667
+
668
+ best: datetime | None = None
669
+ for mtime, path in candidates:
670
+ if best is not None and datetime.fromtimestamp(mtime, tz=UTC) <= best:
671
+ break
672
+ ts = _tail_timestamp(path)
673
+ if ts is not None and (best is None or ts > best):
674
+ best = ts
675
+ return best
676
+
677
+
590
678
  def iter_recent_log_lines_merged(files: Iterable[Path], since: timedelta) -> Iterator[str]:
591
679
  """Yield lines newer than now-`since` merged across multiple files by timestamp.
592
680
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: instruktai-python-logger
3
- Version: 0.6.5
3
+ Version: 0.6.7
4
4
  Summary: Centralized logging utilities for Python services.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "instruktai-python-logger"
7
- version = "0.6.5"
7
+ version = "0.6.7"
8
8
  description = "Centralized logging utilities for Python services."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
11
11
  dependencies = []
12
12
 
13
13
  [dependency-groups]
14
- dev = ["pytest>=8", "pytest-timeout>=2.2.0", "ruff>=0.8", "pyright>=1.1", "mypy>=1.10"]
14
+ dev = ["pytest>=8", "pytest-timeout>=2.2.0", "ruff>=0.8", "pyright>=1.1", "mypy>=1.10", "syrupy>=4"]
15
15
 
16
16
  [project.scripts]
17
17
  instrukt-ai-logs = "instrukt_ai_logging.cli:main"
@@ -7,9 +7,8 @@ from tempfile import TemporaryDirectory
7
7
  import pytest
8
8
  from instrukt_ai_logging import InstruktAILogger, configure_logging, get_logger
9
9
 
10
- # Log markers shared by each test and its assertions — named constants per
11
- # software-development/procedure/snapshot-testing (no bare literals in content
12
- # assertions). The logfmt field fragments are derived from the markers so the
10
+ # Log markers shared by each test and its assertions — named
11
+ # constants. The logfmt field fragments are derived from the markers so the
13
12
  # expected output stays in sync with what is logged.
14
13
  _LOGGER_OURS = "teleclaude.core"
15
14
  _LOGGER_HTTPCORE = "httpcore.http11"
@@ -8,12 +8,9 @@ from pathlib import Path
8
8
  import pytest
9
9
  from instrukt_ai_logging import install
10
10
 
11
- # Protocol-significant tokens for the rotation configs — named constants per
12
- # software-development/procedure/snapshot-testing (no bare literals in content
13
- # assertions). These are newsyslog/logrotate/launchd/systemd field identities,
14
- # not prose.
11
+ # Protocol-significant tokens the rotation-config assertions check for, named as
12
+ # constants. These are newsyslog/logrotate/launchd/systemd field identities, not prose.
15
13
  _NEWSYSLOG_MODE = "640"
16
- _NEWSYSLOG_COUNT = "5"
17
14
  _NEWSYSLOG_SIZE_KB = "50000"
18
15
  _NEWSYSLOG_WHEN = "*"
19
16
  _NEWSYSLOG_COMPRESS_GLOB_FLAGS = "ZG" # gzip-compress archives; glob-expand at rotation time
@@ -22,7 +19,6 @@ _PLIST_PROGRAM = "/usr/sbin/newsyslog"
22
19
  _PLIST_NO_ROOT_FLAG = "<string>-r</string>" # newsyslog otherwise refuses to run as a non-root user
23
20
  _PLIST_START_INTERVAL = "1800"
24
21
  _LOGROTATE_SIZE = "size 50M"
25
- _LOGROTATE_ROTATE = "rotate 5"
26
22
  _LOGROTATE_COMPRESS = "compress"
27
23
  _LOGROTATE_DELAYCOMPRESS = "delaycompress"
28
24
  _LOGROTATE_MISSINGOK = "missingok"
@@ -82,10 +78,9 @@ def test_ensure_rotation_darwin_writes_newsyslog_conf_and_plist(
82
78
 
83
79
  assert problems == []
84
80
  conf_content = install._newsyslog_conf_path().read_text(encoding="utf-8")
85
- path, mode, count, size_kb, when, flags = conf_content.split()
81
+ path, mode, _count, size_kb, when, flags = conf_content.split()
86
82
  assert path == f"{rotation_env / 'instrukt-ai'}/*/*.log"
87
83
  assert mode == _NEWSYSLOG_MODE
88
- assert count == _NEWSYSLOG_COUNT
89
84
  assert size_kb == _NEWSYSLOG_SIZE_KB
90
85
  assert when == _NEWSYSLOG_WHEN
91
86
  assert flags == _NEWSYSLOG_COMPRESS_GLOB_FLAGS
@@ -115,7 +110,6 @@ def test_ensure_rotation_linux_writes_logrotate_conf_and_systemd_units(
115
110
  assert f"{rotation_env / 'instrukt-ai'}/*/*.log" in logrotate_content
116
111
  for directive in (
117
112
  _LOGROTATE_SIZE,
118
- _LOGROTATE_ROTATE,
119
113
  _LOGROTATE_COMPRESS,
120
114
  _LOGROTATE_DELAYCOMPRESS,
121
115
  _LOGROTATE_MISSINGOK,
@@ -19,9 +19,8 @@ def _write(path: Path, content: str) -> None:
19
19
  path.write_text(content, encoding="utf-8")
20
20
 
21
21
 
22
- # Log markers shared by each fixture and its assertions — named constants per
23
- # software-development/procedure/snapshot-testing (no bare literals in content
24
- # assertions).
22
+ # Log markers shared by each fixture and its assertions — named
23
+ # constants.
25
24
  _MSG_FRESH = "fresh"
26
25
  _MSG_BOOM = "boom"
27
26
  _MSG_AFTER = "after"
@@ -7,9 +7,8 @@ from instrukt_ai_logging.logging import (
7
7
  get_logger,
8
8
  )
9
9
 
10
- # Log markers shared by each test and its assertions — named constants per
11
- # software-development/procedure/snapshot-testing (no bare literals in content
12
- # assertions). The logfmt field fragments are derived from the markers so the
10
+ # Log markers shared by each test and its assertions — named
11
+ # constants. The logfmt field fragments are derived from the markers so the
13
12
  # expected output stays in sync with what is logged.
14
13
  _TRACE_MSG = "This is a trace message"
15
14
  _DEBUG_MSG = "This is a debug message"