instruktai-python-logger 0.1.0.post1__tar.gz → 0.1.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.
Files changed (14) hide show
  1. {instruktai_python_logger-0.1.0.post1 → instruktai_python_logger-0.1.1}/PKG-INFO +5 -6
  2. {instruktai_python_logger-0.1.0.post1 → instruktai_python_logger-0.1.1}/README.md +4 -5
  3. {instruktai_python_logger-0.1.0.post1 → instruktai_python_logger-0.1.1}/instrukt_ai_logging/logging.py +55 -84
  4. {instruktai_python_logger-0.1.0.post1 → instruktai_python_logger-0.1.1}/instruktai_python_logger.egg-info/PKG-INFO +5 -6
  5. {instruktai_python_logger-0.1.0.post1 → instruktai_python_logger-0.1.1}/pyproject.toml +1 -1
  6. {instruktai_python_logger-0.1.0.post1 → instruktai_python_logger-0.1.1}/tests/test_configure_logging.py +15 -5
  7. {instruktai_python_logger-0.1.0.post1 → instruktai_python_logger-0.1.1}/instrukt_ai_logging/__init__.py +0 -0
  8. {instruktai_python_logger-0.1.0.post1 → instruktai_python_logger-0.1.1}/instrukt_ai_logging/cli.py +0 -0
  9. {instruktai_python_logger-0.1.0.post1 → instruktai_python_logger-0.1.1}/instruktai_python_logger.egg-info/SOURCES.txt +0 -0
  10. {instruktai_python_logger-0.1.0.post1 → instruktai_python_logger-0.1.1}/instruktai_python_logger.egg-info/dependency_links.txt +0 -0
  11. {instruktai_python_logger-0.1.0.post1 → instruktai_python_logger-0.1.1}/instruktai_python_logger.egg-info/entry_points.txt +0 -0
  12. {instruktai_python_logger-0.1.0.post1 → instruktai_python_logger-0.1.1}/instruktai_python_logger.egg-info/requires.txt +0 -0
  13. {instruktai_python_logger-0.1.0.post1 → instruktai_python_logger-0.1.1}/instruktai_python_logger.egg-info/top_level.txt +0 -0
  14. {instruktai_python_logger-0.1.0.post1 → instruktai_python_logger-0.1.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: instruktai-python-logger
3
- Version: 0.1.0.post1
3
+ Version: 0.1.1
4
4
  Summary: Centralized logging utilities for InstruktAI Python services.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -40,12 +40,11 @@ pip install git+ssh://git@github.com/InstruktAI/python-logger.git
40
40
  Example:
41
41
 
42
42
  ```py
43
- import logging
44
- from instrukt_ai_logging import configure_logging
43
+ from instrukt_ai_logging import configure_logging, get_logger
45
44
 
46
- configure_logging("teleclaude")
47
- logger = logging.getLogger("teleclaude.core")
48
- logger.info("job_started", job_id="abc123", user_id=123)
45
+ configure_logging(name="teleclaude", app_logger_prefix="teleclaude")
46
+ logger = get_logger("teleclaude.core")
47
+ logger.info("job_started", job_id="abc123")
49
48
  ```
50
49
 
51
50
  ## Environment variables (contract)
@@ -30,12 +30,11 @@ pip install git+ssh://git@github.com/InstruktAI/python-logger.git
30
30
  Example:
31
31
 
32
32
  ```py
33
- import logging
34
- from instrukt_ai_logging import configure_logging
33
+ from instrukt_ai_logging import configure_logging, get_logger
35
34
 
36
- configure_logging("teleclaude")
37
- logger = logging.getLogger("teleclaude.core")
38
- logger.info("job_started", job_id="abc123", user_id=123)
35
+ configure_logging(name="teleclaude", app_logger_prefix="teleclaude")
36
+ logger = get_logger("teleclaude.core")
37
+ logger.info("job_started", job_id="abc123")
39
38
  ```
40
39
 
41
40
  ## Environment variables (contract)
@@ -4,6 +4,7 @@ import logging
4
4
  import os
5
5
  import re
6
6
  import sys
7
+ from collections.abc import Mapping
7
8
  from dataclasses import dataclass
8
9
  from datetime import datetime, timedelta, timezone
9
10
  from logging.handlers import WatchedFileHandler
@@ -35,18 +36,6 @@ def _normalize_app_name(name: str) -> str:
35
36
  return raw
36
37
 
37
38
 
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
-
50
39
  def _level_name_to_int(level_name: str, default: int) -> int:
51
40
  name = level_name.strip().upper()
52
41
  if not name:
@@ -178,79 +167,60 @@ class LogfmtFormatter(UtcMillisFormatter):
178
167
  return " ".join(parts)
179
168
 
180
169
 
181
- class InstruktLogger(logging.Logger):
182
- """Logger that accepts arbitrary `**kv` fields.
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.
183
187
 
184
- This lets callers use normal logger methods with named key/value pairs:
185
- logging.getLogger("...").info("event_name", job_id=..., user_id=...)
188
+ Callers use `KVLogger` like a normal logger:
189
+ logger.info("event_name", job_id=..., user_id=...)
186
190
 
187
- All `**kv` values are serialized to text by the formatter.
191
+ This keeps call sites human-friendly while still producing strict logfmt pairs.
188
192
  """
189
193
 
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
- )
194
+ def __init__(self, logger: logging.Logger) -> None:
195
+ self._logger = logger
218
196
 
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)
197
+ @property
198
+ def name(self) -> str:
199
+ return self._logger.name
222
200
 
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)
201
+ def debug(self, msg: str, **kv: Any) -> None:
202
+ _log_kv(self._logger, logging.DEBUG, msg, kv)
226
203
 
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)
204
+ def info(self, msg: str, **kv: Any) -> None:
205
+ _log_kv(self._logger, logging.INFO, msg, kv)
230
206
 
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)
207
+ def warning(self, msg: str, **kv: Any) -> None:
208
+ _log_kv(self._logger, logging.WARNING, msg, kv)
234
209
 
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)
210
+ def error(self, msg: str, **kv: Any) -> None:
211
+ _log_kv(self._logger, logging.ERROR, msg, kv)
238
212
 
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)
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)}})
243
216
 
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)
217
+ def log(self, level: int, msg: str, **kv: Any) -> None:
218
+ _log_kv(self._logger, level, msg, kv)
249
219
 
