instruktai-python-logger 0.5.1__tar.gz → 0.6.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.5.1 → instruktai_python_logger-0.6.1}/PKG-INFO +24 -9
- {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/README.md +23 -8
- {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/instrukt_ai_logging/cli.py +3 -1
- instruktai_python_logger-0.6.1/instrukt_ai_logging/install.py +232 -0
- {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/instrukt_ai_logging/logging.py +17 -10
- {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/instruktai_python_logger.egg-info/PKG-INFO +24 -9
- {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/pyproject.toml +12 -3
- {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/tests/test_configure_logging.py +86 -6
- instruktai_python_logger-0.6.1/tests/test_install.py +255 -0
- {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/tests/test_log_aggregation.py +3 -3
- {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/tests/test_trace_logging.py +2 -2
- instruktai_python_logger-0.5.1/instrukt_ai_logging/install.py +0 -96
- instruktai_python_logger-0.5.1/tests/test_install.py +0 -131
- {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/instrukt_ai_logging/__init__.py +0 -0
- {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/instrukt_ai_logging/py.typed +0 -0
- {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/instruktai_python_logger.egg-info/SOURCES.txt +0 -0
- {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/instruktai_python_logger.egg-info/dependency_links.txt +0 -0
- {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/instruktai_python_logger.egg-info/entry_points.txt +0 -0
- {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/instruktai_python_logger.egg-info/top_level.txt +0 -0
- {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/setup.cfg +0 -0
- {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/tests/test_cli_follow.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: instruktai-python-logger
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary: Centralized logging utilities for Python services.
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -58,8 +58,8 @@ configure_logging("teleclaude", source="cron") # → cron.log
|
|
|
58
58
|
```
|
|
59
59
|
|
|
60
60
|
The `.log` extension is always appended; pass the bare stem. Files all live
|
|
61
|
-
under
|
|
62
|
-
(see below).
|
|
61
|
+
under `$XDG_STATE_HOME/instrukt-ai/{app}/` (fallback `~/.local/state`). The CLI
|
|
62
|
+
merges them transparently (see below).
|
|
63
63
|
|
|
64
64
|
## Environment variables (contract)
|
|
65
65
|
|
|
@@ -70,17 +70,32 @@ Per-app prefix model (example uses `TELECLAUDE_`):
|
|
|
70
70
|
- `TELECLAUDE_THIRD_PARTY_LOGGERS` (comma-separated logger prefixes to spotlight, e.g. `httpcore,telegram`)
|
|
71
71
|
- `TELECLAUDE_MUTED_LOGGERS` (comma-separated logger prefixes to force to WARNING+, e.g. `teleclaude.cli.tui`)
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
- `INSTRUKT_AI_LOG_ROOT` (optional log root override)
|
|
73
|
+
These four per-app variables are the entire contract. There is no log-root
|
|
74
|
+
override variable — the location is a fixed rule (see below).
|
|
76
75
|
|
|
77
76
|
## Log location (contract)
|
|
78
77
|
|
|
79
|
-
|
|
78
|
+
Every app's logs live at the same predictable, prose-citable location on both
|
|
79
|
+
macOS and Linux:
|
|
80
|
+
|
|
81
|
+
- `$XDG_STATE_HOME/instrukt-ai/{app}/{app}.log` (fallback `~/.local/state`,
|
|
82
|
+
per the XDG Base Directory spec)
|
|
83
|
+
|
|
84
|
+
This path is always user-writable — no installer step, no sudo, no elevated
|
|
85
|
+
permissions. `configure_logging(...)` raises immediately if the log file
|
|
86
|
+
cannot be opened; logging is an essential subsystem and a process that cannot
|
|
87
|
+
write its logs must not start blind.
|
|
80
88
|
|
|
81
|
-
|
|
89
|
+
## Rotation
|
|
82
90
|
|
|
83
|
-
|
|
91
|
+
Rotation is transparent and runs entirely in user space:
|
|
92
|
+
`configure_logging(...)` idempotently ensures a per-user rotation conf plus a
|
|
93
|
+
per-user scheduler (a launchd LaunchAgent on macOS running `newsyslog`, a
|
|
94
|
+
systemd user timer on Linux running `logrotate`) on every process start. No
|
|
95
|
+
`/etc` configuration, no sudo, and no system rotation daemon ever touches
|
|
96
|
+
these files, so rotated/recreated logs always stay owned by the producing
|
|
97
|
+
user. Run `instrukt-ai-log-setup` to check rotation-asset status manually;
|
|
98
|
+
normal operation never requires it.
|
|
84
99
|
|
|
85
100
|
## CLI
|
|
86
101
|
|
|
@@ -51,8 +51,8 @@ configure_logging("teleclaude", source="cron") # → cron.log
|
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
The `.log` extension is always appended; pass the bare stem. Files all live
|
|
54
|
-
under
|
|
55
|
-
(see below).
|
|
54
|
+
under `$XDG_STATE_HOME/instrukt-ai/{app}/` (fallback `~/.local/state`). The CLI
|
|
55
|
+
merges them transparently (see below).
|
|
56
56
|
|
|
57
57
|
## Environment variables (contract)
|
|
58
58
|
|
|
@@ -63,17 +63,32 @@ Per-app prefix model (example uses `TELECLAUDE_`):
|
|
|
63
63
|
- `TELECLAUDE_THIRD_PARTY_LOGGERS` (comma-separated logger prefixes to spotlight, e.g. `httpcore,telegram`)
|
|
64
64
|
- `TELECLAUDE_MUTED_LOGGERS` (comma-separated logger prefixes to force to WARNING+, e.g. `teleclaude.cli.tui`)
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
- `INSTRUKT_AI_LOG_ROOT` (optional log root override)
|
|
66
|
+
These four per-app variables are the entire contract. There is no log-root
|
|
67
|
+
override variable — the location is a fixed rule (see below).
|
|
69
68
|
|
|
70
69
|
## Log location (contract)
|
|
71
70
|
|
|
72
|
-
|
|
71
|
+
Every app's logs live at the same predictable, prose-citable location on both
|
|
72
|
+
macOS and Linux:
|
|
73
|
+
|
|
74
|
+
- `$XDG_STATE_HOME/instrukt-ai/{app}/{app}.log` (fallback `~/.local/state`,
|
|
75
|
+
per the XDG Base Directory spec)
|
|
76
|
+
|
|
77
|
+
This path is always user-writable — no installer step, no sudo, no elevated
|
|
78
|
+
permissions. `configure_logging(...)` raises immediately if the log file
|
|
79
|
+
cannot be opened; logging is an essential subsystem and a process that cannot
|
|
80
|
+
write its logs must not start blind.
|
|
73
81
|
|
|
74
|
-
|
|
82
|
+
## Rotation
|
|
75
83
|
|
|
76
|
-
|
|
84
|
+
Rotation is transparent and runs entirely in user space:
|
|
85
|
+
`configure_logging(...)` idempotently ensures a per-user rotation conf plus a
|
|
86
|
+
per-user scheduler (a launchd LaunchAgent on macOS running `newsyslog`, a
|
|
87
|
+
systemd user timer on Linux running `logrotate`) on every process start. No
|
|
88
|
+
`/etc` configuration, no sudo, and no system rotation daemon ever touches
|
|
89
|
+
these files, so rotated/recreated logs always stay owned by the producing
|
|
90
|
+
user. Run `instrukt-ai-log-setup` to check rotation-asset status manually;
|
|
91
|
+
normal operation never requires it.
|
|
77
92
|
|
|
78
93
|
## CLI
|
|
79
94
|
|
{instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/instrukt_ai_logging/cli.py
RENAMED
|
@@ -192,7 +192,9 @@ def main() -> None:
|
|
|
192
192
|
action="version",
|
|
193
193
|
version=f"%(prog)s {__version__}",
|
|
194
194
|
)
|
|
195
|
-
parser.add_argument(
|
|
195
|
+
parser.add_argument(
|
|
196
|
+
"app", help="App/service name (folder under $XDG_STATE_HOME/instrukt-ai/, fallback ~/.local/state)"
|
|
197
|
+
)
|
|
196
198
|
parser.add_argument("--since", default="10m", help="Time window (e.g. 10m, 2h, 1d). Default: 10m")
|
|
197
199
|
parser.add_argument(
|
|
198
200
|
"-f",
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Ensure user-space log rotation assets for InstruktAI logs.
|
|
2
|
+
|
|
3
|
+
Rotation runs entirely as the producing user: a per-user rotation conf plus a
|
|
4
|
+
per-user scheduler (launchd LaunchAgent on macOS, systemd user timer on
|
|
5
|
+
Linux). No `/etc` configuration, no sudo, no system rotation daemon ever
|
|
6
|
+
touches these files, so root-owned recreated logs are structurally
|
|
7
|
+
unexpressible.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
_NEWSYSLOG_BIN = "/usr/sbin/newsyslog"
|
|
18
|
+
_LAUNCHD_LABEL = "ai.instrukt.log-rotate"
|
|
19
|
+
_SYSTEMD_UNIT_NAME = "instrukt-ai-logrotate"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _state_home_root() -> Path:
|
|
23
|
+
"""Resolve `$XDG_STATE_HOME/instrukt-ai` (fallback `~/.local/state`)."""
|
|
24
|
+
xdg_state_home = os.getenv("XDG_STATE_HOME")
|
|
25
|
+
state_home = Path(xdg_state_home).expanduser() if xdg_state_home else Path("~/.local/state").expanduser()
|
|
26
|
+
return state_home / "instrukt-ai"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _config_dir() -> Path:
|
|
30
|
+
return Path.home() / ".config" / "instrukt-ai"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _newsyslog_conf_path() -> Path:
|
|
34
|
+
return _config_dir() / "newsyslog.conf"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _launchd_plist_path() -> Path:
|
|
38
|
+
return Path.home() / "Library" / "LaunchAgents" / f"{_LAUNCHD_LABEL}.plist"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _logrotate_conf_path() -> Path:
|
|
42
|
+
return _config_dir() / "logrotate.conf"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _systemd_user_dir() -> Path:
|
|
46
|
+
return Path.home() / ".config" / "systemd" / "user"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _systemd_service_path() -> Path:
|
|
50
|
+
return _systemd_user_dir() / f"{_SYSTEMD_UNIT_NAME}.service"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _systemd_timer_path() -> Path:
|
|
54
|
+
return _systemd_user_dir() / f"{_SYSTEMD_UNIT_NAME}.timer"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _newsyslog_conf(log_root: Path) -> str:
|
|
58
|
+
"""One glob line: 5 gzipped archives at 50 MB, evaluated at rotation time (G flag)."""
|
|
59
|
+
return f"{log_root}/*/*.log 640 5 50000 * ZG\n"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _launchd_plist(conf_path: Path) -> str:
|
|
63
|
+
# newsyslog refuses to run at all unless invoked as root or with -r; our
|
|
64
|
+
# conf lines carry no pid/signal field, so -r's "won't HUP syslogd" caveat
|
|
65
|
+
# doesn't apply here.
|
|
66
|
+
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
67
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
68
|
+
<plist version="1.0">
|
|
69
|
+
<dict>
|
|
70
|
+
<key>Label</key>
|
|
71
|
+
<string>{_LAUNCHD_LABEL}</string>
|
|
72
|
+
<key>ProgramArguments</key>
|
|
73
|
+
<array>
|
|
74
|
+
<string>{_NEWSYSLOG_BIN}</string>
|
|
75
|
+
<string>-r</string>
|
|
76
|
+
<string>-f</string>
|
|
77
|
+
<string>{conf_path}</string>
|
|
78
|
+
</array>
|
|
79
|
+
<key>StartInterval</key>
|
|
80
|
+
<integer>1800</integer>
|
|
81
|
+
<key>RunAtLoad</key>
|
|
82
|
+
<true/>
|
|
83
|
+
</dict>
|
|
84
|
+
</plist>
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _logrotate_conf(log_root: Path) -> str:
|
|
89
|
+
"""Default move+create rotation; WatchedFileHandler reopens on inode change."""
|
|
90
|
+
return f"""{log_root}/*/*.log {{
|
|
91
|
+
size 50M
|
|
92
|
+
rotate 5
|
|
93
|
+
compress
|
|
94
|
+
delaycompress
|
|
95
|
+
missingok
|
|
96
|
+
notifempty
|
|
97
|
+
}}
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _systemd_service(conf_path: Path, state_file: Path) -> str:
|
|
102
|
+
return f"""[Unit]
|
|
103
|
+
Description=InstruktAI log rotation
|
|
104
|
+
|
|
105
|
+
[Service]
|
|
106
|
+
Type=oneshot
|
|
107
|
+
ExecStart=logrotate --state {state_file} {conf_path}
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _systemd_timer() -> str:
|
|
112
|
+
return """[Unit]
|
|
113
|
+
Description=InstruktAI log rotation timer
|
|
114
|
+
|
|
115
|
+
[Timer]
|
|
116
|
+
OnCalendar=*:0/30
|
|
117
|
+
Persistent=true
|
|
118
|
+
|
|
119
|
+
[Install]
|
|
120
|
+
WantedBy=timers.target
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _write_if_changed(path: Path, content: str) -> None:
|
|
125
|
+
if path.exists() and path.read_text(encoding="utf-8") == content:
|
|
126
|
+
return
|
|
127
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
path.write_text(content, encoding="utf-8")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
_SCHEDULER_COMMAND_TIMEOUT_S = 10.0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _run_scheduler_command(args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
135
|
+
# No Python API wraps launchd's per-user service bus or the systemd user
|
|
136
|
+
# D-Bus/session manager; shell out to launchctl/systemctl. Routed through
|
|
137
|
+
# this wrapper (rather than calling subprocess.run directly) so tests can
|
|
138
|
+
# fake just this boundary without patching the shared subprocess module.
|
|
139
|
+
# A bounded timeout and closed stdin keep an unreachable service bus (no
|
|
140
|
+
# GUI/user session, e.g. a headless CI runner) from hanging the caller
|
|
141
|
+
# forever instead of failing fast.
|
|
142
|
+
return subprocess.run(
|
|
143
|
+
args,
|
|
144
|
+
capture_output=True,
|
|
145
|
+
text=True,
|
|
146
|
+
stdin=subprocess.DEVNULL,
|
|
147
|
+
timeout=_SCHEDULER_COMMAND_TIMEOUT_S,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _ensure_launchd_loaded(plist_path: Path) -> list[str]:
|
|
152
|
+
uid = os.getuid()
|
|
153
|
+
try:
|
|
154
|
+
result = _run_scheduler_command(["launchctl", "bootstrap", f"gui/{uid}", str(plist_path)])
|
|
155
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
156
|
+
return [f"launchctl bootstrap failed to run: {exc}"]
|
|
157
|
+
|
|
158
|
+
if result.returncode != 0 and "already bootstrapped" not in result.stderr.lower():
|
|
159
|
+
return [f"launchctl bootstrap failed: {result.stderr.strip()}"]
|
|
160
|
+
return []
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _ensure_systemd_loaded() -> list[str]:
|
|
164
|
+
try:
|
|
165
|
+
reload_result = _run_scheduler_command(["systemctl", "--user", "daemon-reload"])
|
|
166
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
167
|
+
return [f"systemctl daemon-reload failed to run: {exc}"]
|
|
168
|
+
|
|
169
|
+
if reload_result.returncode != 0:
|
|
170
|
+
return [f"systemctl daemon-reload failed: {reload_result.stderr.strip()}"]
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
enable_result = _run_scheduler_command(
|
|
174
|
+
["systemctl", "--user", "enable", "--now", f"{_SYSTEMD_UNIT_NAME}.timer"]
|
|
175
|
+
)
|
|
176
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
177
|
+
return [f"systemctl enable failed to run: {exc}"]
|
|
178
|
+
|
|
179
|
+
if enable_result.returncode != 0:
|
|
180
|
+
return [f"systemctl enable failed: {enable_result.stderr.strip()}"]
|
|
181
|
+
return []
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def ensure_rotation() -> list[str]:
|
|
185
|
+
"""Idempotently ensure user-space rotation assets exist and are scheduled.
|
|
186
|
+
|
|
187
|
+
Never raises. Returns human-readable problem strings for anything that
|
|
188
|
+
could not be resolved; an empty list means rotation is healthy.
|
|
189
|
+
"""
|
|
190
|
+
log_root = _state_home_root()
|
|
191
|
+
|
|
192
|
+
if sys.platform == "darwin":
|
|
193
|
+
conf_path = _newsyslog_conf_path()
|
|
194
|
+
plist_path = _launchd_plist_path()
|
|
195
|
+
try:
|
|
196
|
+
_write_if_changed(conf_path, _newsyslog_conf(log_root))
|
|
197
|
+
_write_if_changed(plist_path, _launchd_plist(conf_path))
|
|
198
|
+
except OSError as exc:
|
|
199
|
+
return [f"could not write rotation assets: {exc}"]
|
|
200
|
+
return _ensure_launchd_loaded(plist_path)
|
|
201
|
+
|
|
202
|
+
conf_path = _logrotate_conf_path()
|
|
203
|
+
service_path = _systemd_service_path()
|
|
204
|
+
timer_path = _systemd_timer_path()
|
|
205
|
+
state_file = log_root / "logrotate.state"
|
|
206
|
+
try:
|
|
207
|
+
_write_if_changed(conf_path, _logrotate_conf(log_root))
|
|
208
|
+
_write_if_changed(service_path, _systemd_service(conf_path, state_file))
|
|
209
|
+
_write_if_changed(timer_path, _systemd_timer())
|
|
210
|
+
except OSError as exc:
|
|
211
|
+
return [f"could not write rotation assets: {exc}"]
|
|
212
|
+
return _ensure_systemd_loaded()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def main() -> None:
|
|
216
|
+
log_root = _state_home_root()
|
|
217
|
+
sys.stdout.write(f"log root: {log_root}\n")
|
|
218
|
+
sys.stdout.write(
|
|
219
|
+
f"rotation conf: {_newsyslog_conf_path() if sys.platform == 'darwin' else _logrotate_conf_path()}\n"
|
|
220
|
+
)
|
|
221
|
+
sys.stdout.write(f"scheduler: {_launchd_plist_path() if sys.platform == 'darwin' else _systemd_timer_path()}\n")
|
|
222
|
+
|
|
223
|
+
problems = ensure_rotation()
|
|
224
|
+
if problems:
|
|
225
|
+
for problem in problems:
|
|
226
|
+
sys.stdout.write(f"problem: {problem}\n")
|
|
227
|
+
raise SystemExit(1)
|
|
228
|
+
sys.stdout.write("status: healthy\n")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
if __name__ == "__main__":
|
|
232
|
+
main()
|
{instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/instrukt_ai_logging/logging.py
RENAMED
|
@@ -11,6 +11,8 @@ from logging.handlers import WatchedFileHandler
|
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
from typing import Any, Protocol, cast, runtime_checkable
|
|
13
13
|
|
|
14
|
+
from instrukt_ai_logging.install import ensure_rotation
|
|
15
|
+
|
|
14
16
|
# Standard logging levels are: NOTSET=0, DEBUG=10, INFO=20, WARNING=30, ERROR=40, CRITICAL=50
|
|
15
17
|
TRACE: int = 5
|
|
16
18
|
logging.addLevelName(TRACE, "TRACE")
|
|
@@ -345,10 +347,9 @@ class LoggingContract:
|
|
|
345
347
|
|
|
346
348
|
def _resolve_log_root(app_name: str) -> Path:
|
|
347
349
|
fs_app_name = _normalize_app_name(app_name)
|
|
348
|
-
|
|
349
|
-
if
|
|
350
|
-
|
|
351
|
-
return Path("/var/log/instrukt-ai") / fs_app_name
|
|
350
|
+
xdg_state_home = os.getenv("XDG_STATE_HOME")
|
|
351
|
+
state_home = Path(xdg_state_home).expanduser() if xdg_state_home else Path("~/.local/state").expanduser()
|
|
352
|
+
return state_home / "instrukt-ai" / fs_app_name
|
|
352
353
|
|
|
353
354
|
|
|
354
355
|
def _ensure_log_dir(log_dir: Path) -> None:
|
|
@@ -454,18 +455,19 @@ def configure_logging(
|
|
|
454
455
|
root_level = third_party_level if not spotlight else logging.WARNING
|
|
455
456
|
|
|
456
457
|
log_dir = _resolve_log_root(app_name)
|
|
457
|
-
_ensure_log_dir(log_dir)
|
|
458
|
-
|
|
459
458
|
log_file = log_dir / log_filename
|
|
460
459
|
|
|
461
460
|
formatter = LogfmtFormatter(max_message_chars=max_message_chars)
|
|
461
|
+
selector = _ThirdPartySelectorFilter(app_logger_prefix=app_logger_prefix, spotlight_prefixes=tuple(spotlight))
|
|
462
|
+
|
|
463
|
+
# Logging is an essential subsystem: an unwritable log dir/file raises here
|
|
464
|
+
# and the host process must not start blind. No degraded/fallback mode.
|
|
465
|
+
_ensure_log_dir(log_dir)
|
|
466
|
+
handler: logging.Handler = WatchedFileHandler(log_file, encoding="utf-8")
|
|
462
467
|
|
|
463
|
-
handler = WatchedFileHandler(log_file, encoding="utf-8")
|
|
464
468
|
handler.setLevel(logging.NOTSET)
|
|
465
469
|
handler.setFormatter(formatter)
|
|
466
|
-
handler.addFilter(
|
|
467
|
-
_ThirdPartySelectorFilter(app_logger_prefix=app_logger_prefix, spotlight_prefixes=tuple(spotlight))
|
|
468
|
-
)
|
|
470
|
+
handler.addFilter(selector)
|
|
469
471
|
|
|
470
472
|
# Configure root.
|
|
471
473
|
logging.root.handlers = [handler]
|
|
@@ -484,6 +486,11 @@ def configure_logging(
|
|
|
484
486
|
for prefix in muted:
|
|
485
487
|
logging.getLogger(prefix).setLevel(logging.WARNING)
|
|
486
488
|
|
|
489
|
+
# Rotation bootstrap is not logging: a failure here is surfaced as a
|
|
490
|
+
# warning, never fatal to an already-working log file.
|
|
491
|
+
for problem in ensure_rotation():
|
|
492
|
+
logging.getLogger(app_logger_prefix).warning("log rotation ensure problem: %s", problem)
|
|
493
|
+
|
|
487
494
|
return log_file
|
|
488
495
|
|
|
489
496
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: instruktai-python-logger
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary: Centralized logging utilities for Python services.
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -58,8 +58,8 @@ configure_logging("teleclaude", source="cron") # → cron.log
|
|
|
58
58
|
```
|
|
59
59
|
|
|
60
60
|
The `.log` extension is always appended; pass the bare stem. Files all live
|
|
61
|
-
under
|
|
62
|
-
(see below).
|
|
61
|
+
under `$XDG_STATE_HOME/instrukt-ai/{app}/` (fallback `~/.local/state`). The CLI
|
|
62
|
+
merges them transparently (see below).
|
|
63
63
|
|
|
64
64
|
## Environment variables (contract)
|
|
65
65
|
|
|
@@ -70,17 +70,32 @@ Per-app prefix model (example uses `TELECLAUDE_`):
|
|
|
70
70
|
- `TELECLAUDE_THIRD_PARTY_LOGGERS` (comma-separated logger prefixes to spotlight, e.g. `httpcore,telegram`)
|
|
71
71
|
- `TELECLAUDE_MUTED_LOGGERS` (comma-separated logger prefixes to force to WARNING+, e.g. `teleclaude.cli.tui`)
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
- `INSTRUKT_AI_LOG_ROOT` (optional log root override)
|
|
73
|
+
These four per-app variables are the entire contract. There is no log-root
|
|
74
|
+
override variable — the location is a fixed rule (see below).
|
|
76
75
|
|
|
77
76
|
## Log location (contract)
|
|
78
77
|
|
|
79
|
-
|
|
78
|
+
Every app's logs live at the same predictable, prose-citable location on both
|
|
79
|
+
macOS and Linux:
|
|
80
|
+
|
|
81
|
+
- `$XDG_STATE_HOME/instrukt-ai/{app}/{app}.log` (fallback `~/.local/state`,
|
|
82
|
+
per the XDG Base Directory spec)
|
|
83
|
+
|
|
84
|
+
This path is always user-writable — no installer step, no sudo, no elevated
|
|
85
|
+
permissions. `configure_logging(...)` raises immediately if the log file
|
|
86
|
+
cannot be opened; logging is an essential subsystem and a process that cannot
|
|
87
|
+
write its logs must not start blind.
|
|
80
88
|
|
|
81
|
-
|
|
89
|
+
## Rotation
|
|
82
90
|
|
|
83
|
-
|
|
91
|
+
Rotation is transparent and runs entirely in user space:
|
|
92
|
+
`configure_logging(...)` idempotently ensures a per-user rotation conf plus a
|
|
93
|
+
per-user scheduler (a launchd LaunchAgent on macOS running `newsyslog`, a
|
|
94
|
+
systemd user timer on Linux running `logrotate`) on every process start. No
|
|
95
|
+
`/etc` configuration, no sudo, and no system rotation daemon ever touches
|
|
96
|
+
these files, so rotated/recreated logs always stay owned by the producing
|
|
97
|
+
user. Run `instrukt-ai-log-setup` to check rotation-asset status manually;
|
|
98
|
+
normal operation never requires it.
|
|
84
99
|
|
|
85
100
|
## CLI
|
|
86
101
|
|
|
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "instruktai-python-logger"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.1"
|
|
8
8
|
description = "Centralized logging utilities for Python services."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
11
11
|
dependencies = []
|
|
12
12
|
|
|
13
13
|
[dependency-groups]
|
|
14
|
-
dev = ["pytest>=8", "ruff>=0.8", "pyright>=1.1", "mypy>=1.10"]
|
|
14
|
+
dev = ["pytest>=8", "pytest-timeout>=2.2.0", "ruff>=0.8", "pyright>=1.1", "mypy>=1.10"]
|
|
15
15
|
|
|
16
16
|
[project.scripts]
|
|
17
17
|
instrukt-ai-logs = "instrukt_ai_logging.cli:main"
|
|
@@ -25,8 +25,17 @@ include = ["instrukt_ai_logging*"]
|
|
|
25
25
|
instrukt_ai_logging = ["py.typed"]
|
|
26
26
|
|
|
27
27
|
[tool.pytest.ini_options]
|
|
28
|
-
addopts = "-q"
|
|
28
|
+
addopts = "--strict-config --strict-markers --tb=short -q"
|
|
29
29
|
testpaths = ["tests"]
|
|
30
|
+
timeout = 5
|
|
31
|
+
timeout_method = "signal"
|
|
32
|
+
session_timeout = 90
|
|
33
|
+
markers = [
|
|
34
|
+
"functional: fast, hermetic, no I/O beyond tmp_path",
|
|
35
|
+
"integration: exercises multiple in-process components together, still hermetic",
|
|
36
|
+
"process: drives an external process, bounded and hermetic (tmp_path/monkeypatch-isolated)",
|
|
37
|
+
"spec: binds a feature-spec scenario",
|
|
38
|
+
]
|
|
30
39
|
|
|
31
40
|
[tool.ruff]
|
|
32
41
|
line-length = 120
|
{instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/tests/test_configure_logging.py
RENAMED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
import os
|
|
2
3
|
from logging.handlers import WatchedFileHandler
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
from tempfile import TemporaryDirectory
|
|
5
6
|
|
|
6
7
|
import pytest
|
|
7
|
-
from instrukt_ai_logging import InstruktAILogger, configure_logging, get_logger
|
|
8
|
+
from instrukt_ai_logging import InstruktAILogger, configure_logging, get_logger, install
|
|
8
9
|
|
|
9
10
|
# Log markers shared by each test and its assertions — named constants per
|
|
10
11
|
# software-development/procedure/snapshot-testing (no bare literals in content
|
|
@@ -39,6 +40,10 @@ _FIELD_MSG_TUI_WARNING = f'msg="{_MSG_TUI_WARNING}"'
|
|
|
39
40
|
_FIELD_SESSION = f"session={_KV_SESSION}"
|
|
40
41
|
_FIELD_N = f"n={_KV_N}"
|
|
41
42
|
|
|
43
|
+
_APP_NAME = "teleclaude"
|
|
44
|
+
_ROTATION_PROBLEM_MARKER = "log rotation ensure problem"
|
|
45
|
+
_ROTATION_FAILURE_REASON = "no session bus"
|
|
46
|
+
|
|
42
47
|
|
|
43
48
|
@pytest.fixture()
|
|
44
49
|
def isolated_logging():
|
|
@@ -63,7 +68,7 @@ def _read_text(path: Path) -> str:
|
|
|
63
68
|
|
|
64
69
|
def test_our_logs_respect_app_level_and_third_party_baseline(isolated_logging, monkeypatch):
|
|
65
70
|
with TemporaryDirectory() as tmp:
|
|
66
|
-
monkeypatch.setenv("
|
|
71
|
+
monkeypatch.setenv("XDG_STATE_HOME", tmp)
|
|
67
72
|
monkeypatch.setenv("TELECLAUDE_LOG_LEVEL", "DEBUG")
|
|
68
73
|
monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOG_LEVEL", "WARNING")
|
|
69
74
|
monkeypatch.delenv("TELECLAUDE_THIRD_PARTY_LOGGERS", raising=False)
|
|
@@ -81,7 +86,7 @@ def test_our_logs_respect_app_level_and_third_party_baseline(isolated_logging, m
|
|
|
81
86
|
|
|
82
87
|
def test_configure_logging_uses_single_file_handler(isolated_logging, monkeypatch):
|
|
83
88
|
with TemporaryDirectory() as tmp:
|
|
84
|
-
monkeypatch.setenv("
|
|
89
|
+
monkeypatch.setenv("XDG_STATE_HOME", tmp)
|
|
85
90
|
monkeypatch.setenv("TELECLAUDE_LOG_LEVEL", "INFO")
|
|
86
91
|
monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOG_LEVEL", "WARNING")
|
|
87
92
|
|
|
@@ -91,6 +96,81 @@ def test_configure_logging_uses_single_file_handler(isolated_logging, monkeypatc
|
|
|
91
96
|
assert isinstance(logging.root.handlers[0], WatchedFileHandler)
|
|
92
97
|
|
|
93
98
|
|
|
99
|
+
def test_configure_logging_fails_fast_when_file_unwritable(isolated_logging, monkeypatch):
|
|
100
|
+
with TemporaryDirectory() as tmp:
|
|
101
|
+
monkeypatch.setenv("XDG_STATE_HOME", tmp)
|
|
102
|
+
monkeypatch.setenv("TELECLAUDE_LOG_LEVEL", "INFO")
|
|
103
|
+
monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOG_LEVEL", "WARNING")
|
|
104
|
+
|
|
105
|
+
# Pre-create the target log file at the path configure_logging resolves,
|
|
106
|
+
# then strip all permissions to simulate a root-owned/unwritable file
|
|
107
|
+
# left behind by a botched log rotation.
|
|
108
|
+
app_dir = Path(tmp) / "instrukt-ai" / _APP_NAME
|
|
109
|
+
app_dir.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
log_path = app_dir / f"{_APP_NAME}.log"
|
|
111
|
+
log_path.write_text("", encoding="utf-8")
|
|
112
|
+
os.chmod(log_path, 0)
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
# Logging is an essential subsystem: an unopenable log file must
|
|
116
|
+
# raise, never silently degrade.
|
|
117
|
+
with pytest.raises(PermissionError):
|
|
118
|
+
configure_logging(_APP_NAME)
|
|
119
|
+
finally:
|
|
120
|
+
# Restore permissions so the TemporaryDirectory cleanup can unlink it.
|
|
121
|
+
os.chmod(log_path, 0o644)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class _FakeFailingCompletedProcess:
|
|
125
|
+
def __init__(self) -> None:
|
|
126
|
+
self.returncode = 1
|
|
127
|
+
self.stderr = _ROTATION_FAILURE_REASON
|
|
128
|
+
self.stdout = ""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_configure_logging_warns_without_raising_when_rotation_ensure_fails(isolated_logging, monkeypatch):
|
|
132
|
+
with TemporaryDirectory() as tmp:
|
|
133
|
+
monkeypatch.setenv("XDG_STATE_HOME", tmp)
|
|
134
|
+
monkeypatch.setenv("TELECLAUDE_LOG_LEVEL", "INFO")
|
|
135
|
+
monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOG_LEVEL", "WARNING")
|
|
136
|
+
|
|
137
|
+
def _fake_run(args):
|
|
138
|
+
return _FakeFailingCompletedProcess()
|
|
139
|
+
|
|
140
|
+
monkeypatch.setattr(install, "_run_scheduler_command", _fake_run)
|
|
141
|
+
|
|
142
|
+
# A rotation-ensure failure must never crash logging — it's surfaced
|
|
143
|
+
# as a WARNING through the configured logger, not raised.
|
|
144
|
+
log_path = configure_logging(_APP_NAME)
|
|
145
|
+
|
|
146
|
+
content = _read_text(log_path)
|
|
147
|
+
assert _ROTATION_PROBLEM_MARKER in content
|
|
148
|
+
assert _ROTATION_FAILURE_REASON in content
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_configure_logging_resolves_xdg_state_home_location(isolated_logging, monkeypatch):
|
|
152
|
+
with TemporaryDirectory() as tmp:
|
|
153
|
+
monkeypatch.setenv("XDG_STATE_HOME", tmp)
|
|
154
|
+
monkeypatch.setenv("TELECLAUDE_LOG_LEVEL", "INFO")
|
|
155
|
+
monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOG_LEVEL", "WARNING")
|
|
156
|
+
|
|
157
|
+
log_path = configure_logging(_APP_NAME)
|
|
158
|
+
|
|
159
|
+
assert log_path == Path(tmp) / "instrukt-ai" / _APP_NAME / f"{_APP_NAME}.log"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_configure_logging_falls_back_to_local_state_home_when_xdg_unset(isolated_logging, monkeypatch):
|
|
163
|
+
with TemporaryDirectory() as tmp:
|
|
164
|
+
monkeypatch.delenv("XDG_STATE_HOME", raising=False)
|
|
165
|
+
monkeypatch.setenv("HOME", tmp)
|
|
166
|
+
monkeypatch.setenv("TELECLAUDE_LOG_LEVEL", "INFO")
|
|
167
|
+
monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOG_LEVEL", "WARNING")
|
|
168
|
+
|
|
169
|
+
log_path = configure_logging(_APP_NAME)
|
|
170
|
+
|
|
171
|
+
assert log_path == Path(tmp) / ".local" / "state" / "instrukt-ai" / _APP_NAME / f"{_APP_NAME}.log"
|
|
172
|
+
|
|
173
|
+
|
|
94
174
|
def test_get_logger_returns_instrukt_logger(isolated_logging):
|
|
95
175
|
logger = get_logger(_LOGGER_OURS)
|
|
96
176
|
assert isinstance(logger, InstruktAILogger)
|
|
@@ -99,7 +179,7 @@ def test_get_logger_returns_instrukt_logger(isolated_logging):
|
|
|
99
179
|
|
|
100
180
|
def test_spotlight_allows_selected_third_party_only(isolated_logging, monkeypatch):
|
|
101
181
|
with TemporaryDirectory() as tmp:
|
|
102
|
-
monkeypatch.setenv("
|
|
182
|
+
monkeypatch.setenv("XDG_STATE_HOME", tmp)
|
|
103
183
|
monkeypatch.setenv("TELECLAUDE_LOG_LEVEL", "INFO")
|
|
104
184
|
monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOG_LEVEL", "INFO")
|
|
105
185
|
monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOGGERS", "httpcore")
|
|
@@ -130,7 +210,7 @@ def test_spotlight_allows_selected_third_party_only(isolated_logging, monkeypatc
|
|
|
130
210
|
|
|
131
211
|
def test_named_kv_logger_emits_pairs(isolated_logging, monkeypatch):
|
|
132
212
|
with TemporaryDirectory() as tmp:
|
|
133
|
-
monkeypatch.setenv("
|
|
213
|
+
monkeypatch.setenv("XDG_STATE_HOME", tmp)
|
|
134
214
|
monkeypatch.setenv("TELECLAUDE_LOG_LEVEL", "INFO")
|
|
135
215
|
monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOG_LEVEL", "WARNING")
|
|
136
216
|
|
|
@@ -146,7 +226,7 @@ def test_named_kv_logger_emits_pairs(isolated_logging, monkeypatch):
|
|
|
146
226
|
|
|
147
227
|
def test_muted_loggers_forced_to_warning(isolated_logging, monkeypatch):
|
|
148
228
|
with TemporaryDirectory() as tmp:
|
|
149
|
-
monkeypatch.setenv("
|
|
229
|
+
monkeypatch.setenv("XDG_STATE_HOME", tmp)
|
|
150
230
|
monkeypatch.setenv("TELECLAUDE_LOG_LEVEL", "DEBUG")
|
|
151
231
|
monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOG_LEVEL", "WARNING")
|
|
152
232
|
monkeypatch.setenv("TELECLAUDE_MUTED_LOGGERS", _LOGGER_MUTED)
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from instrukt_ai_logging import install
|
|
10
|
+
|
|
11
|
+
# Protocol-significant tokens for the rotation configs — named constants per
|
|
12
|
+
# software-development/procedure/snapshot-testing (no bare literals in content
|
|
13
|
+
# assertions). These are newsyslog/logrotate/launchd/systemd field identities,
|
|
14
|
+
# not prose.
|
|
15
|
+
_NEWSYSLOG_MODE = "640"
|
|
16
|
+
_NEWSYSLOG_COUNT = "5"
|
|
17
|
+
_NEWSYSLOG_SIZE_KB = "50000"
|
|
18
|
+
_NEWSYSLOG_WHEN = "*"
|
|
19
|
+
_NEWSYSLOG_COMPRESS_GLOB_FLAGS = "ZG" # gzip-compress archives; glob-expand at rotation time
|
|
20
|
+
_PLIST_LABEL = "ai.instrukt.log-rotate"
|
|
21
|
+
_PLIST_PROGRAM = "/usr/sbin/newsyslog"
|
|
22
|
+
_PLIST_NO_ROOT_FLAG = "<string>-r</string>" # newsyslog otherwise refuses to run as a non-root user
|
|
23
|
+
_PLIST_START_INTERVAL = "1800"
|
|
24
|
+
_LOGROTATE_SIZE = "size 50M"
|
|
25
|
+
_LOGROTATE_ROTATE = "rotate 5"
|
|
26
|
+
_LOGROTATE_COMPRESS = "compress"
|
|
27
|
+
_LOGROTATE_DELAYCOMPRESS = "delaycompress"
|
|
28
|
+
_LOGROTATE_MISSINGOK = "missingok"
|
|
29
|
+
_LOGROTATE_NOTIFEMPTY = "notifempty"
|
|
30
|
+
_SYSTEMD_EXEC_START_PREFIX = "ExecStart=logrotate"
|
|
31
|
+
_SYSTEMD_ONESHOT = "Type=oneshot"
|
|
32
|
+
_SYSTEMD_ON_CALENDAR = "OnCalendar=*:0/30"
|
|
33
|
+
_SYSTEMD_PERSISTENT = "Persistent=true"
|
|
34
|
+
_SYSTEMD_TIMER_UNIT = "instrukt-ai-logrotate.timer"
|
|
35
|
+
_PROBLEM_DAEMON_RELOAD = "daemon-reload"
|
|
36
|
+
_PROBLEM_WRITE_FAILURE = "could not write rotation assets"
|
|
37
|
+
_MAIN_LOG_ROOT_PREFIX = "log root:"
|
|
38
|
+
_MAIN_HEALTHY_STATUS = "status: healthy"
|
|
39
|
+
_MAIN_PROBLEM_PREFIX = "problem:"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _FakeCompletedProcess:
|
|
43
|
+
def __init__(self, returncode: int, stderr: str = "") -> None:
|
|
44
|
+
self.returncode = returncode
|
|
45
|
+
self.stderr = stderr
|
|
46
|
+
self.stdout = ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.fixture
|
|
50
|
+
def rotation_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
51
|
+
"""Isolate the rotation-asset filesystem via XDG_STATE_HOME and HOME."""
|
|
52
|
+
state_home = tmp_path / "state"
|
|
53
|
+
home = tmp_path / "home"
|
|
54
|
+
state_home.mkdir(exist_ok=True)
|
|
55
|
+
home.mkdir(exist_ok=True)
|
|
56
|
+
monkeypatch.setenv("XDG_STATE_HOME", str(state_home))
|
|
57
|
+
monkeypatch.setenv("HOME", str(home))
|
|
58
|
+
return state_home
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.fixture
|
|
62
|
+
def successful_subprocess(monkeypatch: pytest.MonkeyPatch) -> list[list[str]]:
|
|
63
|
+
"""Fake the scheduler subprocess boundary (launchctl/systemctl) as always succeeding."""
|
|
64
|
+
calls: list[list[str]] = []
|
|
65
|
+
|
|
66
|
+
def _fake_run(args: list[str]) -> _FakeCompletedProcess:
|
|
67
|
+
calls.append(args)
|
|
68
|
+
return _FakeCompletedProcess(returncode=0)
|
|
69
|
+
|
|
70
|
+
monkeypatch.setattr(install, "_run_scheduler_command", _fake_run)
|
|
71
|
+
return calls
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_ensure_rotation_darwin_writes_newsyslog_conf_and_plist(
|
|
75
|
+
rotation_env: Path,
|
|
76
|
+
successful_subprocess: list[list[str]],
|
|
77
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
78
|
+
) -> None:
|
|
79
|
+
monkeypatch.setattr(sys, "platform", "darwin")
|
|
80
|
+
|
|
81
|
+
problems = install.ensure_rotation()
|
|
82
|
+
|
|
83
|
+
assert problems == []
|
|
84
|
+
conf_content = install._newsyslog_conf_path().read_text(encoding="utf-8")
|
|
85
|
+
path, mode, count, size_kb, when, flags = conf_content.split()
|
|
86
|
+
assert path == f"{rotation_env / 'instrukt-ai'}/*/*.log"
|
|
87
|
+
assert mode == _NEWSYSLOG_MODE
|
|
88
|
+
assert count == _NEWSYSLOG_COUNT
|
|
89
|
+
assert size_kb == _NEWSYSLOG_SIZE_KB
|
|
90
|
+
assert when == _NEWSYSLOG_WHEN
|
|
91
|
+
assert flags == _NEWSYSLOG_COMPRESS_GLOB_FLAGS
|
|
92
|
+
|
|
93
|
+
plist_content = install._launchd_plist_path().read_text(encoding="utf-8")
|
|
94
|
+
assert _PLIST_LABEL in plist_content
|
|
95
|
+
assert _PLIST_PROGRAM in plist_content
|
|
96
|
+
assert _PLIST_NO_ROOT_FLAG in plist_content
|
|
97
|
+
assert _PLIST_START_INTERVAL in plist_content
|
|
98
|
+
|
|
99
|
+
assert successful_subprocess == [
|
|
100
|
+
["launchctl", "bootstrap", f"gui/{os.getuid()}", str(install._launchd_plist_path())]
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_ensure_rotation_linux_writes_logrotate_conf_and_systemd_units(
|
|
105
|
+
rotation_env: Path,
|
|
106
|
+
successful_subprocess: list[list[str]],
|
|
107
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
108
|
+
) -> None:
|
|
109
|
+
monkeypatch.setattr(sys, "platform", "linux")
|
|
110
|
+
|
|
111
|
+
problems = install.ensure_rotation()
|
|
112
|
+
|
|
113
|
+
assert problems == []
|
|
114
|
+
logrotate_content = install._logrotate_conf_path().read_text(encoding="utf-8")
|
|
115
|
+
assert f"{rotation_env / 'instrukt-ai'}/*/*.log" in logrotate_content
|
|
116
|
+
for directive in (
|
|
117
|
+
_LOGROTATE_SIZE,
|
|
118
|
+
_LOGROTATE_ROTATE,
|
|
119
|
+
_LOGROTATE_COMPRESS,
|
|
120
|
+
_LOGROTATE_DELAYCOMPRESS,
|
|
121
|
+
_LOGROTATE_MISSINGOK,
|
|
122
|
+
_LOGROTATE_NOTIFEMPTY,
|
|
123
|
+
):
|
|
124
|
+
assert directive in logrotate_content
|
|
125
|
+
assert "su " not in logrotate_content # rotation already runs as the producing user
|
|
126
|
+
|
|
127
|
+
service_content = install._systemd_service_path().read_text(encoding="utf-8")
|
|
128
|
+
assert _SYSTEMD_EXEC_START_PREFIX in service_content
|
|
129
|
+
assert _SYSTEMD_ONESHOT in service_content
|
|
130
|
+
|
|
131
|
+
timer_content = install._systemd_timer_path().read_text(encoding="utf-8")
|
|
132
|
+
assert _SYSTEMD_ON_CALENDAR in timer_content
|
|
133
|
+
assert _SYSTEMD_PERSISTENT in timer_content
|
|
134
|
+
|
|
135
|
+
assert successful_subprocess == [
|
|
136
|
+
["systemctl", "--user", "daemon-reload"],
|
|
137
|
+
["systemctl", "--user", "enable", "--now", _SYSTEMD_TIMER_UNIT],
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_ensure_rotation_is_idempotent_across_runs(
|
|
142
|
+
rotation_env: Path,
|
|
143
|
+
successful_subprocess: list[list[str]],
|
|
144
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
145
|
+
) -> None:
|
|
146
|
+
monkeypatch.setattr(sys, "platform", "linux")
|
|
147
|
+
|
|
148
|
+
install.ensure_rotation()
|
|
149
|
+
conf_path = install._logrotate_conf_path()
|
|
150
|
+
first_mtime_ns = conf_path.stat().st_mtime_ns
|
|
151
|
+
first_content = conf_path.read_text(encoding="utf-8")
|
|
152
|
+
|
|
153
|
+
install.ensure_rotation()
|
|
154
|
+
|
|
155
|
+
assert conf_path.stat().st_mtime_ns == first_mtime_ns
|
|
156
|
+
assert conf_path.read_text(encoding="utf-8") == first_content
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_ensure_rotation_reports_problem_when_scheduler_subprocess_fails(
|
|
160
|
+
rotation_env: Path, monkeypatch: pytest.MonkeyPatch
|
|
161
|
+
) -> None:
|
|
162
|
+
monkeypatch.setattr(sys, "platform", "linux")
|
|
163
|
+
|
|
164
|
+
def _fake_run(args: list[str]) -> _FakeCompletedProcess:
|
|
165
|
+
return _FakeCompletedProcess(returncode=1, stderr="no session bus")
|
|
166
|
+
|
|
167
|
+
monkeypatch.setattr(install, "_run_scheduler_command", _fake_run)
|
|
168
|
+
|
|
169
|
+
problems = install.ensure_rotation()
|
|
170
|
+
|
|
171
|
+
assert problems
|
|
172
|
+
assert any(_PROBLEM_DAEMON_RELOAD in problem for problem in problems)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_ensure_rotation_reports_problem_when_scheduler_subprocess_times_out(
|
|
176
|
+
rotation_env: Path, monkeypatch: pytest.MonkeyPatch
|
|
177
|
+
) -> None:
|
|
178
|
+
monkeypatch.setattr(sys, "platform", "linux")
|
|
179
|
+
|
|
180
|
+
def _fake_run(args: list[str]) -> _FakeCompletedProcess:
|
|
181
|
+
raise subprocess.TimeoutExpired(cmd=args, timeout=install._SCHEDULER_COMMAND_TIMEOUT_S)
|
|
182
|
+
|
|
183
|
+
monkeypatch.setattr(install, "_run_scheduler_command", _fake_run)
|
|
184
|
+
|
|
185
|
+
# An unreachable scheduler bus must be reported, never left to hang the
|
|
186
|
+
# caller indefinitely.
|
|
187
|
+
problems = install.ensure_rotation()
|
|
188
|
+
|
|
189
|
+
assert problems
|
|
190
|
+
assert any(_PROBLEM_DAEMON_RELOAD in problem for problem in problems)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_ensure_rotation_reports_problem_when_asset_write_fails(
|
|
194
|
+
rotation_env: Path, monkeypatch: pytest.MonkeyPatch
|
|
195
|
+
) -> None:
|
|
196
|
+
monkeypatch.setattr(sys, "platform", "linux")
|
|
197
|
+
|
|
198
|
+
def _raise_write_error(path: Path, content: str) -> None:
|
|
199
|
+
raise OSError("read-only file system")
|
|
200
|
+
|
|
201
|
+
monkeypatch.setattr(install, "_write_if_changed", _raise_write_error)
|
|
202
|
+
|
|
203
|
+
# An unwritable rotation-asset directory must be reported, never left to
|
|
204
|
+
# propagate out of ensure_rotation()'s never-raises contract.
|
|
205
|
+
problems = install.ensure_rotation()
|
|
206
|
+
|
|
207
|
+
assert problems
|
|
208
|
+
assert any(_PROBLEM_WRITE_FAILURE in problem for problem in problems)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_ensure_rotation_tolerates_already_bootstrapped_launchd(
|
|
212
|
+
rotation_env: Path, monkeypatch: pytest.MonkeyPatch
|
|
213
|
+
) -> None:
|
|
214
|
+
monkeypatch.setattr(sys, "platform", "darwin")
|
|
215
|
+
|
|
216
|
+
def _fake_run(args: list[str]) -> _FakeCompletedProcess:
|
|
217
|
+
return _FakeCompletedProcess(returncode=1, stderr="Service already bootstrapped")
|
|
218
|
+
|
|
219
|
+
monkeypatch.setattr(install, "_run_scheduler_command", _fake_run)
|
|
220
|
+
|
|
221
|
+
problems = install.ensure_rotation()
|
|
222
|
+
|
|
223
|
+
assert problems == []
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_main_prints_status_and_exits_zero_when_healthy(
|
|
227
|
+
rotation_env: Path,
|
|
228
|
+
successful_subprocess: list[list[str]],
|
|
229
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
230
|
+
capsys: pytest.CaptureFixture[str],
|
|
231
|
+
) -> None:
|
|
232
|
+
monkeypatch.setattr(sys, "platform", "linux")
|
|
233
|
+
|
|
234
|
+
install.main()
|
|
235
|
+
|
|
236
|
+
out = capsys.readouterr().out
|
|
237
|
+
assert _MAIN_LOG_ROOT_PREFIX in out
|
|
238
|
+
assert _MAIN_HEALTHY_STATUS in out
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_main_exits_non_zero_and_reports_problems(
|
|
242
|
+
rotation_env: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
243
|
+
) -> None:
|
|
244
|
+
monkeypatch.setattr(sys, "platform", "linux")
|
|
245
|
+
|
|
246
|
+
def _fake_run(args: list[str]) -> _FakeCompletedProcess:
|
|
247
|
+
return _FakeCompletedProcess(returncode=1, stderr="boom")
|
|
248
|
+
|
|
249
|
+
monkeypatch.setattr(install, "_run_scheduler_command", _fake_run)
|
|
250
|
+
|
|
251
|
+
with pytest.raises(SystemExit):
|
|
252
|
+
install.main()
|
|
253
|
+
|
|
254
|
+
out = capsys.readouterr().out
|
|
255
|
+
assert _MAIN_PROBLEM_PREFIX in out
|
{instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/tests/test_log_aggregation.py
RENAMED
|
@@ -43,9 +43,9 @@ _LARGE_LINE_COUNT = 20000
|
|
|
43
43
|
def app_log_dir(monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]:
|
|
44
44
|
tmp = TemporaryDirectory()
|
|
45
45
|
root = Path(tmp.name)
|
|
46
|
-
monkeypatch.setenv("
|
|
46
|
+
monkeypatch.setenv("XDG_STATE_HOME", str(root))
|
|
47
47
|
|
|
48
|
-
app_dir = root / "demo-app"
|
|
48
|
+
app_dir = root / "instrukt-ai" / "demo-app"
|
|
49
49
|
app_dir.mkdir(parents=True)
|
|
50
50
|
yield app_dir
|
|
51
51
|
tmp.cleanup()
|
|
@@ -83,7 +83,7 @@ def test_resolve_log_files_returns_empty_for_unknown_stem(app_log_dir: Path) ->
|
|
|
83
83
|
|
|
84
84
|
def test_resolve_log_files_returns_empty_when_dir_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
85
85
|
with TemporaryDirectory() as tmp:
|
|
86
|
-
monkeypatch.setenv("
|
|
86
|
+
monkeypatch.setenv("XDG_STATE_HOME", tmp)
|
|
87
87
|
# Directory for the app does not exist.
|
|
88
88
|
assert resolve_log_files("nonexistent-app") == []
|
|
89
89
|
|
{instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/tests/test_trace_logging.py
RENAMED
|
@@ -40,7 +40,7 @@ def test_trace_level_logging(clean_env):
|
|
|
40
40
|
|
|
41
41
|
# Use a temporary directory for logs
|
|
42
42
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
43
|
-
os.environ["
|
|
43
|
+
os.environ["XDG_STATE_HOME"] = temp_dir
|
|
44
44
|
|
|
45
45
|
log_file = configure_logging(app_name)
|
|
46
46
|
logger = get_logger("test_trace_app")
|
|
@@ -71,7 +71,7 @@ def test_trace_level_filtering(clean_env):
|
|
|
71
71
|
os.environ["TEST_TRACE_FILTERING_LOG_LEVEL"] = "DEBUG"
|
|
72
72
|
|
|
73
73
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
74
|
-
os.environ["
|
|
74
|
+
os.environ["XDG_STATE_HOME"] = temp_dir
|
|
75
75
|
|
|
76
76
|
log_file = configure_logging(app_name)
|
|
77
77
|
logger = get_logger("test_trace_filtering")
|
|
@@ -1,96 +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
|
-
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()
|
|
@@ -1,131 +0,0 @@
|
|
|
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.5.1 → instruktai_python_logger-0.6.1}/instrukt_ai_logging/__init__.py
RENAMED
|
File without changes
|
{instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/instrukt_ai_logging/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|