instruktai-python-logger 0.4.5__tar.gz → 0.5.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/PKG-INFO +2 -5
- {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/README.md +1 -1
- {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/instrukt_ai_logging/__init__.py +9 -8
- {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/instrukt_ai_logging/cli.py +82 -61
- {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/instrukt_ai_logging/install.py +33 -7
- {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/instrukt_ai_logging/logging.py +73 -36
- {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/instruktai_python_logger.egg-info/PKG-INFO +2 -5
- {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/instruktai_python_logger.egg-info/SOURCES.txt +1 -1
- instruktai_python_logger-0.5.1/pyproject.toml +92 -0
- {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/tests/test_configure_logging.py +59 -27
- instruktai_python_logger-0.5.1/tests/test_install.py +131 -0
- instruktai_python_logger-0.5.1/tests/test_log_aggregation.py +293 -0
- {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/tests/test_trace_logging.py +29 -12
- instruktai_python_logger-0.4.5/instruktai_python_logger.egg-info/requires.txt +0 -4
- instruktai_python_logger-0.4.5/pyproject.toml +0 -33
- instruktai_python_logger-0.4.5/tests/test_log_aggregation.py +0 -158
- {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/instrukt_ai_logging/py.typed +0 -0
- {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/instruktai_python_logger.egg-info/dependency_links.txt +0 -0
- {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/instruktai_python_logger.egg-info/entry_points.txt +0 -0
- {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/instruktai_python_logger.egg-info/top_level.txt +0 -0
- {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/setup.cfg +0 -0
- {instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/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.1
|
|
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.5 → instruktai_python_logger-0.5.1}/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.5 → instruktai_python_logger-0.5.1}/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)
|
{instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/instrukt_ai_logging/install.py
RENAMED
|
@@ -7,6 +7,9 @@ import os
|
|
|
7
7
|
import sys
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
|
+
DEFAULT_NEWSYSLOG_CONF = Path("/etc/newsyslog.d/instrukt-ai.conf")
|
|
11
|
+
DEFAULT_LOGROTATE_CONF = Path("/etc/logrotate.d/instrukt-ai")
|
|
12
|
+
|
|
10
13
|
|
|
11
14
|
def _write_file(path: Path, content: str, *, force: bool) -> None:
|
|
12
15
|
if path.exists() and not force:
|
|
@@ -15,24 +18,47 @@ def _write_file(path: Path, content: str, *, force: bool) -> None:
|
|
|
15
18
|
path.write_text(content, encoding="utf-8")
|
|
16
19
|
|
|
17
20
|
|
|
18
|
-
def _install_newsyslog(
|
|
19
|
-
|
|
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.
|
|
20
28
|
|
|
21
29
|
Format: path owner:group mode count size(KB) when flags
|
|
22
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).
|
|
23
38
|
"""
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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)
|
|
27
49
|
return conf_path
|
|
28
50
|
|
|
29
51
|
|
|
30
|
-
def _install_logrotate(
|
|
52
|
+
def _install_logrotate(
|
|
53
|
+
log_root: Path,
|
|
54
|
+
*,
|
|
55
|
+
force: bool,
|
|
56
|
+
conf_path: Path = DEFAULT_LOGROTATE_CONF,
|
|
57
|
+
) -> Path:
|
|
31
58
|
"""Install a glob-based logrotate rule covering every app's *.log file.
|
|
32
59
|
|
|
33
60
|
Defaults: 50 MB rotation, 5 archives, gzip-compress archives.
|
|
34
61
|
"""
|
|
35
|
-
conf_path = Path("/etc/logrotate.d/instrukt-ai")
|
|
36
62
|
content = f"""{log_root}/*/*.log {{
|
|
37
63
|
size 50M
|
|
38
64
|
rotate 5
|
{instruktai_python_logger-0.4.5 → instruktai_python_logger-0.5.1}/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
|
|
|
@@ -556,6 +548,46 @@ def iter_recent_log_lines(log_file: Path, since: timedelta) -> list[str]:
|
|
|
556
548
|
return lines
|
|
557
549
|
|
|
558
550
|
|
|
551
|
+
# Initial backward-probe size for locating the start of the --since window in a
|
|
552
|
+
# large file. A typical multi-minute window fits in one or two probes; the probe
|
|
553
|
+
# doubles when the window is larger, so worst case is still O(window), not O(file).
|
|
554
|
+
_WINDOW_PROBE_BYTES = 262144 # 256 KiB
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _window_start_offset(path: Path, cutoff: datetime) -> int:
|
|
558
|
+
"""Return a byte offset at or before the first line newer than `cutoff`.
|
|
559
|
+
|
|
560
|
+
A log file is appended in timestamp order, so the window's start can be found
|
|
561
|
+
by probing backward from EOF in exponentially growing chunks instead of
|
|
562
|
+
scanning from byte 0: once a probe's earliest timestamped line is already
|
|
563
|
+
older than the cutoff, the window begins within that probe and everything
|
|
564
|
+
before it is skippable. Returns 0 when the window spans the whole file (or the
|
|
565
|
+
file is smaller than a single probe), which reproduces a full forward scan.
|
|
566
|
+
"""
|
|
567
|
+
try:
|
|
568
|
+
size = path.stat().st_size
|
|
569
|
+
except OSError:
|
|
570
|
+
return 0
|
|
571
|
+
if size <= _WINDOW_PROBE_BYTES:
|
|
572
|
+
return 0
|
|
573
|
+
|
|
574
|
+
probe = _WINDOW_PROBE_BYTES
|
|
575
|
+
with path.open("rb") as fb:
|
|
576
|
+
while probe < size:
|
|
577
|
+
start = size - probe
|
|
578
|
+
fb.seek(start)
|
|
579
|
+
fb.readline() # discard the partial line spanning the probe boundary
|
|
580
|
+
first_ts: datetime | None = None
|
|
581
|
+
for raw in fb:
|
|
582
|
+
first_ts = parse_log_timestamp(raw.decode("utf-8", errors="replace"))
|
|
583
|
+
if first_ts is not None:
|
|
584
|
+
break
|
|
585
|
+
if first_ts is not None and first_ts < cutoff:
|
|
586
|
+
return start
|
|
587
|
+
probe *= 2
|
|
588
|
+
return 0
|
|
589
|
+
|
|
590
|
+
|
|
559
591
|
def iter_recent_log_lines_merged(files: Iterable[Path], since: timedelta) -> Iterator[str]:
|
|
560
592
|
"""Yield lines newer than now-`since` merged across multiple files by timestamp.
|
|
561
593
|
|
|
@@ -575,12 +607,17 @@ def iter_recent_log_lines_merged(files: Iterable[Path], since: timedelta) -> Ite
|
|
|
575
607
|
|
|
576
608
|
def _file_stream(path: Path) -> Iterator[tuple[datetime, str]]:
|
|
577
609
|
try:
|
|
578
|
-
|
|
610
|
+
fb = path.open("rb")
|
|
579
611
|
except FileNotFoundError:
|
|
580
612
|
return
|
|
581
613
|
try:
|
|
614
|
+
offset = _window_start_offset(path, cutoff)
|
|
615
|
+
if offset:
|
|
616
|
+
fb.seek(offset)
|
|
617
|
+
fb.readline() # discard the partial line at the seek boundary
|
|
582
618
|
last_ts: datetime | None = None
|
|
583
|
-
for
|
|
619
|
+
for raw in fb:
|
|
620
|
+
line = raw.decode("utf-8", errors="replace")
|
|
584
621
|
ts = parse_log_timestamp(line)
|
|
585
622
|
if ts is None:
|
|
586
623
|
if last_ts is None or last_ts < cutoff:
|
|
@@ -592,12 +629,12 @@ def iter_recent_log_lines_merged(files: Iterable[Path], since: timedelta) -> Ite
|
|
|
592
629
|
continue
|
|
593
630
|
yield (ts, line)
|
|
594
631
|
finally:
|
|
595
|
-
|
|
632
|
+
fb.close()
|
|
596
633
|
|
|
597
634
|
eligible: list[Path] = []
|
|
598
635
|
for path in files:
|
|
599
636
|
try:
|
|
600
|
-
mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=
|
|
637
|
+
mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=UTC)
|
|
601
638
|
except OSError:
|
|
602
639
|
continue
|
|
603
640
|
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.1
|
|
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
|