instruktai-python-logger 0.4.4__tar.gz → 0.5.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 (22) hide show
  1. {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/PKG-INFO +2 -5
  2. {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/README.md +1 -1
  3. {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/instrukt_ai_logging/__init__.py +9 -8
  4. {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/instrukt_ai_logging/cli.py +82 -61
  5. instruktai_python_logger-0.5.0/instrukt_ai_logging/install.py +96 -0
  6. {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/instrukt_ai_logging/logging.py +25 -33
  7. {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/instruktai_python_logger.egg-info/PKG-INFO +2 -5
  8. {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/instruktai_python_logger.egg-info/SOURCES.txt +1 -1
  9. instruktai_python_logger-0.5.0/pyproject.toml +92 -0
  10. {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/tests/test_configure_logging.py +59 -27
  11. instruktai_python_logger-0.5.0/tests/test_install.py +131 -0
  12. {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/tests/test_log_aggregation.py +95 -16
  13. {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/tests/test_trace_logging.py +29 -12
  14. instruktai_python_logger-0.4.4/instrukt_ai_logging/install.py +0 -59
  15. instruktai_python_logger-0.4.4/instruktai_python_logger.egg-info/requires.txt +0 -4
  16. instruktai_python_logger-0.4.4/pyproject.toml +0 -33
  17. {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/instrukt_ai_logging/py.typed +0 -0
  18. {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/instruktai_python_logger.egg-info/dependency_links.txt +0 -0
  19. {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/instruktai_python_logger.egg-info/entry_points.txt +0 -0
  20. {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/instruktai_python_logger.egg-info/top_level.txt +0 -0
  21. {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/setup.cfg +0 -0
  22. {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/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.4
3
+ Version: 0.5.0
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)
@@ -0,0 +1,96 @@
1
+ """Install log rotation for InstruktAI logs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ DEFAULT_NEWSYSLOG_CONF = Path("/etc/newsyslog.d/instrukt-ai.conf")
11
+ DEFAULT_LOGROTATE_CONF = Path("/etc/logrotate.d/instrukt-ai")
12
+
13
+
14
+ def _write_file(path: Path, content: str, *, force: bool) -> None:
15
+ if path.exists() and not force:
16
+ raise SystemExit(f"Refusing to overwrite existing file: {path}. Use --force.")
17
+ path.parent.mkdir(parents=True, exist_ok=True)
18
+ path.write_text(content, encoding="utf-8")
19
+
20
+
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.
28
+
29
+ Format: path owner:group mode count size(KB) when flags
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).
38
+ """
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)
49
+ return conf_path
50
+
51
+
52
+ def _install_logrotate(
53
+ log_root: Path,
54
+ *,
55
+ force: bool,
56
+ conf_path: Path = DEFAULT_LOGROTATE_CONF,
57
+ ) -> Path:
58
+ """Install a glob-based logrotate rule covering every app's *.log file.
59
+
60
+ Defaults: 50 MB rotation, 5 archives, gzip-compress archives.
61
+ """
62
+ content = f"""{log_root}/*/*.log {{
63
+ size 50M
64
+ rotate 5
65
+ compress
66
+ delaycompress
67
+ missingok
68
+ notifempty
69
+ copytruncate
70
+ }}
71
+ """
72
+ _write_file(conf_path, content, force=force)
73
+ return conf_path
74
+
75
+
76
+ def main() -> None:
77
+ parser = argparse.ArgumentParser(prog="instrukt-ai-log-setup", add_help=True)
78
+ parser.add_argument(
79
+ "--log-root",
80
+ default=os.environ.get("INSTRUKT_AI_LOG_ROOT", "/var/log/instrukt-ai"),
81
+ help="Base log root (default: /var/log/instrukt-ai or INSTRUKT_AI_LOG_ROOT)",
82
+ )
83
+ parser.add_argument("--force", action="store_true", help="Overwrite existing config")
84
+ args = parser.parse_args()
85
+
86
+ log_root = Path(args.log_root)
87
+ if sys.platform == "darwin":
88
+ conf = _install_newsyslog(log_root, force=args.force)
89
+ else:
90
+ conf = _install_logrotate(log_root, force=args.force)
91
+
92
+ sys.stdout.write(f"installed: {conf}\n")
93
+
94
+
95
+ if __name__ == "__main__":
96
+ main()
@@ -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
 
@@ -597,7 +589,7 @@ def iter_recent_log_lines_merged(files: Iterable[Path], since: timedelta) -> Ite
597
589
  eligible: list[Path] = []
598
590
  for path in files:
599
591
  try:
600
- mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
592
+ mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=UTC)
601
593
  except OSError:
602
594
  continue
603
595
  if mtime < cutoff:
@@ -1,12 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: instruktai-python-logger
3
- Version: 0.4.4
3
+ Version: 0.5.0
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
@@ -0,0 +1,92 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "instruktai-python-logger"
7
+ version = "0.5.0"
8
+ description = "Centralized logging utilities for Python services."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = []
12
+
13
+ [dependency-groups]
14
+ dev = ["pytest>=8", "ruff>=0.8", "pyright>=1.1", "mypy>=1.10"]
15
+
16
+ [project.scripts]
17
+ instrukt-ai-logs = "instrukt_ai_logging.cli:main"
18
+ instrukt-ai-log-setup = "instrukt_ai_logging.install:main"
19
+
20
+ [tool.setuptools.packages.find]
21
+ where = ["."]
22
+ include = ["instrukt_ai_logging*"]
23
+
24
+ [tool.setuptools.package-data]
25
+ instrukt_ai_logging = ["py.typed"]
26
+
27
+ [tool.pytest.ini_options]
28
+ addopts = "-q"
29
+ testpaths = ["tests"]
30
+
31
+ [tool.ruff]
32
+ line-length = 120
33
+ target-version = "py311"
34
+ src = ["instrukt_ai_logging", "tests"]
35
+
36
+ [tool.ruff.lint]
37
+ select = ["E", "F", "I", "C90", "B", "UP", "RUF"]
38
+ ignore = ["E501"]
39
+
40
+ [tool.ruff.lint.mccabe]
41
+ max-complexity = 20
42
+
43
+ [tool.pyright]
44
+ typeCheckingMode = "strict"
45
+ pythonVersion = "3.11"
46
+ include = ["instrukt_ai_logging", "tests"]
47
+ # Flat layout: the package lives at the repo root, so add it as an import source
48
+ # for files outside the package (e.g. tests importing `instrukt_ai_logging`).
49
+ extraPaths = ["."]
50
+
51
+ # Strict typing guards the library; the test suite is exercised by pytest, not by
52
+ # strict static typing. Relax the strict-only "unknown type" family for tests so
53
+ # unannotated fixtures and pytest's own surfaces do not block lint.
54
+ [[tool.pyright.executionEnvironments]]
55
+ root = "tests"
56
+ reportUnknownMemberType = false
57
+ reportUnknownParameterType = false
58
+ reportMissingParameterType = false
59
+ reportUnknownArgumentType = false
60
+ reportUnknownVariableType = false
61
+ reportUnknownLambdaType = false
62
+ reportPrivateUsage = false
63
+ reportCallIssue = false
64
+
65
+ [tool.mypy]
66
+ python_version = "3.11"
67
+ strict = true
68
+ files = ["instrukt_ai_logging"]
69
+ # mypy is unpinned (mypy>=1.10); keep defensive `type: ignore`s that a given
70
+ # version may not need rather than flagging them and breaking on version drift.
71
+ warn_unused_ignores = false
72
+
73
+ # Tests are exercised by pytest, not strict typing. `tests/__init__.py` makes mypy
74
+ # name test modules `tests.*` under both full and scoped (per-file commit-gate)
75
+ # runs, so this relaxation matches in both. `call-arg` is disabled because tests
76
+ # call the kv-logger through stdlib `logging.getLogger`, whose base type omits **kv.
77
+ [[tool.mypy.overrides]]
78
+ module = "tests.*"
79
+ disallow_untyped_defs = false
80
+ disallow_incomplete_defs = false
81
+ disallow_untyped_decorators = false
82
+ check_untyped_defs = false
83
+ ignore_missing_imports = true
84
+ disable_error_code = ["call-arg"]
85
+
86
+ [tool.guardrails]
87
+ max_ignore_files = 8
88
+ max_global_ignores = 8
89
+ max_per_file_ignores = 50
90
+ max_override_sections = 13
91
+ max_module_lines = 1000
92
+ required_rule_groups = ["E", "F", "I", "C90", "B", "UP", "RUF"]
@@ -4,9 +4,41 @@ from pathlib import Path
4
4
  from tempfile import TemporaryDirectory
5
5
 
6
6
  import pytest
7
-
8
7
  from instrukt_ai_logging import InstruktAILogger, configure_logging, get_logger
9
8
 
9
+ # Log markers shared by each test and its assertions — named constants per
10
+ # software-development/procedure/snapshot-testing (no bare literals in content
11
+ # assertions). The logfmt field fragments are derived from the markers so the
12
+ # expected output stays in sync with what is logged.
13
+ _LOGGER_OURS = "teleclaude.core"
14
+ _LOGGER_HTTPCORE = "httpcore.http11"
15
+ _LOGGER_TELEGRAM = "telegram.ext.ExtBot"
16
+ _LOGGER_MUTED = "teleclaude.cli.tui"
17
+
18
+ _MSG_OURS = "hello from ours"
19
+ _MSG_THIRD_PARTY = "hello from third-party"
20
+ _MSG_HTTPCORE = "httpcore info"
21
+ _MSG_TELEGRAM = "telegram info"
22
+ _MSG_HELLO = "hello"
23
+ _MSG_CORE_DEBUG = "core debug"
24
+ _MSG_TUI_DEBUG = "tui debug"
25
+ _MSG_TUI_WARNING = "tui warning"
26
+
27
+ _KV_SESSION = "abc123"
28
+ _KV_N = 1
29
+
30
+ _FIELD_LOGGER_OURS = f"logger={_LOGGER_OURS}"
31
+ _FIELD_LOGGER_HTTPCORE = f"logger={_LOGGER_HTTPCORE}"
32
+ _FIELD_LOGGER_TELEGRAM = f"logger={_LOGGER_TELEGRAM}"
33
+ _FIELD_LOGGER_MUTED = f"logger={_LOGGER_MUTED}"
34
+ _FIELD_MSG_OURS = f'msg="{_MSG_OURS}"'
35
+ _FIELD_MSG_HTTPCORE = f'msg="{_MSG_HTTPCORE}"'
36
+ _FIELD_MSG_HELLO = f'msg="{_MSG_HELLO}"'
37
+ _FIELD_MSG_CORE_DEBUG = f'msg="{_MSG_CORE_DEBUG}"'
38
+ _FIELD_MSG_TUI_WARNING = f'msg="{_MSG_TUI_WARNING}"'
39
+ _FIELD_SESSION = f"session={_KV_SESSION}"
40
+ _FIELD_N = f"n={_KV_N}"
41
+
10
42
 
11
43
  @pytest.fixture()
12
44
  def isolated_logging():
@@ -38,13 +70,13 @@ def test_our_logs_respect_app_level_and_third_party_baseline(isolated_logging, m
38
70
 
39
71
  log_path = configure_logging("teleclaude")
40
72
 
41
- logging.getLogger("teleclaude.core").debug("hello from ours")
42
- logging.getLogger("httpcore.http11").info("hello from third-party")
73
+ logging.getLogger(_LOGGER_OURS).debug(_MSG_OURS)
74
+ logging.getLogger(_LOGGER_HTTPCORE).info(_MSG_THIRD_PARTY)
43
75
 
44
76
  content = _read_text(log_path)
45
- assert "logger=teleclaude.core" in content
46
- assert 'msg="hello from ours"' in content
47
- assert "logger=httpcore.http11" not in content
77
+ assert _FIELD_LOGGER_OURS in content
78
+ assert _FIELD_MSG_OURS in content
79
+ assert _FIELD_LOGGER_HTTPCORE not in content
48
80
 
49
81
 
50
82
  def test_configure_logging_uses_single_file_handler(isolated_logging, monkeypatch):
@@ -60,9 +92,9 @@ def test_configure_logging_uses_single_file_handler(isolated_logging, monkeypatc
60
92
 
61
93
 
62
94
  def test_get_logger_returns_instrukt_logger(isolated_logging):
63
- logger = get_logger("teleclaude.core")
95
+ logger = get_logger(_LOGGER_OURS)
64
96
  assert isinstance(logger, InstruktAILogger)
65
- logger.info("hello", session="abc123", n=1)
97
+ logger.info(_MSG_HELLO, session=_KV_SESSION, n=_KV_N)
66
98
 
67
99
 
68
100
  def test_spotlight_allows_selected_third_party_only(isolated_logging, monkeypatch):
@@ -83,17 +115,17 @@ def test_spotlight_allows_selected_third_party_only(isolated_logging, monkeypatc
83
115
  telegram_logger.setLevel(logging.INFO)
84
116
 
85
117
  try:
86
- logging.getLogger("httpcore.http11").info("httpcore info")
87
- logging.getLogger("telegram.ext.ExtBot").info("telegram info")
118
+ logging.getLogger(_LOGGER_HTTPCORE).info(_MSG_HTTPCORE)
119
+ logging.getLogger(_LOGGER_TELEGRAM).info(_MSG_TELEGRAM)
88
120
  finally:
89
121
  httpcore_logger.setLevel(previous_httpcore_level)
90
122
  telegram_logger.setLevel(previous_telegram_level)
91
123
 
92
124
  content = _read_text(log_path)
93
- assert "logger=httpcore.http11" in content
94
- assert 'msg="httpcore info"' in content
95
- assert "logger=telegram.ext.ExtBot" not in content
96
- assert "telegram info" not in content
125
+ assert _FIELD_LOGGER_HTTPCORE in content
126
+ assert _FIELD_MSG_HTTPCORE in content
127
+ assert _FIELD_LOGGER_TELEGRAM not in content
128
+ assert _MSG_TELEGRAM not in content
97
129
 
98
130
 
99
131
  def test_named_kv_logger_emits_pairs(isolated_logging, monkeypatch):
@@ -104,12 +136,12 @@ def test_named_kv_logger_emits_pairs(isolated_logging, monkeypatch):
104
136
 
105
137
  log_path = configure_logging("teleclaude")
106
138
 
107
- logging.getLogger("teleclaude.core").info("hello", session="abc123", n=1)
139
+ logging.getLogger(_LOGGER_OURS).info(_MSG_HELLO, session=_KV_SESSION, n=_KV_N)
108
140
 
109
141
  content = _read_text(log_path)
110
- assert 'msg="hello"' in content
111
- assert "session=abc123" in content
112
- assert "n=1" in content
142
+ assert _FIELD_MSG_HELLO in content
143
+ assert _FIELD_SESSION in content
144
+ assert _FIELD_N in content
113
145
 
114
146
 
115
147
  def test_muted_loggers_forced_to_warning(isolated_logging, monkeypatch):
@@ -117,20 +149,20 @@ def test_muted_loggers_forced_to_warning(isolated_logging, monkeypatch):
117
149
  monkeypatch.setenv("INSTRUKT_AI_LOG_ROOT", tmp)
118
150
  monkeypatch.setenv("TELECLAUDE_LOG_LEVEL", "DEBUG")
119
151
  monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOG_LEVEL", "WARNING")
120
- monkeypatch.setenv("TELECLAUDE_MUTED_LOGGERS", "teleclaude.cli.tui")
152
+ monkeypatch.setenv("TELECLAUDE_MUTED_LOGGERS", _LOGGER_MUTED)
121
153
 
122
154
  log_path = configure_logging("teleclaude")
123
155
 
124
- logging.getLogger("teleclaude.core").debug("core debug")
125
- logging.getLogger("teleclaude.cli.tui").debug("tui debug")
126
- logging.getLogger("teleclaude.cli.tui").warning("tui warning")
156
+ logging.getLogger(_LOGGER_OURS).debug(_MSG_CORE_DEBUG)
157
+ logging.getLogger(_LOGGER_MUTED).debug(_MSG_TUI_DEBUG)
158
+ logging.getLogger(_LOGGER_MUTED).warning(_MSG_TUI_WARNING)
127
159
 
128
160
  content = _read_text(log_path)
129
161
  # Core debug should appear (app level is DEBUG)
130
- assert "logger=teleclaude.core" in content
131
- assert 'msg="core debug"' in content
162
+ assert _FIELD_LOGGER_OURS in content
163
+ assert _FIELD_MSG_CORE_DEBUG in content
132
164
  # Muted logger's debug should NOT appear (forced to WARNING)
133
- assert "tui debug" not in content
165
+ assert _MSG_TUI_DEBUG not in content
134
166
  # Muted logger's warning SHOULD appear
135
- assert "logger=teleclaude.cli.tui" in content
136
- assert 'msg="tui warning"' in content
167
+ assert _FIELD_LOGGER_MUTED in content
168
+ assert _FIELD_MSG_TUI_WARNING in content
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+ from instrukt_ai_logging.install import _install_logrotate, _install_newsyslog
7
+
8
+ # Protocol-significant tokens for the rotation configs — named constants per
9
+ # software-development/procedure/snapshot-testing (no bare literals in content
10
+ # assertions). These are newsyslog field identities, not prose.
11
+ _NEWSYSLOG_MODE = "640"
12
+ _NEWSYSLOG_COUNT = "5"
13
+ _NEWSYSLOG_SIZE_KB = "50000"
14
+ _NEWSYSLOG_WHEN = "*"
15
+ _NEWSYSLOG_COMPRESS_FLAG = "Z" # gzip-compress archives
16
+ _COMMENT_PREFIX = "#"
17
+ _PREEXISTING_CONF = "preexisting"
18
+
19
+
20
+ def test_install_newsyslog_emits_one_line_per_existing_log_file(tmp_path: Path) -> None:
21
+ log_root = tmp_path / "logs"
22
+ (log_root / "teleclaude").mkdir(parents=True)
23
+ (log_root / "teleclaude" / "teleclaude.log").touch()
24
+ (log_root / "teleclaude" / "cron.log").touch()
25
+ (log_root / "teleclaude" / "docs-watch.log").touch()
26
+ (log_root / "other-app").mkdir()
27
+ (log_root / "other-app" / "other-app.log").touch()
28
+ (log_root / "teleclaude" / "notes.txt").touch() # ignored: not *.log
29
+ conf_path = tmp_path / "etc" / "newsyslog.d" / "instrukt-ai.conf"
30
+
31
+ _install_newsyslog(log_root, force=False, conf_path=conf_path)
32
+
33
+ rotated = [
34
+ line.split()[0]
35
+ for line in conf_path.read_text(encoding="utf-8").splitlines()
36
+ if line and not line.startswith(_COMMENT_PREFIX)
37
+ ]
38
+ assert rotated == sorted(
39
+ [
40
+ str(log_root / "other-app" / "other-app.log"),
41
+ str(log_root / "teleclaude" / "cron.log"),
42
+ str(log_root / "teleclaude" / "docs-watch.log"),
43
+ str(log_root / "teleclaude" / "teleclaude.log"),
44
+ ]
45
+ )
46
+
47
+
48
+ def test_install_newsyslog_uses_compressing_rotation_defaults(tmp_path: Path) -> None:
49
+ log_root = tmp_path / "logs"
50
+ (log_root / "app").mkdir(parents=True)
51
+ (log_root / "app" / "app.log").touch()
52
+ conf_path = tmp_path / "newsyslog.conf"
53
+
54
+ _install_newsyslog(log_root, force=False, conf_path=conf_path)
55
+
56
+ fields = conf_path.read_text(encoding="utf-8").split()
57
+ # path mode count size(KB) when flags
58
+ _, mode, count, size_kb, when, flags = fields
59
+ assert mode == _NEWSYSLOG_MODE
60
+ assert count == _NEWSYSLOG_COUNT
61
+ assert size_kb == _NEWSYSLOG_SIZE_KB
62
+ assert when == _NEWSYSLOG_WHEN
63
+ assert _NEWSYSLOG_COMPRESS_FLAG in flags # gzip-compress archives
64
+
65
+
66
+ def test_install_newsyslog_writes_placeholder_when_no_log_files_exist(tmp_path: Path) -> None:
67
+ log_root = tmp_path / "logs"
68
+ log_root.mkdir() # exists but contains no *.log files
69
+ conf_path = tmp_path / "newsyslog.conf"
70
+
71
+ _install_newsyslog(log_root, force=False, conf_path=conf_path)
72
+
73
+ content = conf_path.read_text(encoding="utf-8")
74
+ assert content.startswith(_COMMENT_PREFIX)
75
+ assert all(line.startswith(_COMMENT_PREFIX) or not line for line in content.splitlines())
76
+
77
+
78
+ def test_install_newsyslog_refuses_overwrite_without_force(tmp_path: Path) -> None:
79
+ log_root = tmp_path / "logs"
80
+ (log_root / "app").mkdir(parents=True)
81
+ (log_root / "app" / "app.log").touch()
82
+ conf_path = tmp_path / "newsyslog.conf"
83
+ conf_path.write_text(_PREEXISTING_CONF, encoding="utf-8")
84
+
85
+ with pytest.raises(SystemExit):
86
+ _install_newsyslog(log_root, force=False, conf_path=conf_path)
87
+ assert conf_path.read_text(encoding="utf-8") == _PREEXISTING_CONF
88
+
89
+
90
+ def test_install_newsyslog_overwrites_with_force(tmp_path: Path) -> None:
91
+ log_root = tmp_path / "logs"
92
+ (log_root / "app").mkdir(parents=True)
93
+ (log_root / "app" / "app.log").touch()
94
+ conf_path = tmp_path / "newsyslog.conf"
95
+ conf_path.write_text(_PREEXISTING_CONF, encoding="utf-8")
96
+
97
+ _install_newsyslog(log_root, force=True, conf_path=conf_path)
98
+ assert conf_path.read_text(encoding="utf-8") != _PREEXISTING_CONF
99
+
100
+
101
+ def test_install_logrotate_writes_glob_block_with_compressing_defaults(tmp_path: Path) -> None:
102
+ log_root = tmp_path / "logs"
103
+ log_root.mkdir()
104
+ conf_path = tmp_path / "logrotate.conf"
105
+
106
+ _install_logrotate(log_root, force=False, conf_path=conf_path)
107
+
108
+ content = conf_path.read_text(encoding="utf-8")
109
+ # logrotate handles multi-level globs natively, unlike macOS newsyslog.
110
+ assert f"{log_root}/*/*.log" in content
111
+ # Protocol-significant directives — these are logrotate keywords, not prose.
112
+ for directive in (
113
+ "size 50M",
114
+ "rotate 5",
115
+ "compress",
116
+ "delaycompress",
117
+ "missingok",
118
+ "copytruncate",
119
+ ):
120
+ assert directive in content
121
+
122
+
123
+ def test_install_logrotate_refuses_overwrite_without_force(tmp_path: Path) -> None:
124
+ log_root = tmp_path / "logs"
125
+ log_root.mkdir()
126
+ conf_path = tmp_path / "logrotate.conf"
127
+ conf_path.write_text(_PREEXISTING_CONF, encoding="utf-8")
128
+
129
+ with pytest.raises(SystemExit):
130
+ _install_logrotate(log_root, force=False, conf_path=conf_path)
131
+ assert conf_path.read_text(encoding="utf-8") == _PREEXISTING_CONF
@@ -1,12 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import sys
3
4
  from collections.abc import Iterator
4
- from datetime import timedelta
5
+ from datetime import UTC, timedelta
5
6
  from pathlib import Path
6
7
  from tempfile import TemporaryDirectory
7
8
 
8
9
  import pytest
9
-
10
+ from instrukt_ai_logging.cli import main
10
11
  from instrukt_ai_logging.logging import (
11
12
  iter_recent_log_lines_merged,
12
13
  resolve_log_files,
@@ -18,6 +19,23 @@ def _write(path: Path, content: str) -> None:
18
19
  path.write_text(content, encoding="utf-8")
19
20
 
20
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).
25
+ _MSG_FRESH = "fresh"
26
+ _MSG_BOOM = "boom"
27
+ _MSG_AFTER = "after"
28
+ _TRACEBACK_LINE = " Traceback (most recent call last):"
29
+ _FILE_LINE = " File 'foo.py', line 1"
30
+ _MSG_KEEP = "keep-me"
31
+ _MSG_GIT_NOISE = "git-noise"
32
+ _MSG_REAL_ERROR = "real-error"
33
+ _MSG_GIT_ERROR = "git-error"
34
+ _MSG_INFO = "info-line"
35
+ _MSG_CRON = "cron-line"
36
+ _MSG_DAEMON = "daemon-line"
37
+
38
+
21
39
  @pytest.fixture()
22
40
  def app_log_dir(monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]:
23
41
  tmp = TemporaryDirectory()
@@ -68,9 +86,9 @@ def test_resolve_log_files_returns_empty_when_dir_missing(monkeypatch: pytest.Mo
68
86
 
69
87
 
70
88
  def _now_iso(offset_minutes: int = 0) -> str:
71
- from datetime import datetime, timezone
89
+ from datetime import datetime
72
90
 
73
- dt = datetime.now(tz=timezone.utc) + timedelta(minutes=offset_minutes)
91
+ dt = datetime.now(tz=UTC) + timedelta(minutes=offset_minutes)
74
92
  return dt.strftime("%Y-%m-%dT%H:%M:%S") + f".{int(dt.microsecond / 1000):03d}Z"
75
93
 
76
94
 
@@ -101,21 +119,21 @@ def test_iter_recent_log_lines_merged_respects_since_cutoff(app_log_dir: Path) -
101
119
  daemon = app_log_dir / "demo-app.log"
102
120
  daemon.write_text(
103
121
  f"{_now_iso(-30)} level=INFO logger=demo.daemon msg=old\n"
104
- f"{_now_iso(-1)} level=INFO logger=demo.daemon msg=fresh\n",
122
+ f"{_now_iso(-1)} level=INFO logger=demo.daemon msg={_MSG_FRESH}\n",
105
123
  encoding="utf-8",
106
124
  )
107
125
 
108
126
  files = resolve_log_files("demo-app")
109
127
  lines = list(iter_recent_log_lines_merged(files, since=timedelta(minutes=5)))
110
128
  assert len(lines) == 1
111
- assert "fresh" in lines[0]
129
+ assert _MSG_FRESH in lines[0]
112
130
 
113
131
 
114
132
  def test_iter_recent_log_lines_merged_skips_files_with_mtime_before_cutoff(
115
133
  app_log_dir: Path,
116
134
  ) -> None:
117
135
  import os
118
- from datetime import datetime, timezone
136
+ from datetime import datetime
119
137
 
120
138
  fresh = app_log_dir / "demo-app.log"
121
139
  fresh.write_text(
@@ -128,7 +146,7 @@ def test_iter_recent_log_lines_merged_skips_files_with_mtime_before_cutoff(
128
146
  encoding="utf-8",
129
147
  )
130
148
  # Force stale.log's mtime to 1 day ago — older than --since=10m cutoff.
131
- one_day_ago = (datetime.now(tz=timezone.utc) - timedelta(days=1)).timestamp()
149
+ one_day_ago = (datetime.now(tz=UTC) - timedelta(days=1)).timestamp()
132
150
  os.utime(stale, (one_day_ago, one_day_ago))
133
151
 
134
152
  files = resolve_log_files("demo-app")
@@ -142,17 +160,78 @@ def test_iter_recent_log_lines_merged_keeps_continuation_lines_with_parent(
142
160
  ) -> None:
143
161
  daemon = app_log_dir / "demo-app.log"
144
162
  daemon.write_text(
145
- f"{_now_iso(-1)} level=ERROR logger=demo.daemon msg=boom\n"
146
- f" Traceback (most recent call last):\n"
147
- f" File 'foo.py', line 1\n"
148
- f"{_now_iso(0)} level=INFO logger=demo.daemon msg=after\n",
163
+ f"{_now_iso(-1)} level=ERROR logger=demo.daemon msg={_MSG_BOOM}\n"
164
+ f"{_TRACEBACK_LINE}\n"
165
+ f"{_FILE_LINE}\n"
166
+ f"{_now_iso(0)} level=INFO logger=demo.daemon msg={_MSG_AFTER}\n",
149
167
  encoding="utf-8",
150
168
  )
151
169
 
152
170
  files = resolve_log_files("demo-app")
153
171
  lines = list(iter_recent_log_lines_merged(files, since=timedelta(minutes=10)))
154
172
  assert len(lines) == 4
155
- assert "boom" in lines[0]
156
- assert lines[1].startswith(" Traceback")
157
- assert lines[2].startswith(" File")
158
- assert "after" in lines[3]
173
+ assert _MSG_BOOM in lines[0]
174
+ assert lines[1].startswith(_TRACEBACK_LINE)
175
+ assert lines[2].startswith(_FILE_LINE)
176
+ assert _MSG_AFTER in lines[3]
177
+
178
+
179
+ def test_cli_exclude_drops_matching_lines(
180
+ app_log_dir: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
181
+ ) -> None:
182
+ daemon = app_log_dir / "demo-app.log"
183
+ daemon.write_text(
184
+ f"{_now_iso(-1)} level=ERROR logger=demo.git.exec msg={_MSG_GIT_NOISE}\n"
185
+ f"{_now_iso(0)} level=INFO logger=demo.daemon msg={_MSG_KEEP}\n",
186
+ encoding="utf-8",
187
+ )
188
+
189
+ monkeypatch.setattr(sys, "argv", ["instrukt-ai-logs", "demo-app", "--exclude", r"logger=demo\.git\.exec"])
190
+ main()
191
+
192
+ out = capsys.readouterr().out
193
+ assert _MSG_KEEP in out
194
+ assert _MSG_GIT_NOISE not in out
195
+
196
+
197
+ def test_cli_exclude_applies_after_grep(
198
+ app_log_dir: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
199
+ ) -> None:
200
+ daemon = app_log_dir / "demo-app.log"
201
+ daemon.write_text(
202
+ f"{_now_iso(-2)} level=ERROR logger=demo.git.exec msg={_MSG_GIT_ERROR}\n"
203
+ f"{_now_iso(-1)} level=ERROR logger=demo.daemon msg={_MSG_REAL_ERROR}\n"
204
+ f"{_now_iso(0)} level=INFO logger=demo.daemon msg={_MSG_INFO}\n",
205
+ encoding="utf-8",
206
+ )
207
+
208
+ # Keep only ERROR lines, then drop the git seam ones.
209
+ monkeypatch.setattr(
210
+ sys,
211
+ "argv",
212
+ ["instrukt-ai-logs", "demo-app", "--grep", "level=ERROR", "--exclude", r"logger=demo\.git\.exec"],
213
+ )
214
+ main()
215
+
216
+ out = capsys.readouterr().out
217
+ assert _MSG_REAL_ERROR in out
218
+ assert _MSG_GIT_ERROR not in out
219
+ assert _MSG_INFO not in out
220
+
221
+
222
+ def test_cli_include_alias_matches_logs_flag(
223
+ app_log_dir: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
224
+ ) -> None:
225
+ (app_log_dir / "demo-app.log").write_text(
226
+ f"{_now_iso(0)} level=INFO logger=demo.daemon msg={_MSG_DAEMON}\n", encoding="utf-8"
227
+ )
228
+ (app_log_dir / "cron.log").write_text(
229
+ f"{_now_iso(0)} level=INFO logger=demo.cron msg={_MSG_CRON}\n", encoding="utf-8"
230
+ )
231
+
232
+ monkeypatch.setattr(sys, "argv", ["instrukt-ai-logs", "demo-app", "--include", "cron"])
233
+ main()
234
+
235
+ out = capsys.readouterr().out
236
+ assert _MSG_CRON in out
237
+ assert _MSG_DAEMON not in out
@@ -7,6 +7,23 @@ 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
13
+ # expected output stays in sync with what is logged.
14
+ _TRACE_MSG = "This is a trace message"
15
+ _DEBUG_MSG = "This is a debug message"
16
+ _KV_KEY = "kv_key"
17
+ _KV_VALUE = "kv_value"
18
+ _FILTERED_TRACE_MSG = "This should not appear"
19
+ _FILTERED_DEBUG_MSG = "This should appear"
20
+
21
+ _LEVEL_TRACE_FIELD = "level=TRACE"
22
+ _LEVEL_DEBUG_FIELD = "level=DEBUG"
23
+ _TRACE_MSG_FIELD = f'msg="{_TRACE_MSG}"'
24
+ _DEBUG_MSG_FIELD = f'msg="{_DEBUG_MSG}"'
25
+ _KV_FIELD = f"{_KV_KEY}={_KV_VALUE}"
26
+
10
27
 
11
28
  @pytest.fixture
12
29
  def clean_env():
@@ -29,23 +46,23 @@ def test_trace_level_logging(clean_env):
29
46
  logger = get_logger("test_trace_app")
30
47
 
31
48
  # Log at TRACE level
32
- logger.trace("This is a trace message", kv_key="kv_value")
49
+ logger.trace(_TRACE_MSG, **{_KV_KEY: _KV_VALUE})
33
50
 
34
51
  # Log at DEBUG level
35
- logger.debug("This is a debug message")
52
+ logger.debug(_DEBUG_MSG)
36
53
 
37
54
  # Read the log file
38
- with open(log_file, "r") as f:
55
+ with open(log_file) as f:
39
56
  lines = f.readlines()
40
57
 
41
58
  assert len(lines) == 2
42
59
 
43
- assert "level=TRACE" in lines[0]
44
- assert 'msg="This is a trace message"' in lines[0]
45
- assert "kv_key=kv_value" in lines[0]
60
+ assert _LEVEL_TRACE_FIELD in lines[0]
61
+ assert _TRACE_MSG_FIELD in lines[0]
62
+ assert _KV_FIELD in lines[0]
46
63
 
47
- assert "level=DEBUG" in lines[1]
48
- assert 'msg="This is a debug message"' in lines[1]
64
+ assert _LEVEL_DEBUG_FIELD in lines[1]
65
+ assert _DEBUG_MSG_FIELD in lines[1]
49
66
 
50
67
 
51
68
  def test_trace_level_filtering(clean_env):
@@ -59,11 +76,11 @@ def test_trace_level_filtering(clean_env):
59
76
  log_file = configure_logging(app_name)
60
77
  logger = get_logger("test_trace_filtering")
61
78
 
62
- logger.trace("This should not appear")
63
- logger.debug("This should appear")
79
+ logger.trace(_FILTERED_TRACE_MSG)
80
+ logger.debug(_FILTERED_DEBUG_MSG)
64
81
 
65
- with open(log_file, "r") as f:
82
+ with open(log_file) as f:
66
83
  lines = f.readlines()
67
84
 
68
85
  assert len(lines) == 1
69
- assert "level=DEBUG" in lines[0]
86
+ assert _LEVEL_DEBUG_FIELD in lines[0]
@@ -1,59 +0,0 @@
1
- """Install log rotation for InstruktAI logs."""
2
-
3
- from __future__ import annotations
4
-
5
- import argparse
6
- import os
7
- import sys
8
- from pathlib import Path
9
-
10
-
11
- def _write_file(path: Path, content: str, *, force: bool) -> None:
12
- if path.exists() and not force:
13
- raise SystemExit(f"Refusing to overwrite existing file: {path}. Use --force.")
14
- path.parent.mkdir(parents=True, exist_ok=True)
15
- path.write_text(content, encoding="utf-8")
16
-
17
-
18
- def _install_newsyslog(log_root: Path, *, force: bool) -> Path:
19
- conf_path = Path("/etc/newsyslog.d/instrukt-ai.conf")
20
- line = f"{log_root}/" + "*/*.log 640 10 100000 * N\n"
21
- _write_file(conf_path, line, force=force)
22
- return conf_path
23
-
24
-
25
- def _install_logrotate(log_root: Path, *, force: bool) -> Path:
26
- conf_path = Path("/etc/logrotate.d/instrukt-ai")
27
- content = f"""{log_root}/*/*.log {{
28
- size 100M
29
- rotate 10
30
- missingok
31
- notifempty
32
- copytruncate
33
- }}
34
- """
35
- _write_file(conf_path, content, force=force)
36
- return conf_path
37
-
38
-
39
- def main() -> None:
40
- parser = argparse.ArgumentParser(prog="instrukt-ai-log-setup", add_help=True)
41
- parser.add_argument(
42
- "--log-root",
43
- default=os.environ.get("INSTRUKT_AI_LOG_ROOT", "/var/log/instrukt-ai"),
44
- help="Base log root (default: /var/log/instrukt-ai or INSTRUKT_AI_LOG_ROOT)",
45
- )
46
- parser.add_argument("--force", action="store_true", help="Overwrite existing config")
47
- args = parser.parse_args()
48
-
49
- log_root = Path(args.log_root)
50
- if sys.platform == "darwin":
51
- conf = _install_newsyslog(log_root, force=args.force)
52
- else:
53
- conf = _install_logrotate(log_root, force=args.force)
54
-
55
- sys.stdout.write(f"installed: {conf}\n")
56
-
57
-
58
- if __name__ == "__main__":
59
- main()
@@ -1,4 +0,0 @@
1
-
2
- [dev]
3
- pytest>=8
4
- ruff>=0.8
@@ -1,33 +0,0 @@
1
- [build-system]
2
- requires = ["setuptools>=69", "wheel"]
3
- build-backend = "setuptools.build_meta"
4
-
5
- [project]
6
- name = "instruktai-python-logger"
7
- version = "0.4.4"
8
- description = "Centralized logging utilities for Python services."
9
- readme = "README.md"
10
- requires-python = ">=3.11"
11
- dependencies = []
12
-
13
- [project.optional-dependencies]
14
- dev = ["pytest>=8", "ruff>=0.8"]
15
-
16
- [project.scripts]
17
- instrukt-ai-logs = "instrukt_ai_logging.cli:main"
18
- instrukt-ai-log-setup = "instrukt_ai_logging.install:main"
19
-
20
- [tool.setuptools.packages.find]
21
- where = ["."]
22
- include = ["instrukt_ai_logging*"]
23
-
24
- [tool.setuptools.package-data]
25
- instrukt_ai_logging = ["py.typed"]
26
-
27
- [tool.pytest.ini_options]
28
- addopts = "-q"
29
- testpaths = ["tests"]
30
-
31
- [tool.ruff]
32
- line-length = 100
33
- target-version = "py311"