instruktai-python-logger 0.1.1__tar.gz → 0.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (14) hide show
  1. {instruktai_python_logger-0.1.1 → instruktai_python_logger-0.1.4}/PKG-INFO +6 -5
  2. {instruktai_python_logger-0.1.1 → instruktai_python_logger-0.1.4}/README.md +5 -4
  3. {instruktai_python_logger-0.1.1 → instruktai_python_logger-0.1.4}/instrukt_ai_logging/logging.py +85 -53
  4. {instruktai_python_logger-0.1.1 → instruktai_python_logger-0.1.4}/instruktai_python_logger.egg-info/PKG-INFO +6 -5
  5. {instruktai_python_logger-0.1.1 → instruktai_python_logger-0.1.4}/pyproject.toml +1 -1
  6. {instruktai_python_logger-0.1.1 → instruktai_python_logger-0.1.4}/tests/test_configure_logging.py +5 -15
  7. {instruktai_python_logger-0.1.1 → instruktai_python_logger-0.1.4}/instrukt_ai_logging/__init__.py +0 -0
  8. {instruktai_python_logger-0.1.1 → instruktai_python_logger-0.1.4}/instrukt_ai_logging/cli.py +0 -0
  9. {instruktai_python_logger-0.1.1 → instruktai_python_logger-0.1.4}/instruktai_python_logger.egg-info/SOURCES.txt +0 -0
  10. {instruktai_python_logger-0.1.1 → instruktai_python_logger-0.1.4}/instruktai_python_logger.egg-info/dependency_links.txt +0 -0
  11. {instruktai_python_logger-0.1.1 → instruktai_python_logger-0.1.4}/instruktai_python_logger.egg-info/entry_points.txt +0 -0
  12. {instruktai_python_logger-0.1.1 → instruktai_python_logger-0.1.4}/instruktai_python_logger.egg-info/requires.txt +0 -0
  13. {instruktai_python_logger-0.1.1 → instruktai_python_logger-0.1.4}/instruktai_python_logger.egg-info/top_level.txt +0 -0
  14. {instruktai_python_logger-0.1.1 → instruktai_python_logger-0.1.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: instruktai-python-logger
3
- Version: 0.1.1
3
+ Version: 0.1.4
4
4
  Summary: Centralized logging utilities for InstruktAI Python services.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -40,11 +40,12 @@ pip install git+ssh://git@github.com/InstruktAI/python-logger.git
40
40
  Example:
41
41
 
42
42
  ```py
43
- from instrukt_ai_logging import configure_logging, get_logger
43
+ import logging
44
+ from instrukt_ai_logging import configure_logging
44
45
 
45
- configure_logging(name="teleclaude", app_logger_prefix="teleclaude")
46
- logger = get_logger("teleclaude.core")
47
- logger.info("job_started", job_id="abc123")
46
+ configure_logging("teleclaude")
47
+ logger = logging.getLogger("teleclaude.core")
48
+ logger.info("job_started", job_id="abc123", user_id=123)
48
49
  ```
49
50
 
50
51
  ## Environment variables (contract)
@@ -30,11 +30,12 @@ pip install git+ssh://git@github.com/InstruktAI/python-logger.git
30
30
  Example:
31
31
 
32
32
  ```py
33
- from instrukt_ai_logging import configure_logging, get_logger
33
+ import logging
34
+ from instrukt_ai_logging import configure_logging
34
35
 
35
- configure_logging(name="teleclaude", app_logger_prefix="teleclaude")
36
- logger = get_logger("teleclaude.core")
37
- logger.info("job_started", job_id="abc123")
36
+ configure_logging("teleclaude")
37
+ logger = logging.getLogger("teleclaude.core")
38
+ logger.info("job_started", job_id="abc123", user_id=123)
38
39
  ```
39
40
 
40
41
  ## Environment variables (contract)
@@ -4,7 +4,6 @@ import logging
4
4
  import os
5
5
  import re
6
6
  import sys
7
- from collections.abc import Mapping
8
7
  from dataclasses import dataclass
9
8
  from datetime import datetime, timedelta, timezone
10
9
  from logging.handlers import WatchedFileHandler
@@ -36,6 +35,18 @@ def _normalize_app_name(name: str) -> str:
36
35
  return raw
37
36
 
38
37
 
38
+ def _normalize_logger_prefix(name: str) -> str:
39
+ # teleclaude, crypto_ai, etc. (match Python package style)
40
+ raw = name.strip().lower()
41
+ if not raw:
42
+ raise ValueError("name must be non-empty")
43
+ raw = re.sub(r"[^a-z0-9]+", "_", raw)
44
+ raw = re.sub(r"_+", "_", raw).strip("_")
45
+ if not raw:
46
+ raise ValueError("name did not produce a valid logger prefix")
47
+ return raw
48
+
49
+
39
50
  def _level_name_to_int(level_name: str, default: int) -> int:
40
51
  name = level_name.strip().upper()
41
52
  if not name:
@@ -167,60 +178,79 @@ class LogfmtFormatter(UtcMillisFormatter):
167
178
  return " ".join(parts)
168
179
 
169
180
 
170
- def _log_kv(logger: logging.Logger, level: int, msg: str, kv: Mapping[str, Any]) -> None:
171
- """Internal helper: attach `kv` pairs without forcing callers to use `extra`."""
172
- safe: dict[str, object] = {}
173
- for key, value in kv.items():
174
- if not isinstance(key, str):
175
- continue
176
- if key == "msg":
177
- continue
178
- if not _SAFE_KEY.fullmatch(key):
179
- continue
180
- safe[key] = value
181
-
182
- logger.log(level, msg, extra={"kv": safe})
183
-
184
-
185
- class KVLogger:
186
- """Ergonomic key/value logger.
181
+ class InstruktLogger(logging.Logger):
182
+ """Logger that accepts arbitrary `**kv` fields.
187
183
 
188
- Callers use `KVLogger` like a normal logger:
189
- logger.info("event_name", job_id=..., user_id=...)
184
+ This lets callers use normal logger methods with named key/value pairs:
185
+ logging.getLogger("...").info("event_name", job_id=..., user_id=...)
190
186
 
191
- This keeps call sites human-friendly while still producing strict logfmt pairs.
187
+ All `**kv` values are serialized to text by the formatter.
192
188
  """
193
189
 
194
- def __init__(self, logger: logging.Logger) -> None:
195
- self._logger = logger
190
+ def _log_with_kv(self, level: int, msg: object, args: tuple[object, ...], **kwargs: Any) -> None:
191
+ exc_info = kwargs.pop("exc_info", None)
192
+ stack_info = kwargs.pop("stack_info", False)
193
+ stacklevel = kwargs.pop("stacklevel", 1)
194
+ extra = kwargs.pop("extra", None)
195
+
196
+ # Remaining kwargs are treated as `kv`.
197
+ kv: dict[str, object] = {k: v for k, v in kwargs.items() if _SAFE_KEY.fullmatch(k)}
198
+
199
+ merged_extra: dict[str, object] = {}
200
+ if isinstance(extra, dict):
201
+ merged_extra.update(extra)
202
+
203
+ existing_kv = merged_extra.get("kv")
204
+ if isinstance(existing_kv, dict):
205
+ merged_extra["kv"] = {**existing_kv, **kv}
206
+ else:
207
+ merged_extra["kv"] = kv
208
+
209
+ super()._log(
210
+ level,
211
+ msg,
212
+ args,
213
+ exc_info=exc_info,
214
+ extra=merged_extra,
215
+ stack_info=stack_info,
216
+ stacklevel=stacklevel,
217
+ )
196
218
 
197
- @property
198
- def name(self) -> str:
199
- return self._logger.name
219
+ def debug(self, msg: object, *args: object, **kwargs: Any) -> None: # type: ignore[override]
220
+ if self.isEnabledFor(logging.DEBUG):
221
+ self._log_with_kv(logging.DEBUG, msg, args, **kwargs)
200
222
 
201
- def debug(self, msg: str, **kv: Any) -> None:
202
- _log_kv(self._logger, logging.DEBUG, msg, kv)
223
+ def info(self, msg: object, *args: object, **kwargs: Any) -> None: # type: ignore[override]
224
+ if self.isEnabledFor(logging.INFO):
225
+ self._log_with_kv(logging.INFO, msg, args, **kwargs)
203
226
 
204
- def info(self, msg: str, **kv: Any) -> None:
205
- _log_kv(self._logger, logging.INFO, msg, kv)
227
+ def warning(self, msg: object, *args: object, **kwargs: Any) -> None: # type: ignore[override]
228
+ if self.isEnabledFor(logging.WARNING):
229
+ self._log_with_kv(logging.WARNING, msg, args, **kwargs)
206
230
 
207
- def warning(self, msg: str, **kv: Any) -> None:
208
- _log_kv(self._logger, logging.WARNING, msg, kv)
231
+ def error(self, msg: object, *args: object, **kwargs: Any) -> None: # type: ignore[override]
232
+ if self.isEnabledFor(logging.ERROR):
233
+ self._log_with_kv(logging.ERROR, msg, args, **kwargs)
209
234
 
210
- def error(self, msg: str, **kv: Any) -> None:
211
- _log_kv(self._logger, logging.ERROR, msg, kv)
235
+ def critical(self, msg: object, *args: object, **kwargs: Any) -> None: # type: ignore[override]
236
+ if self.isEnabledFor(logging.CRITICAL):
237
+ self._log_with_kv(logging.CRITICAL, msg, args, **kwargs)
212
238
 
213
- def exception(self, msg: str, **kv: Any) -> None:
214
- # Match logging.exception behavior (includes exc_info=True).
215
- self._logger.exception(msg, extra={"kv": {k: v for k, v in kv.items() if _SAFE_KEY.fullmatch(k)}})
239
+ def exception(self, msg: object, *args: object, **kwargs: Any) -> None: # type: ignore[override]
240
+ kwargs.setdefault("exc_info", True)
241
+ if self.isEnabledFor(logging.ERROR):
242
+ self._log_with_kv(logging.ERROR, msg, args, **kwargs)
216
243
 
217
- def log(self, level: int, msg: str, **kv: Any) -> None:
218
- _log_kv(self._logger, level, msg, kv)
244
+ def log(self, level: int, msg: object, *args: object, **kwargs: Any) -> None: # type: ignore[override]
245
+ if not isinstance(level, int):
246
+ raise TypeError("level must be an int")
247
+ if self.isEnabledFor(level):
248
+ self._log_with_kv(level, msg, args, **kwargs)
219
249
 
220
250
 
221
- def get_logger(name: str) -> KVLogger:
222
- """Return a `KVLogger` wrapper for the named logger."""
223
- return KVLogger(logging.getLogger(name))
251
+ def get_logger(name: str) -> logging.Logger:
252
+ """Compatibility helper (returns a normal logger, but with `**kv` support after configuration)."""
253
+ return logging.getLogger(name)
224
254
 
225
255
 
226
256
  class _ThirdPartySelectorFilter(logging.Filter):
@@ -285,9 +315,9 @@ def _ensure_log_dir(log_dir: Path) -> None:
285
315
 
286
316
 
287
317
  def configure_logging(
318
+ name: str,
288
319
  *,
289
- app_logger_prefix: str,
290
- name: str | None = None,
320
+ app_logger_prefix: str | None = None,
291
321
  env_prefix: str | None = None,
292
322
  app_name: str | None = None,
293
323
  log_filename: str | None = None,
@@ -297,14 +327,10 @@ def configure_logging(
297
327
 
298
328
  Returns the resolved log file path in use.
299
329
  """
300
- if name is not None:
301
- if env_prefix is not None or app_name is not None:
302
- raise ValueError("Pass either name= OR env_prefix/app_name (not both)")
303
- env_prefix = _normalize_env_prefix(name)
304
- app_name = _normalize_app_name(name)
305
-
306
- if env_prefix is None or app_name is None:
307
- raise ValueError("Missing required config: name= OR both env_prefix= and app_name=")
330
+ env_prefix = env_prefix or _normalize_env_prefix(name)
331
+ app_logger_prefix = app_logger_prefix or _normalize_logger_prefix(name)
332
+ app_name = app_name or app_logger_prefix
333
+ log_filename = log_filename or f"{app_logger_prefix}.log"
308
334
 
309
335
  contract = LoggingContract(
310
336
  env_prefix=env_prefix,
@@ -312,6 +338,12 @@ def configure_logging(
312
338
  app_name=app_name,
313
339
  )
314
340
 
341
+ # Ensure all loggers accept arbitrary `**kv` (no wrapper at call sites).
342
+ logging.setLoggerClass(InstruktLogger)
343
+ for obj in logging.root.manager.loggerDict.values():
344
+ if isinstance(obj, logging.Logger) and not isinstance(obj, InstruktLogger):
345
+ obj.__class__ = InstruktLogger
346
+
315
347
  our_level_name = (os.getenv(contract.env_log_level) or "INFO").upper()
316
348
  third_party_level_name = (os.getenv(contract.env_third_party_level) or "WARNING").upper()
317
349
  spotlight = _parse_csv(os.getenv(contract.env_third_party_loggers, ""))
@@ -329,7 +361,7 @@ def configure_logging(
329
361
  log_dir = _fallback_log_root(app_name)
330
362
  _ensure_log_dir(log_dir)
331
363
 
332
- log_file = log_dir / (log_filename or f"{app_name}.log")
364
+ log_file = log_dir / log_filename
333
365
 
334
366
  formatter = LogfmtFormatter(max_message_chars=max_message_chars)
335
367
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: instruktai-python-logger
3
- Version: 0.1.1
3
+ Version: 0.1.4
4
4
  Summary: Centralized logging utilities for InstruktAI Python services.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -40,11 +40,12 @@ pip install git+ssh://git@github.com/InstruktAI/python-logger.git
40
40
  Example:
41
41
 
42
42
  ```py
43
- from instrukt_ai_logging import configure_logging, get_logger
43
+ import logging
44
+ from instrukt_ai_logging import configure_logging
44
45
 
45
- configure_logging(name="teleclaude", app_logger_prefix="teleclaude")
46
- logger = get_logger("teleclaude.core")
47
- logger.info("job_started", job_id="abc123")
46
+ configure_logging("teleclaude")
47
+ logger = logging.getLogger("teleclaude.core")
48
+ logger.info("job_started", job_id="abc123", user_id=123)
48
49
  ```
49
50
 
50
51
  ## Environment variables (contract)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "instruktai-python-logger"
7
- version = "0.1.1"
7
+ version = "0.1.4"
8
8
  description = "Centralized logging utilities for InstruktAI Python services."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -4,7 +4,7 @@ from tempfile import TemporaryDirectory
4
4
 
5
5
  import pytest
6
6
 
7
- from instrukt_ai_logging import configure_logging, get_logger
7
+ from instrukt_ai_logging import configure_logging
8
8
 
9
9
 
10
10
  @pytest.fixture()
@@ -35,10 +35,7 @@ def test_our_logs_respect_app_level_and_third_party_baseline(isolated_logging, m
35
35
  monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOG_LEVEL", "WARNING")
36
36
  monkeypatch.delenv("TELECLAUDE_THIRD_PARTY_LOGGERS", raising=False)
37
37
 
38
- log_path = configure_logging(
39
- app_logger_prefix="teleclaude",
40
- name="teleclaude",
41
- )
38
+ log_path = configure_logging("teleclaude")
42
39
 
43
40
  logging.getLogger("teleclaude.core").debug("hello from ours")
44
41
  logging.getLogger("httpcore.http11").info("hello from third-party")
@@ -56,10 +53,7 @@ def test_spotlight_allows_selected_third_party_only(isolated_logging, monkeypatc
56
53
  monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOG_LEVEL", "INFO")
57
54
  monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOGGERS", "httpcore")
58
55
 
59
- log_path = configure_logging(
60
- app_logger_prefix="teleclaude",
61
- name="teleclaude",
62
- )
56
+ log_path = configure_logging("teleclaude")
63
57
 
64
58
  # Ensure records are actually created even though root is WARNING in spotlight mode.
65
59
  httpcore_logger = logging.getLogger("httpcore")
@@ -89,13 +83,9 @@ def test_named_kv_logger_emits_pairs(isolated_logging, monkeypatch):
89
83
  monkeypatch.setenv("TELECLAUDE_LOG_LEVEL", "INFO")
90
84
  monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOG_LEVEL", "WARNING")
91
85
 
92
- log_path = configure_logging(
93
- app_logger_prefix="teleclaude",
94
- name="teleclaude",
95
- )
86
+ log_path = configure_logging("teleclaude")
96
87
 
97
- logger = get_logger("teleclaude.core")
98
- logger.info("hello", session="abc123", n=1)
88
+ logging.getLogger("teleclaude.core").info("hello", session="abc123", n=1)
99
89
 
100
90
  content = _read_text(log_path)
101
91
  assert 'msg="hello"' in content