instruktai-python-logger 0.5.0__tar.gz → 0.6.0__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 (21) hide show
  1. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/PKG-INFO +24 -9
  2. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/README.md +23 -8
  3. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/instrukt_ai_logging/cli.py +3 -1
  4. instruktai_python_logger-0.6.0/instrukt_ai_logging/install.py +232 -0
  5. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/instrukt_ai_logging/logging.py +65 -13
  6. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/instruktai_python_logger.egg-info/PKG-INFO +24 -9
  7. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/pyproject.toml +12 -3
  8. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/tests/test_configure_logging.py +86 -6
  9. instruktai_python_logger-0.6.0/tests/test_install.py +255 -0
  10. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/tests/test_log_aggregation.py +59 -3
  11. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/tests/test_trace_logging.py +2 -2
  12. instruktai_python_logger-0.5.0/instrukt_ai_logging/install.py +0 -96
  13. instruktai_python_logger-0.5.0/tests/test_install.py +0 -131
  14. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/instrukt_ai_logging/__init__.py +0 -0
  15. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/instrukt_ai_logging/py.typed +0 -0
  16. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/instruktai_python_logger.egg-info/SOURCES.txt +0 -0
  17. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/instruktai_python_logger.egg-info/dependency_links.txt +0 -0
  18. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/instruktai_python_logger.egg-info/entry_points.txt +0 -0
  19. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/instruktai_python_logger.egg-info/top_level.txt +0 -0
  20. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/setup.cfg +0 -0
  21. {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/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.5.0
3
+ Version: 0.6.0
4
4
  Summary: Centralized logging utilities for Python services.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -58,8 +58,8 @@ configure_logging("teleclaude", source="cron") # → cron.log
58
58
  ```
59
59
 
60
60
  The `.log` extension is always appended; pass the bare stem. Files all live
61
- under `/var/log/instrukt-ai/{app}/`. The CLI merges them transparently
62
- (see below).
61
+ under `$XDG_STATE_HOME/instrukt-ai/{app}/` (fallback `~/.local/state`). The CLI
62
+ merges them transparently (see below).
63
63
 
64
64
  ## Environment variables (contract)
65
65
 
@@ -70,17 +70,32 @@ Per-app prefix model (example uses `TELECLAUDE_`):
70
70
  - `TELECLAUDE_THIRD_PARTY_LOGGERS` (comma-separated logger prefixes to spotlight, e.g. `httpcore,telegram`)
71
71
  - `TELECLAUDE_MUTED_LOGGERS` (comma-separated logger prefixes to force to WARNING+, e.g. `teleclaude.cli.tui`)
72
72
 
73
- Global:
74
-
75
- - `INSTRUKT_AI_LOG_ROOT` (optional log root override)
73
+ These four per-app variables are the entire contract. There is no log-root
74
+ override variable — the location is a fixed rule (see below).
76
75
 
77
76
  ## Log location (contract)
78
77
 
79
- Default target:
78
+ Every app's logs live at the same predictable, prose-citable location on both
79
+ macOS and Linux:
80
+
81
+ - `$XDG_STATE_HOME/instrukt-ai/{app}/{app}.log` (fallback `~/.local/state`,
82
+ per the XDG Base Directory spec)
83
+
84
+ This path is always user-writable — no installer step, no sudo, no elevated
85
+ permissions. `configure_logging(...)` raises immediately if the log file
86
+ cannot be opened; logging is an essential subsystem and a process that cannot
87
+ write its logs must not start blind.
80
88
 
81
- - `/var/log/instrukt-ai/{app}/{app}.log`
89
+ ## Rotation
82
90
 
83
- The installer for each service is expected to create the directory and set write permissions for the daemon user. If the default location is not writable, the implementation will fall back to a user-writable directory and/or require `INSTRUKT_AI_LOG_ROOT`.
91
+ Rotation is transparent and runs entirely in user space:
92
+ `configure_logging(...)` idempotently ensures a per-user rotation conf plus a
93
+ per-user scheduler (a launchd LaunchAgent on macOS running `newsyslog`, a
94
+ systemd user timer on Linux running `logrotate`) on every process start. No
95
+ `/etc` configuration, no sudo, and no system rotation daemon ever touches
96
+ these files, so rotated/recreated logs always stay owned by the producing
97
+ user. Run `instrukt-ai-log-setup` to check rotation-asset status manually;
98
+ normal operation never requires it.
84
99
 
85
100
  ## CLI
86
101
 
@@ -51,8 +51,8 @@ configure_logging("teleclaude", source="cron") # → cron.log
51
51
  ```
52
52
 
53
53
  The `.log` extension is always appended; pass the bare stem. Files all live
54
- under `/var/log/instrukt-ai/{app}/`. The CLI merges them transparently
55
- (see below).
54
+ under `$XDG_STATE_HOME/instrukt-ai/{app}/` (fallback `~/.local/state`). The CLI
55
+ merges them transparently (see below).
56
56
 
57
57
  ## Environment variables (contract)
58
58
 
@@ -63,17 +63,32 @@ Per-app prefix model (example uses `TELECLAUDE_`):
63
63
  - `TELECLAUDE_THIRD_PARTY_LOGGERS` (comma-separated logger prefixes to spotlight, e.g. `httpcore,telegram`)
64
64
  - `TELECLAUDE_MUTED_LOGGERS` (comma-separated logger prefixes to force to WARNING+, e.g. `teleclaude.cli.tui`)
65
65
 
66
- Global:
67
-
68
- - `INSTRUKT_AI_LOG_ROOT` (optional log root override)
66
+ These four per-app variables are the entire contract. There is no log-root
67
+ override variable — the location is a fixed rule (see below).
69
68
 
70
69
  ## Log location (contract)
71
70
 
72
- Default target:
71
+ Every app's logs live at the same predictable, prose-citable location on both
72
+ macOS and Linux:
73
+
74
+ - `$XDG_STATE_HOME/instrukt-ai/{app}/{app}.log` (fallback `~/.local/state`,
75
+ per the XDG Base Directory spec)
76
+
77
+ This path is always user-writable — no installer step, no sudo, no elevated
78
+ permissions. `configure_logging(...)` raises immediately if the log file
79
+ cannot be opened; logging is an essential subsystem and a process that cannot
80
+ write its logs must not start blind.
73
81
 
74
- - `/var/log/instrukt-ai/{app}/{app}.log`
82
+ ## Rotation
75
83
 
76
- The installer for each service is expected to create the directory and set write permissions for the daemon user. If the default location is not writable, the implementation will fall back to a user-writable directory and/or require `INSTRUKT_AI_LOG_ROOT`.
84
+ Rotation is transparent and runs entirely in user space:
85
+ `configure_logging(...)` idempotently ensures a per-user rotation conf plus a
86
+ per-user scheduler (a launchd LaunchAgent on macOS running `newsyslog`, a
87
+ systemd user timer on Linux running `logrotate`) on every process start. No
88
+ `/etc` configuration, no sudo, and no system rotation daemon ever touches
89
+ these files, so rotated/recreated logs always stay owned by the producing
90
+ user. Run `instrukt-ai-log-setup` to check rotation-asset status manually;
91
+ normal operation never requires it.
77
92
 
78
93
  ## CLI
79
94
 
@@ -192,7 +192,9 @@ def main() -> None:
192
192
  action="version",
193
193
  version=f"%(prog)s {__version__}",
194
194
  )
