instruktai-python-logger 0.1.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.
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: instruktai-python-logger
3
+ Version: 0.1.0
4
+ Summary: Centralized logging utilities for InstruktAI Python services.
5
+ Requires-Python: >=3.11
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
+
11
+ # `instruktai-python-logger`
12
+
13
+ Centralized logging utilities for InstruktAI Python services.
14
+
15
+ This repo provides a shared, consistent logging contract (env vars + output format + log location) intended to keep logs highly queryable (including by AIs that only read a tail window).
16
+
17
+ Background and rationale live in `docs/design.md`.
18
+ Publishing notes live in `docs/publishing.md`.
19
+
20
+ ## Install (editable, local workspace)
21
+
22
+ From PyPI (recommended):
23
+
24
+ ```bash
25
+ pip install instruktai-python-logger
26
+ ```
27
+
28
+ From GitHub:
29
+
30
+ ```bash
31
+ pip install git+ssh://git@github.com/InstruktAI/python-logger.git
32
+ ```
33
+
34
+ ## API
35
+
36
+ - Python entrypoint: `instrukt_ai_logging.configure_logging(...)`
37
+ - Structured logging helper: `instrukt_ai_logging.log_kv(logger, level, {"msg": "...", ...})`
38
+ - CLI entrypoint: `instrukt-ai-logs` (reads recent log lines)
39
+
40
+ ## Environment variables (contract)
41
+
42
+ Per-app prefix model (example uses `TELECLAUDE_`):
43
+
44
+ - `TELECLAUDE_LOG_LEVEL`
45
+ - `TELECLAUDE_THIRD_PARTY_LOG_LEVEL`
46
+ - `TELECLAUDE_THIRD_PARTY_LOGGERS` (comma-separated logger prefixes, e.g. `httpcore,telegram`)
47
+
48
+ Global:
49
+
50
+ - `INSTRUKT_AI_LOG_ROOT` (optional log root override)
51
+
52
+ ## Log location (contract)
53
+
54
+ Default target:
55
+
56
+ - `/var/log/instrukt-ai/{app}/{app}.log`
57
+
58
+ The installer for each service is expected to create the directory and set write permissions for the daemon user. If the default location is not writable, the implementation will fall back to a user-writable directory and/or require `INSTRUKT_AI_LOG_ROOT`.
@@ -0,0 +1,48 @@
1
+ # `instruktai-python-logger`
2
+
3
+ Centralized logging utilities for InstruktAI Python services.
4
+
5
+ This repo provides a shared, consistent logging contract (env vars + output format + log location) intended to keep logs highly queryable (including by AIs that only read a tail window).
6
+
7
+ Background and rationale live in `docs/design.md`.
8
+ Publishing notes live in `docs/publishing.md`.
9
+
10
+ ## Install (editable, local workspace)
11
+
12
+ From PyPI (recommended):
13
+
14
+ ```bash
15
+ pip install instruktai-python-logger
16
+ ```
17
+
18
+ From GitHub:
19
+
20
+ ```bash
21
+ pip install git+ssh://git@github.com/InstruktAI/python-logger.git
22
+ ```
23
+
24
+ ## API
25
+
26
+ - Python entrypoint: `instrukt_ai_logging.configure_logging(...)`
27
+ - Structured logging helper: `instrukt_ai_logging.log_kv(logger, level, {"msg": "...", ...})`
28
+ - CLI entrypoint: `instrukt-ai-logs` (reads recent log lines)
29
+
30
+ ## Environment variables (contract)
31
+
32
+ Per-app prefix model (example uses `TELECLAUDE_`):
33
+
34
+ - `TELECLAUDE_LOG_LEVEL`
35
+ - `TELECLAUDE_THIRD_PARTY_LOG_LEVEL`
36
+ - `TELECLAUDE_THIRD_PARTY_LOGGERS` (comma-separated logger prefixes, e.g. `httpcore,telegram`)
37
+
38
+ Global:
39
+
40
+ - `INSTRUKT_AI_LOG_ROOT` (optional log root override)
41
+
42
+ ## Log location (contract)
43
+
44
+ Default target:
45
+
46
+ - `/var/log/instrukt-ai/{app}/{app}.log`
47
+
48
+ The installer for each service is expected to create the directory and set write permissions for the daemon user. If the default location is not writable, the implementation will fall back to a user-writable directory and/or require `INSTRUKT_AI_LOG_ROOT`.
@@ -0,0 +1,14 @@
1
+ """InstruktAI logging standard library.
2
+
3
+ See `README.md` for usage and `docs/design.md` for design intent.
4
+ """
5
+
6
+ __all__ = [
7
+ "__version__",
8
+ "configure_logging",
9
+ "log_kv",
10
+ ]
11
+
12
+ __version__ = "0.0.0"
13
+
14
+ from instrukt_ai_logging.logging import configure_logging, log_kv # noqa: E402 (intentional re-export)
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import re
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from instrukt_ai_logging.logging import (
9
+ _fallback_log_root,
10
+ _resolve_log_root,
11
+ iter_recent_log_lines,
12
+ parse_since,
13
+ )
14
+
15
+
16
+ def _resolve_log_file(app: str) -> Path:
17
+ primary_dir = _resolve_log_root(app)
18
+ primary_file = primary_dir / f"{app}.log"
19
+ if primary_file.exists():
20
+ return primary_file
21
+
22
+ fallback_dir = _fallback_log_root(app)
23
+ fallback_file = fallback_dir / f"{app}.log"
24
+ if fallback_file.exists():
25
+ return fallback_file
26
+
27
+ return primary_file
28
+
29
+
30
+ def main() -> None:
31
+ parser = argparse.ArgumentParser(prog="instrukt-ai-logs", add_help=True)
32
+ parser.add_argument("app", help="App/service name (folder and filename stem)")
33
+ parser.add_argument(
34
+ "--since", default="10m", help="Time window (e.g. 10m, 2h, 1d). Default: 10m"
35
+ )
36
+ parser.add_argument("--grep", default="", help="Regex to filter lines (optional)")
37
+ args = parser.parse_args()
38
+
39
+ try:
40
+ since = parse_since(args.since)
41
+ except ValueError as e:
42
+ raise SystemExit(str(e)) from e
43
+
44
+ log_file = _resolve_log_file(args.app)
45
+ if not log_file.exists():
46
+ raise SystemExit(f"Log file not found: {log_file}")
47
+
48
+ pattern = re.compile(args.grep) if args.grep else None
49
+ for line in iter_recent_log_lines(log_file, since):
50
+ if pattern and not pattern.search(line):
51
+ continue
52
+ sys.stdout.write(line)
@@ -0,0 +1,366 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import re
6
+ import sys
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from datetime import datetime, timedelta, timezone
10
+ from logging.handlers import WatchedFileHandler
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+
15
+ def _level_name_to_int(level_name: str, default: int) -> int:
16
+ name = level_name.strip().upper()
17
+ if not name:
18
+ return default
19
+ candidate: object = getattr(logging, name, None)
20
+ if isinstance(candidate, int):
21
+ return candidate
22
+ return default
23
+
24
+
25
+ def _parse_csv(value: str) -> list[str]:
26
+ return [item.strip() for item in value.split(",") if item.strip()]
27
+
28
+
29
+ def _now_utc() -> datetime:
30
+ return datetime.now(tz=timezone.utc)
31
+
32
+
33
+ class UtcMillisFormatter(logging.Formatter):
34
+ def formatTime(self, record: logging.LogRecord, datefmt: str | None = None) -> str:
35
+ dt = datetime.fromtimestamp(record.created, tz=timezone.utc)
36
+ if datefmt:
37
+ return dt.strftime(datefmt)
38
+ return dt.strftime("%Y-%m-%dT%H:%M:%S") + f".{int(record.msecs):03d}Z"
39
+
40
+
41
+ _SAFE_BARE_VALUE = re.compile(r"^[A-Za-z0-9._/:+-]+$")
42
+ _SAFE_KEY = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]*$")
43
+
44
+ # Keep this small and high-signal; expand only with strong justification.
45
+ _REDACTION_PATTERNS: list[tuple[re.Pattern[str], str]] = [
46
+ # Telegram bot token in API URLs: https://api.telegram.org/bot<token>/...
47
+ (re.compile(r"(api\\.telegram\\.org/bot)([^/\\s]+)"), r"\\1<REDACTED>"),
48
+ # Generic Bearer token
49
+ (re.compile(r"\\bBearer\\s+[^\\s]+"), "Bearer <REDACTED>"),
50
+ # Common OpenAI-style key prefix (avoid leaking)
51
+ (re.compile(r"\\bsk-[A-Za-z0-9]{10,}\\b"), "sk-<REDACTED>"),
52
+ ]
53
+
54
+
55
+ def _redact_text(text: str) -> str:
56
+ redacted = text
57
+ for pattern, replacement in _REDACTION_PATTERNS:
58
+ redacted = pattern.sub(replacement, redacted)
59
+ return redacted
60
+
61
+
62
+ def _truncate_text(text: str, max_chars: int) -> str:
63
+ if max_chars > 0 and len(text) > max_chars:
64
+ return text[:max_chars] + "…(truncated)"
65
+ return text
66
+
67
+
68
+ def _escape_quotes(value: str) -> str:
69
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\r", "\\r")
70
+
71
+
72
+ def _format_logfmt_value(value: object, *, max_chars: int) -> str:
73
+ if value is None:
74
+ return "null"
75
+ if isinstance(value, bool):
76
+ return "true" if value else "false"
77
+ if isinstance(value, (int, float)):
78
+ return str(value)
79
+
80
+ text = _redact_text(str(value))
81
+ text = _truncate_text(text, max_chars=max_chars)
82
+
83
+ if _SAFE_BARE_VALUE.fullmatch(text):
84
+ return text
85
+ return f'"{_escape_quotes(text)}"'
86
+
87
+
88
+ def _format_logfmt_string(text: str, *, max_chars: int, force_quote: bool) -> str:
89
+ redacted = _redact_text(text)
90
+ redacted = _truncate_text(redacted, max_chars=max_chars)
91
+ if not force_quote and _SAFE_BARE_VALUE.fullmatch(redacted):
92
+ return redacted
93
+ return f'"{_escape_quotes(redacted)}"'
94
+
95
+
96
+ class LogfmtFormatter(UtcMillisFormatter):
97
+ """Single-line logfmt-ish formatter.
98
+
99
+ Always emits key/value pairs with at least:
100
+ level=..., logger=..., msg="..."
101
+
102
+ Optional additional pairs can be attached via `extra={"kv": {...}}`.
103
+ """
104
+
105
+ def __init__(self, max_message_chars: int) -> None:
106
+ super().__init__(datefmt=None)
107
+ self.max_message_chars = max_message_chars
108
+
109
+ def format(self, record: logging.LogRecord) -> str:
110
+ ts = self.formatTime(record)
111
+ try:
112
+ message = record.getMessage()
113
+ except Exception:
114
+ message = "<unprintable>"
115
+
116
+ parts = [
117
+ ts,
118
+ f"level={_format_logfmt_value(record.levelname, max_chars=self.max_message_chars)}",
119
+ f"logger={_format_logfmt_value(record.name, max_chars=self.max_message_chars)}",
120
+ f"msg={_format_logfmt_string(str(message), max_chars=self.max_message_chars, force_quote=True)}",
121
+ ]
122
+
123
+ kv: object = getattr(record, "kv", None)
124
+ if isinstance(kv, dict):
125
+ for key in sorted(kv.keys(), key=lambda x: str(x)):
126
+ if key == "msg":
127
+ continue
128
+ if not isinstance(key, str):
129
+ continue
130
+ if not _SAFE_KEY.fullmatch(key):
131
+ continue
132
+ parts.append(
133
+ f"{key}={_format_logfmt_value(kv[key], max_chars=self.max_message_chars)}"
134
+ )
135
+
136
+ if record.exc_info:
137
+ try:
138
+ exc_text = self.formatException(record.exc_info).replace("\n", "\\n")
139
+ except Exception:
140
+ exc_text = "<exception>"
141
+ parts.append(f"exc={_format_logfmt_value(exc_text, max_chars=self.max_message_chars)}")
142
+
143
+ return " ".join(parts)
144
+
145
+
146
+ def log_kv(logger: logging.Logger, level: int, data: Mapping[str, Any]) -> None:
147
+ """Log a key/value event.
148
+
149
+ `data` MUST include `msg` and may include any other serializable values.
150
+ """
151
+ if "msg" not in data:
152
+ raise ValueError('log_kv requires a "msg" key')
153
+
154
+ msg = str(data["msg"])
155
+ kv: dict[str, object] = {}
156
+ for key, value in data.items():
157
+ if key == "msg":
158
+ continue
159
+ if not _SAFE_KEY.fullmatch(key):
160
+ raise ValueError(f"Invalid key for log_kv: {key!r}")
161
+ kv[key] = value
162
+
163
+ logger.log(level, msg, extra={"kv": kv})
164
+
165
+
166
+ class _ThirdPartySelectorFilter(logging.Filter):
167
+ """Enforce third-party spotlight semantics on a handler.
168
+
169
+ - Always allow records from `app_logger_prefix`.
170
+ - For non-app loggers:
171
+ - If `spotlight_prefixes` is empty: allow (level gating happens via logger levels).
172
+ - If `spotlight_prefixes` is set: only allow records whose logger name starts with one of them.
173
+ """
174
+
175
+ def __init__(self, app_logger_prefix: str, spotlight_prefixes: tuple[str, ...]) -> None:
176
+ super().__init__()
177
+ self.app_logger_prefix = app_logger_prefix
178
+ self.spotlight_prefixes = spotlight_prefixes
179
+
180
+ def filter(self, record: logging.LogRecord) -> bool: # noqa: A003
181
+ name = record.name
182
+ if name == self.app_logger_prefix or name.startswith(self.app_logger_prefix + "."):
183
+ return True
184
+ if not self.spotlight_prefixes:
185
+ return True
186
+ return any(name == p or name.startswith(p + ".") for p in self.spotlight_prefixes)
187
+
188
+
189
+ @dataclass(frozen=True)
190
+ class LoggingContract:
191
+ env_prefix: str
192
+ app_logger_prefix: str
193
+ app_name: str
194
+
195
+ @property
196
+ def env_log_level(self) -> str:
197
+ return f"{self.env_prefix}_LOG_LEVEL"
198
+
199
+ @property
200
+ def env_third_party_level(self) -> str:
201
+ return f"{self.env_prefix}_THIRD_PARTY_LOG_LEVEL"
202
+
203
+ @property
204
+ def env_third_party_loggers(self) -> str:
205
+ return f"{self.env_prefix}_THIRD_PARTY_LOGGERS"
206
+
207
+
208
+ def _resolve_log_root(app_name: str) -> Path:
209
+ override = os.getenv("INSTRUKT_AI_LOG_ROOT")
210
+ if override:
211
+ return Path(override).expanduser()
212
+ return Path("/var/log/instrukt-ai") / app_name
213
+
214
+
215
+ def _fallback_log_root(app_name: str) -> Path:
216
+ # Deterministic fallback: repo-local ./logs if available, else /tmp.
217
+ candidate = Path.cwd() / "logs"
218
+ if candidate.exists() or candidate.parent.exists():
219
+ return candidate
220
+ return Path("/tmp") / "instrukt-ai" / app_name
221
+
222
+
223
+ def _ensure_log_dir(log_dir: Path) -> None:
224
+ log_dir.mkdir(parents=True, exist_ok=True)
225
+
226
+
227
+ def configure_logging(
228
+ *,
229
+ env_prefix: str,
230
+ app_logger_prefix: str,
231
+ app_name: str,
232
+ log_filename: str | None = None,
233
+ max_message_chars: int = 4000,
234
+ ) -> Path:
235
+ """Configure logging according to the InstruktAI contract.
236
+
237
+ Returns the resolved log file path in use.
238
+ """
239
+ contract = LoggingContract(
240
+ env_prefix=env_prefix,
241
+ app_logger_prefix=app_logger_prefix,
242
+ app_name=app_name,
243
+ )
244
+
245
+ our_level_name = (os.getenv(contract.env_log_level) or "INFO").upper()
246
+ third_party_level_name = (os.getenv(contract.env_third_party_level) or "WARNING").upper()
247
+ spotlight = _parse_csv(os.getenv(contract.env_third_party_loggers, ""))
248
+
249
+ our_level = _level_name_to_int(our_level_name, logging.INFO)
250
+ third_party_level = _level_name_to_int(third_party_level_name, logging.WARNING)
251
+
252
+ # Root logger governs all loggers that don't explicitly set a level.
253
+ root_level = third_party_level if not spotlight else logging.WARNING
254
+
255
+ log_dir = _resolve_log_root(app_name)
256
+ try:
257
+ _ensure_log_dir(log_dir)
258
+ except (PermissionError, OSError):
259
+ log_dir = _fallback_log_root(app_name)
260
+ _ensure_log_dir(log_dir)
261
+
262
+ log_file = log_dir / (log_filename or f"{app_name}.log")
263
+
264
+ formatter = LogfmtFormatter(max_message_chars=max_message_chars)
265
+
266
+ handler = WatchedFileHandler(log_file, encoding="utf-8")
267
+ handler.setLevel(logging.NOTSET)
268
+ handler.setFormatter(formatter)
269
+ handler.addFilter(
270
+ _ThirdPartySelectorFilter(
271
+ app_logger_prefix=app_logger_prefix, spotlight_prefixes=tuple(spotlight)
272
+ )
273
+ )
274
+
275
+ # Configure root.
276
+ logging.root.handlers = [handler]
277
+ logging.root.setLevel(root_level)
278
+
279
+ # Configure our logs.
280
+ logging.getLogger(app_logger_prefix).setLevel(our_level)
281
+
282
+ # Configure spotlight third-party prefixes (only affects non-app loggers).
283
+ for prefix in spotlight:
284
+ if prefix == app_logger_prefix or prefix.startswith(app_logger_prefix + "."):
285
+ continue
286
+ logging.getLogger(prefix).setLevel(third_party_level)
287
+
288
+ # Optional console output for interactive runs only.
289
+ if sys.stdout.isatty(): # type: ignore[misc]
290
+ console = logging.StreamHandler()
291
+ console.setLevel(logging.NOTSET)
292
+ console.setFormatter(formatter)
293
+ console.addFilter(
294
+ _ThirdPartySelectorFilter(
295
+ app_logger_prefix=app_logger_prefix,
296
+ spotlight_prefixes=tuple(spotlight),
297
+ )
298
+ )
299
+ logging.root.addHandler(console)
300
+
301
+ return log_file
302
+
303
+
304
+ def parse_since(value: str) -> timedelta:
305
+ """Parse durations like '10m', '2h', '1d', '30s'."""
306
+ raw = value.strip().lower()
307
+ if not raw:
308
+ raise ValueError("Empty duration")
309
+ unit = raw[-1]
310
+ number = raw[:-1]
311
+ if not number.isdigit():
312
+ raise ValueError(f"Invalid duration: {value}")
313
+ n = int(number)
314
+ if unit == "s":
315
+ return timedelta(seconds=n)
316
+ if unit == "m":
317
+ return timedelta(minutes=n)
318
+ if unit == "h":
319
+ return timedelta(hours=n)
320
+ if unit == "d":
321
+ return timedelta(days=n)
322
+ raise ValueError(f"Invalid duration unit: {unit}")
323
+
324
+
325
+ def parse_log_timestamp(line: str) -> datetime | None:
326
+ """Parse the timestamp at the start of a standard log line.
327
+
328
+ Expected: YYYY-MM-DDTHH:MM:SS.mmmZ ...
329
+ """
330
+ token = line.split(" ", 1)[0].strip()
331
+ if not token.endswith("Z"):
332
+ return None
333
+ try:
334
+ # Python doesn't accept 'Z' directly in fromisoformat.
335
+ return datetime.fromisoformat(token.replace("Z", "+00:00"))
336
+ except ValueError:
337
+ return None
338
+
339
+
340
+ def iter_recent_log_lines(log_file: Path, since: timedelta) -> list[str]:
341
+ """Return log lines newer than now-`since`, reading rotated siblings when present."""
342
+ cutoff = _now_utc() - since
343
+
344
+ candidates = sorted(
345
+ [
346
+ p
347
+ for p in log_file.parent.glob(log_file.name + "*")
348
+ if p.is_file() and not p.name.endswith(".gz")
349
+ ],
350
+ key=lambda p: p.stat().st_mtime,
351
+ )
352
+
353
+ lines: list[str] = []
354
+ for path in candidates:
355
+ try:
356
+ with path.open("r", encoding="utf-8", errors="replace") as f:
357
+ for line in f:
358
+ ts = parse_log_timestamp(line)
359
+ if ts is None:
360
+ continue
361
+ if ts >= cutoff:
362
+ lines.append(line)
363
+ except FileNotFoundError:
364
+ continue
365
+
366
+ return lines
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: instruktai-python-logger
3
+ Version: 0.1.0
4
+ Summary: Centralized logging utilities for InstruktAI Python services.
5
+ Requires-Python: >=3.11
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
+
11
+ # `instruktai-python-logger`
12
+
13
+ Centralized logging utilities for InstruktAI Python services.
14
+
15
+ This repo provides a shared, consistent logging contract (env vars + output format + log location) intended to keep logs highly queryable (including by AIs that only read a tail window).
16
+
17
+ Background and rationale live in `docs/design.md`.
18
+ Publishing notes live in `docs/publishing.md`.
19
+
20
+ ## Install (editable, local workspace)
21
+
22
+ From PyPI (recommended):
23
+
24
+ ```bash
25
+ pip install instruktai-python-logger
26
+ ```
27
+
28
+ From GitHub:
29
+
30
+ ```bash
31
+ pip install git+ssh://git@github.com/InstruktAI/python-logger.git
32
+ ```
33
+
34
+ ## API
35
+
36
+ - Python entrypoint: `instrukt_ai_logging.configure_logging(...)`
37
+ - Structured logging helper: `instrukt_ai_logging.log_kv(logger, level, {"msg": "...", ...})`
38
+ - CLI entrypoint: `instrukt-ai-logs` (reads recent log lines)
39
+
40
+ ## Environment variables (contract)
41
+
42
+ Per-app prefix model (example uses `TELECLAUDE_`):
43
+
44
+ - `TELECLAUDE_LOG_LEVEL`
45
+ - `TELECLAUDE_THIRD_PARTY_LOG_LEVEL`
46
+ - `TELECLAUDE_THIRD_PARTY_LOGGERS` (comma-separated logger prefixes, e.g. `httpcore,telegram`)
47
+
48
+ Global:
49
+
50
+ - `INSTRUKT_AI_LOG_ROOT` (optional log root override)
51
+
52
+ ## Log location (contract)
53
+
54
+ Default target:
55
+
56
+ - `/var/log/instrukt-ai/{app}/{app}.log`
57
+
58
+ The installer for each service is expected to create the directory and set write permissions for the daemon user. If the default location is not writable, the implementation will fall back to a user-writable directory and/or require `INSTRUKT_AI_LOG_ROOT`.
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ instrukt_ai_logging/__init__.py
4
+ instrukt_ai_logging/cli.py
5
+ instrukt_ai_logging/logging.py
6
+ instruktai_python_logger.egg-info/PKG-INFO
7
+ instruktai_python_logger.egg-info/SOURCES.txt
8
+ instruktai_python_logger.egg-info/dependency_links.txt
9
+ instruktai_python_logger.egg-info/entry_points.txt
10
+ instruktai_python_logger.egg-info/requires.txt
11
+ instruktai_python_logger.egg-info/top_level.txt
12
+ tests/test_configure_logging.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ instrukt-ai-logs = instrukt_ai_logging.cli:main
@@ -0,0 +1,4 @@
1
+
2
+ [dev]
3
+ pytest>=8
4
+ ruff>=0.8
@@ -0,0 +1,29 @@
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.1.0"
8
+ description = "Centralized logging utilities for InstruktAI 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
+
19
+ [tool.setuptools.packages.find]
20
+ where = ["."]
21
+ include = ["instrukt_ai_logging*"]
22
+
23
+ [tool.pytest.ini_options]
24
+ addopts = "-q"
25
+ testpaths = ["tests"]
26
+
27
+ [tool.ruff]
28
+ line-length = 100
29
+ target-version = "py311"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,125 @@
1
+ import logging
2
+ from pathlib import Path
3
+ from tempfile import TemporaryDirectory
4
+
5
+ import pytest
6
+
7
+ from instrukt_ai_logging import configure_logging, log_kv
8
+
9
+
10
+ @pytest.fixture()
11
+ def isolated_logging():
12
+ previous_handlers = list(logging.root.handlers)
13
+ previous_root_level = logging.root.level
14
+
15
+ try:
16
+ yield
17
+ finally:
18
+ for handler in logging.root.handlers:
19
+ try:
20
+ handler.close()
21
+ except Exception:
22
+ pass
23
+ logging.root.handlers = previous_handlers
24
+ logging.root.setLevel(previous_root_level)
25
+
26
+
27
+ def _read_text(path: Path) -> str:
28
+ return path.read_text(encoding="utf-8", errors="replace")
29
+
30
+
31
+ def test_our_logs_respect_app_level_and_third_party_baseline(isolated_logging, monkeypatch):
32
+ with TemporaryDirectory() as tmp:
33
+ monkeypatch.setenv("INSTRUKT_AI_LOG_ROOT", tmp)
34
+ monkeypatch.setenv("TELECLAUDE_LOG_LEVEL", "DEBUG")
35
+ monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOG_LEVEL", "WARNING")
36
+ monkeypatch.delenv("TELECLAUDE_THIRD_PARTY_LOGGERS", raising=False)
37
+
38
+ log_path = configure_logging(
39
+ env_prefix="TELECLAUDE",
40
+ app_logger_prefix="teleclaude",
41
+ app_name="teleclaude",
42
+ )
43
+
44
+ logging.getLogger("teleclaude.core").debug("hello from ours")
45
+ logging.getLogger("httpcore.http11").info("hello from third-party")
46
+
47
+ content = _read_text(log_path)
48
+ assert "logger=teleclaude.core" in content
49
+ assert 'msg="hello from ours"' in content
50
+ assert "logger=httpcore.http11" not in content
51
+
52
+
53
+ def test_spotlight_allows_selected_third_party_only(isolated_logging, monkeypatch):
54
+ with TemporaryDirectory() as tmp:
55
+ monkeypatch.setenv("INSTRUKT_AI_LOG_ROOT", tmp)
56
+ monkeypatch.setenv("TELECLAUDE_LOG_LEVEL", "INFO")
57
+ monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOG_LEVEL", "INFO")
58
+ monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOGGERS", "httpcore")
59
+
60
+ log_path = configure_logging(
61
+ env_prefix="TELECLAUDE",
62
+ app_logger_prefix="teleclaude",
63
+ app_name="teleclaude",
64
+ )
65
+
66
+ # Ensure records are actually created even though root is WARNING in spotlight mode.
67
+ httpcore_logger = logging.getLogger("httpcore")
68
+ telegram_logger = logging.getLogger("telegram")
69
+ previous_httpcore_level = httpcore_logger.level
70
+ previous_telegram_level = telegram_logger.level
71
+ httpcore_logger.setLevel(logging.INFO)
72
+ telegram_logger.setLevel(logging.INFO)
73
+
74
+ try:
75
+ logging.getLogger("httpcore.http11").info("httpcore info")
76
+ logging.getLogger("telegram.ext.ExtBot").info("telegram info")
77
+ finally:
78
+ httpcore_logger.setLevel(previous_httpcore_level)
79
+ telegram_logger.setLevel(previous_telegram_level)
80
+
81
+ content = _read_text(log_path)
82
+ assert "logger=httpcore.http11" in content
83
+ assert 'msg="httpcore info"' in content
84
+ assert "logger=telegram.ext.ExtBot" not in content
85
+ assert "telegram info" not in content
86
+
87
+
88
+ def test_log_kv_requires_msg_key(isolated_logging, monkeypatch):
89
+ with TemporaryDirectory() as tmp:
90
+ monkeypatch.setenv("INSTRUKT_AI_LOG_ROOT", tmp)
91
+ monkeypatch.setenv("TELECLAUDE_LOG_LEVEL", "INFO")
92
+ monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOG_LEVEL", "WARNING")
93
+
94
+ configure_logging(
95
+ env_prefix="TELECLAUDE",
96
+ app_logger_prefix="teleclaude",
97
+ app_name="teleclaude",
98
+ )
99
+
100
+ with pytest.raises(ValueError):
101
+ log_kv(logging.getLogger("teleclaude.core"), logging.INFO, {"session": "abc"})
102
+
103
+
104
+ def test_log_kv_emits_pairs(isolated_logging, monkeypatch):
105
+ with TemporaryDirectory() as tmp:
106
+ monkeypatch.setenv("INSTRUKT_AI_LOG_ROOT", tmp)
107
+ monkeypatch.setenv("TELECLAUDE_LOG_LEVEL", "INFO")
108
+ monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOG_LEVEL", "WARNING")
109
+
110
+ log_path = configure_logging(
111
+ env_prefix="TELECLAUDE",
112
+ app_logger_prefix="teleclaude",
113
+ app_name="teleclaude",
114
+ )
115
+
116
+ log_kv(
117
+ logging.getLogger("teleclaude.core"),
118
+ logging.INFO,
119
+ {"msg": "hello", "session": "abc123", "n": 1},
120
+ )
121
+
122
+ content = _read_text(log_path)
123
+ assert 'msg="hello"' in content
124
+ assert "session=abc123" in content
125
+ assert "n=1" in content