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.
- {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/PKG-INFO +2 -5
- {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/README.md +1 -1
- {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/instrukt_ai_logging/__init__.py +9 -8
- {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/instrukt_ai_logging/cli.py +82 -61
- instruktai_python_logger-0.5.0/instrukt_ai_logging/install.py +96 -0
- {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/instrukt_ai_logging/logging.py +25 -33
- {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/instruktai_python_logger.egg-info/PKG-INFO +2 -5
- {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/instruktai_python_logger.egg-info/SOURCES.txt +1 -1
- instruktai_python_logger-0.5.0/pyproject.toml +92 -0
- {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/tests/test_configure_logging.py +59 -27
- instruktai_python_logger-0.5.0/tests/test_install.py +131 -0
- {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/tests/test_log_aggregation.py +95 -16
- {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/tests/test_trace_logging.py +29 -12
- instruktai_python_logger-0.4.4/instrukt_ai_logging/install.py +0 -59
- instruktai_python_logger-0.4.4/instruktai_python_logger.egg-info/requires.txt +0 -4
- instruktai_python_logger-0.4.4/pyproject.toml +0 -33
- {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/instrukt_ai_logging/py.typed +0 -0
- {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/instruktai_python_logger.egg-info/dependency_links.txt +0 -0
- {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/instruktai_python_logger.egg-info/entry_points.txt +0 -0
- {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/instruktai_python_logger.egg-info/top_level.txt +0 -0
- {instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/setup.cfg +0 -0
- {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.
|
|
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
|
|
22
|
+
pip install instruktai-python-logger
|
|
26
23
|
```
|
|
27
24
|
|
|
28
25
|
From GitHub:
|
{instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/instrukt_ai_logging/__init__.py
RENAMED
|
@@ -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
|
|
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 (
|
|
26
|
-
|
|
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
|
)
|
{instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/instrukt_ai_logging/cli.py
RENAMED
|
@@ -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
|
|
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
|
|
170
|
-
|
|
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()
|
{instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/instrukt_ai_logging/logging.py
RENAMED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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
|
-
|
|
165
|
-
if isinstance(
|
|
166
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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=
|
|
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.
|
|
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
|
|
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"]
|
{instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/tests/test_configure_logging.py
RENAMED
|
@@ -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(
|
|
42
|
-
logging.getLogger(
|
|
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
|
|
46
|
-
assert
|
|
47
|
-
assert
|
|
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(
|
|
95
|
+
logger = get_logger(_LOGGER_OURS)
|
|
64
96
|
assert isinstance(logger, InstruktAILogger)
|
|
65
|
-
logger.info(
|
|
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(
|
|
87
|
-
logging.getLogger(
|
|
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
|
|
94
|
-
assert
|
|
95
|
-
assert
|
|
96
|
-
assert
|
|
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(
|
|
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
|
|
111
|
-
assert
|
|
112
|
-
assert
|
|
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",
|
|
152
|
+
monkeypatch.setenv("TELECLAUDE_MUTED_LOGGERS", _LOGGER_MUTED)
|
|
121
153
|
|
|
122
154
|
log_path = configure_logging("teleclaude")
|
|
123
155
|
|
|
124
|
-
logging.getLogger(
|
|
125
|
-
logging.getLogger(
|
|
126
|
-
logging.getLogger(
|
|
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
|
|
131
|
-
assert
|
|
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
|
|
165
|
+
assert _MSG_TUI_DEBUG not in content
|
|
134
166
|
# Muted logger's warning SHOULD appear
|
|
135
|
-
assert
|
|
136
|
-
assert
|
|
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
|
{instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/tests/test_log_aggregation.py
RENAMED
|
@@ -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
|
|
89
|
+
from datetime import datetime
|
|
72
90
|
|
|
73
|
-
dt = datetime.now(tz=
|
|
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=
|
|
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
|
|
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
|
|
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=
|
|
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=
|
|
146
|
-
f"
|
|
147
|
-
f"
|
|
148
|
-
f"{_now_iso(0)} level=INFO logger=demo.daemon msg=
|
|
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
|
|
156
|
-
assert lines[1].startswith(
|
|
157
|
-
assert lines[2].startswith(
|
|
158
|
-
assert
|
|
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
|
{instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/tests/test_trace_logging.py
RENAMED
|
@@ -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(
|
|
49
|
+
logger.trace(_TRACE_MSG, **{_KV_KEY: _KV_VALUE})
|
|
33
50
|
|
|
34
51
|
# Log at DEBUG level
|
|
35
|
-
logger.debug(
|
|
52
|
+
logger.debug(_DEBUG_MSG)
|
|
36
53
|
|
|
37
54
|
# Read the log file
|
|
38
|
-
with open(log_file
|
|
55
|
+
with open(log_file) as f:
|
|
39
56
|
lines = f.readlines()
|
|
40
57
|
|
|
41
58
|
assert len(lines) == 2
|
|
42
59
|
|
|
43
|
-
assert
|
|
44
|
-
assert
|
|
45
|
-
assert
|
|
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
|
|
48
|
-
assert
|
|
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(
|
|
63
|
-
logger.debug(
|
|
79
|
+
logger.trace(_FILTERED_TRACE_MSG)
|
|
80
|
+
logger.debug(_FILTERED_DEBUG_MSG)
|
|
64
81
|
|
|
65
|
-
with open(log_file
|
|
82
|
+
with open(log_file) as f:
|
|
66
83
|
lines = f.readlines()
|
|
67
84
|
|
|
68
85
|
assert len(lines) == 1
|
|
69
|
-
assert
|
|
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,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"
|
{instruktai_python_logger-0.4.4 → instruktai_python_logger-0.5.0}/instrukt_ai_logging/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|