195
- parser.add_argument("app", help="App/service name (folder under /var/log/instrukt-ai/)")
195
+ parser.add_argument(
196
+ "app", help="App/service name (folder under $XDG_STATE_HOME/instrukt-ai/, fallback ~/.local/state)"
197
+ )
196
198
  parser.add_argument("--since", default="10m", help="Time window (e.g. 10m, 2h, 1d). Default: 10m")
197
199
  parser.add_argument(
198
200
  "-f",
@@ -0,0 +1,232 @@
1
+ """Ensure user-space log rotation assets for InstruktAI logs.
2
+
3
+ Rotation runs entirely as the producing user: a per-user rotation conf plus a
4
+ per-user scheduler (launchd LaunchAgent on macOS, systemd user timer on
5
+ Linux). No `/etc` configuration, no sudo, no system rotation daemon ever
6
+ touches these files, so root-owned recreated logs are structurally
7
+ unexpressible.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import subprocess
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ _NEWSYSLOG_BIN = "/usr/sbin/newsyslog"
18
+ _LAUNCHD_LABEL = "ai.instrukt.log-rotate"
19
+ _SYSTEMD_UNIT_NAME = "instrukt-ai-logrotate"
20
+
21
+
22
+ def _state_home_root() -> Path:
23
+ """Resolve `$XDG_STATE_HOME/instrukt-ai` (fallback `~/.local/state`)."""
24
+ xdg_state_home = os.getenv("XDG_STATE_HOME")
25
+ state_home = Path(xdg_state_home).expanduser() if xdg_state_home else Path("~/.local/state").expanduser()
26
+ return state_home / "instrukt-ai"
27
+
28
+
29
+ def _config_dir() -> Path:
30
+ return Path.home() / ".config" / "instrukt-ai"
31
+
32
+
33
+ def _newsyslog_conf_path() -> Path:
34
+ return _config_dir() / "newsyslog.conf"
35
+
36
+
37
+ def _launchd_plist_path() -> Path:
38
+ return Path.home() / "Library" / "LaunchAgents" / f"{_LAUNCHD_LABEL}.plist"
39
+
40
+
41
+ def _logrotate_conf_path() -> Path:
42
+ return _config_dir() / "logrotate.conf"
43
+
44
+
45
+ def _systemd_user_dir() -> Path:
46
+ return Path.home() / ".config" / "systemd" / "user"
47
+
48
+
49
+ def _systemd_service_path() -> Path:
50
+ return _systemd_user_dir() / f"{_SYSTEMD_UNIT_NAME}.service"
51
+
52
+
53
+ def _systemd_timer_path() -> Path:
54
+ return _systemd_user_dir() / f"{_SYSTEMD_UNIT_NAME}.timer"
55
+
56
+
57
+ 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"
60
+
61
+
62
+ def _launchd_plist(conf_path: Path) -> str:
63
+ # newsyslog refuses to run at all unless invoked as root or with -r; our
64
+ # conf lines carry no pid/signal field, so -r's "won't HUP syslogd" caveat
65
+ # doesn't apply here.
66
+ return f"""<?xml version="1.0" encoding="UTF-8"?>
67
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
68
+ <plist version="1.0">
69
+ <dict>
70
+ <key>Label</key>
71
+ <string>{_LAUNCHD_LABEL}</string>
72
+ <key>ProgramArguments</key>
73
+ <array>
74
+ <string>{_NEWSYSLOG_BIN}</string>
75
+ <string>-r</string>
76
+ <string>-f</string>
77
+ <string>{conf_path}</string>
78
+ </array>
79
+ <key>StartInterval</key>
80
+ <integer>1800</integer>
81
+ <key>RunAtLoad</key>
82
+ <true/>
83
+ </dict>
84
+ </plist>
85
+ """
86
+
87
+
88
+ def _logrotate_conf(log_root: Path) -> str:
89
+ """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
+ """
99
+
100
+
101
+ def _systemd_service(conf_path: Path, state_file: Path) -> str:
102
+ return f"""[Unit]
103
+ Description=InstruktAI log rotation
104
+
105
+ [Service]
106
+ Type=oneshot
107
+ ExecStart=logrotate --state {state_file} {conf_path}
108
+ """
109
+
110
+
111
+ def _systemd_timer() -> str:
112
+ return """[Unit]
113
+ Description=InstruktAI log rotation timer
114
+
115
+ [Timer]
116
+ OnCalendar=*:0/30
117
+ Persistent=true
118
+
119
+ [Install]
120
+ WantedBy=timers.target
121
+ """
122
+
123
+
124
+ def _write_if_changed(path: Path, content: str) -> None:
125
+ if path.exists() and path.read_text(encoding="utf-8") == content:
126
+ return
127
+ path.parent.mkdir(parents=True, exist_ok=True)
128
+ path.write_text(content, encoding="utf-8")
129
+
130
+
131
+ _SCHEDULER_COMMAND_TIMEOUT_S = 10.0
132
+
133
+
134
+ def _run_scheduler_command(args: list[str]) -> subprocess.CompletedProcess[str]:
135
+ # No Python API wraps launchd's per-user service bus or the systemd user
136
+ # D-Bus/session manager; shell out to launchctl/systemctl. Routed through
137
+ # this wrapper (rather than calling subprocess.run directly) so tests can
138
+ # fake just this boundary without patching the shared subprocess module.
139
+ # A bounded timeout and closed stdin keep an unreachable service bus (no
140
+ # GUI/user session, e.g. a headless CI runner) from hanging the caller
141
+ # forever instead of failing fast.
142
+ return subprocess.run(
143
+ args,
144
+ capture_output=True,
145
+ text=True,
146
+ stdin=subprocess.DEVNULL,
147
+ timeout=_SCHEDULER_COMMAND_TIMEOUT_S,
148
+ )
149
+
150
+
151
+ def _ensure_launchd_loaded(plist_path: Path) -> list[str]:
152
+ uid = os.getuid()
153
+ try:
154
+ result = _run_scheduler_command(["launchctl", "bootstrap", f"gui/{uid}", str(plist_path)])
155
+ except (OSError, subprocess.TimeoutExpired) as exc:
156
+ return [f"launchctl bootstrap failed to run: {exc}"]
157
+
158
+ if result.returncode != 0 and "already bootstrapped" not in result.stderr.lower():
159
+ return [f"launchctl bootstrap failed: {result.stderr.strip()}"]
160
+ return []
161
+
162
+
163
+ def _ensure_systemd_loaded() -> list[str]:
164
+ try:
165
+ reload_result = _run_scheduler_command(["systemctl", "--user", "daemon-reload"])
166
+ except (OSError, subprocess.TimeoutExpired) as exc:
167
+ return [f"systemctl daemon-reload failed to run: {exc}"]
168
+
169
+ if reload_result.returncode != 0:
170
+ return [f"systemctl daemon-reload failed: {reload_result.stderr.strip()}"]
171
+
172
+ try:
173
+ enable_result = _run_scheduler_command(
174
+ ["systemctl", "--user", "enable", "--now", f"{_SYSTEMD_UNIT_NAME}.timer"]
175
+ )
176
+ except (OSError, subprocess.TimeoutExpired) as exc:
177
+ return [f"systemctl enable failed to run: {exc}"]
178
+
179
+ if enable_result.returncode != 0:
180
+ return [f"systemctl enable failed: {enable_result.stderr.strip()}"]
181
+ return []
182
+
183
+
184
+ def ensure_rotation() -> list[str]:
185
+ """Idempotently ensure user-space rotation assets exist and are scheduled.
186
+
187
+ Never raises. Returns human-readable problem strings for anything that
188
+ could not be resolved; an empty list means rotation is healthy.
189
+ """
190
+ log_root = _state_home_root()
191
+
192
+ if sys.platform == "darwin":
193
+ conf_path = _newsyslog_conf_path()
194
+ plist_path = _launchd_plist_path()
195
+ try:
196
+ _write_if_changed(conf_path, _newsyslog_conf(log_root))
197
+ _write_if_changed(plist_path, _launchd_plist(conf_path))
198
+ except OSError as exc:
199
+ return [f"could not write rotation assets: {exc}"]
200
+ return _ensure_launchd_loaded(plist_path)
201
+
202
+ conf_path = _logrotate_conf_path()
203
+ service_path = _systemd_service_path()
204
+ timer_path = _systemd_timer_path()
205
+ state_file = log_root / "logrotate.state"
206
+ try:
207
+ _write_if_changed(conf_path, _logrotate_conf(log_root))
208
+ _write_if_changed(service_path, _systemd_service(conf_path, state_file))
209
+ _write_if_changed(timer_path, _systemd_timer())
210
+ except OSError as exc:
211
+ return [f"could not write rotation assets: {exc}"]
212
+ return _ensure_systemd_loaded()
213
+
214
+
215
+ def main() -> None:
216
+ log_root = _state_home_root()
217
+ sys.stdout.write(f"log root: {log_root}\n")
218
+ sys.stdout.write(
219
+ f"rotation conf: {_newsyslog_conf_path() if sys.platform == 'darwin' else _logrotate_conf_path()}\n"
220
+ )
221
+ sys.stdout.write(f"scheduler: {_launchd_plist_path() if sys.platform == 'darwin' else _systemd_timer_path()}\n")
222
+
223
+ problems = ensure_rotation()
224
+ if problems:
225
+ for problem in problems:
226
+ sys.stdout.write(f"problem: {problem}\n")
227
+ raise SystemExit(1)
228
+ sys.stdout.write("status: healthy\n")
229
+
230
+
231
+ if __name__ == "__main__":
232
+ main()
@@ -11,6 +11,8 @@ from logging.handlers import WatchedFileHandler
11
11
  from pathlib import Path
12
12
  from typing import Any, Protocol, cast, runtime_checkable
13
13
 
14
+ from instrukt_ai_logging.install import ensure_rotation
15
+
14
16
  # Standard logging levels are: NOTSET=0, DEBUG=10, INFO=20, WARNING=30, ERROR=40, CRITICAL=50
15
17
  TRACE: int = 5
16
18
  logging.addLevelName(TRACE, "TRACE")
@@ -345,10 +347,9 @@ class LoggingContract:
345
347
 
346
348
  def _resolve_log_root(app_name: str) -> Path:
347
349
  fs_app_name = _normalize_app_name(app_name)
348
- override = os.getenv("INSTRUKT_AI_LOG_ROOT")
349
- if override:
350
- return Path(override).expanduser() / fs_app_name
351
- return Path("/var/log/instrukt-ai") / fs_app_name
350
+ xdg_state_home = os.getenv("XDG_STATE_HOME")
351
+ state_home = Path(xdg_state_home).expanduser() if xdg_state_home else Path("~/.local/state").expanduser()
352
+ return state_home / "instrukt-ai" / fs_app_name
352
353
 
353
354
 
354
355
  def _ensure_log_dir(log_dir: Path) -> None:
@@ -454,18 +455,19 @@ def configure_logging(
454
455
  root_level = third_party_level if not spotlight else logging.WARNING
455
456
 
456
457
  log_dir = _resolve_log_root(app_name)
457
- _ensure_log_dir(log_dir)
458
-
459
458
  log_file = log_dir / log_filename
460
459
 
461
460
  formatter = LogfmtFormatter(max_message_chars=max_message_chars)
461
+ selector = _ThirdPartySelectorFilter(app_logger_prefix=app_logger_prefix, spotlight_prefixes=tuple(spotlight))
462
+
463
+ # Logging is an essential subsystem: an unwritable log dir/file raises here
464
+ # and the host process must not start blind. No degraded/fallback mode.
465
+ _ensure_log_dir(log_dir)
466
+ handler: logging.Handler = WatchedFileHandler(log_file, encoding="utf-8")
462
467
 
463
- handler = WatchedFileHandler(log_file, encoding="utf-8")
464
468
  handler.setLevel(logging.NOTSET)
465
469
  handler.setFormatter(formatter)
466
- handler.addFilter(
467
- _ThirdPartySelectorFilter(app_logger_prefix=app_logger_prefix, spotlight_prefixes=tuple(spotlight))
468
- )
470
+ handler.addFilter(selector)
469
471
 
470
472
  # Configure root.
471
473
  logging.root.handlers = [handler]
@@ -484,6 +486,11 @@ def configure_logging(
484
486
  for prefix in muted:
485
487
  logging.getLogger(prefix).setLevel(logging.WARNING)
486
488
 
489
+ # Rotation bootstrap is not logging: a failure here is surfaced as a
490
+ # warning, never fatal to an already-working log file.
491
+ for problem in ensure_rotation():
492
+ logging.getLogger(app_logger_prefix).warning("log rotation ensure problem: %s", problem)
493
+
487
494
  return log_file
488
495
 
489
496
 
@@ -548,6 +555,46 @@ def iter_recent_log_lines(log_file: Path, since: timedelta) -> list[str]:
548
555
  return lines
549
556
 
550
557
 
558
+ # Initial backward-probe size for locating the start of the --since window in a
559
+ # large file. A typical multi-minute window fits in one or two probes; the probe
560
+ # doubles when the window is larger, so worst case is still O(window), not O(file).
561
+ _WINDOW_PROBE_BYTES = 262144 # 256 KiB
562
+
563
+
564
+ def _window_start_offset(path: Path, cutoff: datetime) -> int:
565
+ """Return a byte offset at or before the first line newer than `cutoff`.
566
+
567
+ A log file is appended in timestamp order, so the window's start can be found
568
+ by probing backward from EOF in exponentially growing chunks instead of
569
+ scanning from byte 0: once a probe's earliest timestamped line is already
570
+ older than the cutoff, the window begins within that probe and everything
571
+ before it is skippable. Returns 0 when the window spans the whole file (or the
572
+ file is smaller than a single probe), which reproduces a full forward scan.
573
+ """
574
+ try:
575
+ size = path.stat().st_size
576
+ except OSError:
577
+ return 0
578
+ if size <= _WINDOW_PROBE_BYTES:
579
+ return 0
580
+
581
+ probe = _WINDOW_PROBE_BYTES
582
+ with path.open("rb") as fb:
583
+ while probe < size:
584
+ start = size - probe
585
+ fb.seek(start)
586
+ fb.readline() # discard the partial line spanning the probe boundary
587
+ first_ts: datetime | None = None
588
+ for raw in fb:
589
+ first_ts = parse_log_timestamp(raw.decode("utf-8", errors="replace"))
590
+ if first_ts is not None:
591
+ break
592
+ if first_ts is not None and first_ts < cutoff:
593
+ return start
594
+ probe *= 2
595
+ return 0
596
+
597
+
551
598
  def iter_recent_log_lines_merged(files: Iterable[Path], since: timedelta) -> Iterator[str]:
552
599
  """Yield lines newer than now-`since` merged across multiple files by timestamp.
553
600
 
@@ -567,12 +614,17 @@ def iter_recent_log_lines_merged(files: Iterable[Path], since: timedelta) -> Ite
567
614
 
568
615
  def _file_stream(path: Path) -> Iterator[tuple[datetime, str]]:
569
616
  try:
570
- f = path.open("r", encoding="utf-8", errors="replace")
617
+ fb = path.open("rb")
571
618
  except FileNotFoundError:
572
619
  return
573
620
  try:
621
+ offset = _window_start_offset(path, cutoff)
622
+ if offset:
623
+ fb.seek(offset)
624
+ fb.readline() # discard the partial line at the seek boundary
574
625
  last_ts: datetime | None = None
575
- for line in f:
626
+ for raw in fb:
627
+ line = raw.decode("utf-8", errors="replace")
576
628
  ts = parse_log_timestamp(line)
577
629
  if ts is None:
578
630
  if last_ts is None or last_ts < cutoff:
@@ -584,7 +636,7 @@ def iter_recent_log_lines_merged(files: Iterable[Path], since: timedelta) -> Ite
584
636
  continue
585
637
  yield (ts, line)
586
638
  finally:
587
- f.close()
639
+ fb.close()
588
640
 
589
641
  eligible: list[Path] = []
590
642
  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.6.0
4
4
  Summary: Centralized logging utilities for Python services.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -58,8 +58,8 @@ configure_logging("teleclaude", source="cron") # → cron.log
58
58
  ```
59
59
 
60
60
  The `.log` extension is always appended; pass the bare stem. Files all live
61
- under `/var/log/instrukt-ai/{app}/`. The CLI merges them transparently
62
- (see below).
61
+ under `$XDG_STATE_HOME/instrukt-ai/{app}/` (fallback `~/.local/state`). The CLI
62
+ merges them transparently (see below).
63
63
 
64
64
  ## Environment variables (contract)
65
65
 
@@ -70,17 +70,32 @@ Per-app prefix model (example uses `TELECLAUDE_`):
70
70
  - `TELECLAUDE_THIRD_PARTY_LOGGERS` (comma-separated logger prefixes to spotlight, e.g. `httpcore,telegram`)
71
71
  - `TELECLAUDE_MUTED_LOGGERS` (comma-separated logger prefixes to force to WARNING+, e.g. `teleclaude.cli.tui`)
72
72
 
73
- Global:
74
-
75
- - `INSTRUKT_AI_LOG_ROOT` (optional log root override)
73
+ These four per-app variables are the entire contract. There is no log-root
74
+ override variable — the location is a fixed rule (see below).
76
75
 
77
76
  ## Log location (contract)
78
77
 
79
- Default target:
78
+ Every app's logs live at the same predictable, prose-citable location on both
79
+ macOS and Linux:
80
+
81
+ - `$XDG_STATE_HOME/instrukt-ai/{app}/{app}.log` (fallback `~/.local/state`,
82
+ per the XDG Base Directory spec)
83
+
84
+ This path is always user-writable — no installer step, no sudo, no elevated
85
+ permissions. `configure_logging(...)` raises immediately if the log file
86
+ cannot be opened; logging is an essential subsystem and a process that cannot
87
+ write its logs must not start blind.
80
88
 
81
- - `/var/log/instrukt-ai/{app}/{app}.log`
89
+ ## Rotation
82
90
 
83
- The installer for each service is expected to create the directory and set write permissions for the daemon user. If the default location is not writable, the implementation will fall back to a user-writable directory and/or require `INSTRUKT_AI_LOG_ROOT`.
91
+ Rotation is transparent and runs entirely in user space:
92
+ `configure_logging(...)` idempotently ensures a per-user rotation conf plus a
93
+ per-user scheduler (a launchd LaunchAgent on macOS running `newsyslog`, a
94
+ systemd user timer on Linux running `logrotate`) on every process start. No
95
+ `/etc` configuration, no sudo, and no system rotation daemon ever touches
96
+ these files, so rotated/recreated logs always stay owned by the producing
97
+ user. Run `instrukt-ai-log-setup` to check rotation-asset status manually;
98
+ normal operation never requires it.
84
99
 
85
100
  ## CLI
86
101
 
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "instruktai-python-logger"
7
- version = "0.5.0"
7
+ version = "0.6.0"
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", "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"]
15
15
 
16
16
  [project.scripts]
17
17
  instrukt-ai-logs = "instrukt_ai_logging.cli:main"
@@ -25,8 +25,17 @@ include = ["instrukt_ai_logging*"]
25
25
  instrukt_ai_logging = ["py.typed"]
26
26
 
27
27
  [tool.pytest.ini_options]
28
- addopts = "-q"
28
+ addopts = "--strict-config --strict-markers --tb=short -q"
29
29
  testpaths = ["tests"]
30
+ timeout = 5
31
+ timeout_method = "signal"
32
+ session_timeout = 90
33
+ markers = [
34
+ "functional: fast, hermetic, no I/O beyond tmp_path",
35
+ "integration: exercises multiple in-process components together, still hermetic",
36
+ "process: drives an external process, bounded and hermetic (tmp_path/monkeypatch-isolated)",
37
+ "spec: binds a feature-spec scenario",
38
+ ]
30
39
 
31
40
  [tool.ruff]
32
41
  line-length = 120