250
220
 
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)
221
+ def get_logger(name: str) -> KVLogger:
222
+ """Return a `KVLogger` wrapper for the named logger."""
223
+ return KVLogger(logging.getLogger(name))
254
224
 
255
225
 
256
226
  class _ThirdPartySelectorFilter(logging.Filter):
@@ -315,8 +285,11 @@ def _ensure_log_dir(log_dir: Path) -> None:
315
285
 
316
286
 
317
287
  def configure_logging(
318
- name: str,
319
288
  *,
289
+ app_logger_prefix: str,
290
+ name: str | None = None,
291
+ env_prefix: str | None = None,
292
+ app_name: str | None = None,
320
293
  log_filename: str | None = None,
321
294
  max_message_chars: int = 4000,
322
295
  ) -> Path:
@@ -324,10 +297,14 @@ def configure_logging(
324
297
 
325
298
  Returns the resolved log file path in use.
326
299
  """
327
- env_prefix = _normalize_env_prefix(name)
328
- app_logger_prefix = _normalize_logger_prefix(name)
329
- app_name = app_logger_prefix
330
- log_filename = log_filename or f"{app_logger_prefix}.log"
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=")
331
308
 
332
309
  contract = LoggingContract(
333
310
  env_prefix=env_prefix,
@@ -335,12 +312,6 @@ def configure_logging(
335
312
  app_name=app_name,
336
313
  )
337
314
 
338
- # Ensure all loggers accept arbitrary `**kv` (no wrapper at call sites).
339
- logging.setLoggerClass(InstruktLogger)
340
- for obj in logging.root.manager.loggerDict.values():
341
- if isinstance(obj, logging.Logger) and not isinstance(obj, InstruktLogger):
342
- obj.__class__ = InstruktLogger
343
-
344
315
  our_level_name = (os.getenv(contract.env_log_level) or "INFO").upper()
345
316
  third_party_level_name = (os.getenv(contract.env_third_party_level) or "WARNING").upper()
346
317
  spotlight = _parse_csv(os.getenv(contract.env_third_party_loggers, ""))
@@ -358,7 +329,7 @@ def configure_logging(
358
329
  log_dir = _fallback_log_root(app_name)
359
330
  _ensure_log_dir(log_dir)
360
331
 
361
- log_file = log_dir / log_filename
332
+ log_file = log_dir / (log_filename or f"{app_name}.log")
362
333
 
363
334
  formatter = LogfmtFormatter(max_message_chars=max_message_chars)
364
335
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: instruktai-python-logger
3
- Version: 0.1.0.post1
3
+ Version: 0.1.1
4
4
  Summary: Centralized logging utilities for InstruktAI Python services.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -40,12 +40,11 @@ pip install git+ssh://git@github.com/InstruktAI/python-logger.git
40
40
  Example:
41
41
 
42
42
  ```py
43
- import logging
44
- from instrukt_ai_logging import configure_logging
43
+ from instrukt_ai_logging import configure_logging, get_logger
45
44
 
46
- configure_logging("teleclaude")
47
- logger = logging.getLogger("teleclaude.core")
48
- logger.info("job_started", job_id="abc123", user_id=123)
45
+ configure_logging(name="teleclaude", app_logger_prefix="teleclaude")
46
+ logger = get_logger("teleclaude.core")
47
+ logger.info("job_started", job_id="abc123")
49
48
  ```
50
49
 
51
50
  ## 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.0.post1"
7
+ version = "0.1.1"
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
7
+ from instrukt_ai_logging import configure_logging, get_logger
8
8
 
9
9
 
10
10
  @pytest.fixture()
@@ -35,7 +35,10 @@ 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("teleclaude")
38
+ log_path = configure_logging(
39
+ app_logger_prefix="teleclaude",
40
+ name="teleclaude",
41
+ )
39
42
 
40
43
  logging.getLogger("teleclaude.core").debug("hello from ours")
41
44
  logging.getLogger("httpcore.http11").info("hello from third-party")
@@ -53,7 +56,10 @@ def test_spotlight_allows_selected_third_party_only(isolated_logging, monkeypatc
53
56
  monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOG_LEVEL", "INFO")
54
57
  monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOGGERS", "httpcore")
55
58
 
56
- log_path = configure_logging("teleclaude")
59
+ log_path = configure_logging(
60
+ app_logger_prefix="teleclaude",
61
+ name="teleclaude",
62
+ )
57
63
 
58
64
  # Ensure records are actually created even though root is WARNING in spotlight mode.
59
65
  httpcore_logger = logging.getLogger("httpcore")
@@ -83,9 +89,13 @@ def test_named_kv_logger_emits_pairs(isolated_logging, monkeypatch):
83
89
  monkeypatch.setenv("TELECLAUDE_LOG_LEVEL", "INFO")
84
90
  monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOG_LEVEL", "WARNING")
85
91
 
86
- log_path = configure_logging("teleclaude")
92
+ log_path = configure_logging(
93
+ app_logger_prefix="teleclaude",
94
+ name="teleclaude",
95
+ )
87
96
 
88
- logging.getLogger("teleclaude.core").info("hello", session="abc123", n=1)
97
+ logger = get_logger("teleclaude.core")
98
+ logger.info("hello", session="abc123", n=1)
89
99
 
90
100
  content = _read_text(log_path)
91
101
  assert 'msg="hello"' in content