utilityhub-logging 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,82 @@
1
+ Metadata-Version: 2.3
2
+ Name: utilityhub-logging
3
+ Version: 0.1.0
4
+ Summary: Project-agnostic logging orchestration utilities built on Python's stdlib logging package
5
+ Author: Rajesh Das
6
+ Author-email: Rajesh Das <rajesh@hyperoot.dev>
7
+ Requires-Python: >=3.12
8
+ Description-Content-Type: text/markdown
9
+
10
+ # utilityhub-logging
11
+
12
+ `utilityhub-logging` is a small, project-agnostic package that extends Python's
13
+ standard library `logging` module with deterministic setup and cleanup helpers.
14
+
15
+ It focuses on the repetitive parts teams tend to rebuild:
16
+
17
+ - resolving a safe logs directory
18
+ - configuring one log file per application run
19
+ - opening a scoped log for an operation
20
+ - switching between plain text and JSON output
21
+ - attaching async-safe contextual metadata
22
+ - preventing duplicate handlers and cleaning them up reliably
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ uv add utilityhub-logging
28
+ ```
29
+
30
+ ## Quick Start
31
+
32
+ ```python
33
+ import logging
34
+
35
+ from utilityhub_logging import (
36
+ LogFormat,
37
+ begin_scope_logging,
38
+ bind_context,
39
+ cleanup_logging,
40
+ configure_app_logging,
41
+ )
42
+
43
+ log_file = configure_app_logging(
44
+ app_name="demo-app",
45
+ level="INFO",
46
+ console=True,
47
+ log_format=LogFormat.PLAIN,
48
+ )
49
+
50
+ logger = logging.getLogger("demo")
51
+ with bind_context(environment="dev", subsystem="worker"):
52
+ logger.info("Application started")
53
+
54
+ scope_logger, scope_file = begin_scope_logging(
55
+ app_name="demo-app",
56
+ scope_type="job",
57
+ scope_id="job-42",
58
+ log_format=LogFormat.JSON,
59
+ )
60
+ scope_logger.info("Running scoped operation")
61
+
62
+ cleanup_logging()
63
+ ```
64
+
65
+ ## Public API
66
+
67
+ - `resolve_logs_path(...)`
68
+ - `configure_app_logging(...)`
69
+ - `begin_scope_logging(...)`
70
+ - `end_scope_logging(...)`
71
+ - `bind_context(...)`
72
+ - `cleanup_logging(...)`
73
+
74
+ ## Defaults
75
+
76
+ - UTF-8 file output
77
+ - timestamped log files in UTC
78
+ - platform-aware default log directories
79
+ - `~/.local/state/<app>/logs` on Linux and similar Unix systems
80
+ - `~/Library/Logs/<app>` on macOS
81
+ - `%LOCALAPPDATA%\<app>\Logs` on Windows
82
+ - no current-working-directory logging unless explicitly requested
@@ -0,0 +1,73 @@
1
+ # utilityhub-logging
2
+
3
+ `utilityhub-logging` is a small, project-agnostic package that extends Python's
4
+ standard library `logging` module with deterministic setup and cleanup helpers.
5
+
6
+ It focuses on the repetitive parts teams tend to rebuild:
7
+
8
+ - resolving a safe logs directory
9
+ - configuring one log file per application run
10
+ - opening a scoped log for an operation
11
+ - switching between plain text and JSON output
12
+ - attaching async-safe contextual metadata
13
+ - preventing duplicate handlers and cleaning them up reliably
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ uv add utilityhub-logging
19
+ ```
20
+
21
+ ## Quick Start
22
+
23
+ ```python
24
+ import logging
25
+
26
+ from utilityhub_logging import (
27
+ LogFormat,
28
+ begin_scope_logging,
29
+ bind_context,
30
+ cleanup_logging,
31
+ configure_app_logging,
32
+ )
33
+
34
+ log_file = configure_app_logging(
35
+ app_name="demo-app",
36
+ level="INFO",
37
+ console=True,
38
+ log_format=LogFormat.PLAIN,
39
+ )
40
+
41
+ logger = logging.getLogger("demo")
42
+ with bind_context(environment="dev", subsystem="worker"):
43
+ logger.info("Application started")
44
+
45
+ scope_logger, scope_file = begin_scope_logging(
46
+ app_name="demo-app",
47
+ scope_type="job",
48
+ scope_id="job-42",
49
+ log_format=LogFormat.JSON,
50
+ )
51
+ scope_logger.info("Running scoped operation")
52
+
53
+ cleanup_logging()
54
+ ```
55
+
56
+ ## Public API
57
+
58
+ - `resolve_logs_path(...)`
59
+ - `configure_app_logging(...)`
60
+ - `begin_scope_logging(...)`
61
+ - `end_scope_logging(...)`
62
+ - `bind_context(...)`
63
+ - `cleanup_logging(...)`
64
+
65
+ ## Defaults
66
+
67
+ - UTF-8 file output
68
+ - timestamped log files in UTC
69
+ - platform-aware default log directories
70
+ - `~/.local/state/<app>/logs` on Linux and similar Unix systems
71
+ - `~/Library/Logs/<app>` on macOS
72
+ - `%LOCALAPPDATA%\<app>\Logs` on Windows
73
+ - no current-working-directory logging unless explicitly requested
@@ -0,0 +1,15 @@
1
+ [project]
2
+ name = "utilityhub-logging"
3
+ version = "0.1.0"
4
+ description = "Project-agnostic logging orchestration utilities built on Python's stdlib logging package"
5
+ readme = "README.md"
6
+ authors = [{ name = "Rajesh Das", email = "rajesh@hyperoot.dev" }]
7
+ requires-python = ">=3.12"
8
+ dependencies = []
9
+
10
+ [project.scripts]
11
+ utilityhub_logging = "utilityhub_logging:main"
12
+
13
+ [build-system]
14
+ requires = ["uv_build>=0.9.18,<0.10.0"]
15
+ build-backend = "uv_build"
@@ -0,0 +1,26 @@
1
+ """Composable logging utilities built on Python's standard library logging package."""
2
+
3
+ from utilityhub_logging.cleanup import cleanup_logging
4
+ from utilityhub_logging.context import bind_context
5
+ from utilityhub_logging.formatters import JsonFormatter, PlainTextFormatter
6
+ from utilityhub_logging.paths import resolve_logs_path
7
+ from utilityhub_logging.scopes import begin_scope_logging, end_scope_logging
8
+ from utilityhub_logging.setup import configure_app_logging
9
+ from utilityhub_logging.types import LogFormat, LogPathConvention
10
+
11
+ __all__: list[str] = [
12
+ "JsonFormatter",
13
+ "LogFormat",
14
+ "LogPathConvention",
15
+ "PlainTextFormatter",
16
+ "begin_scope_logging",
17
+ "bind_context",
18
+ "cleanup_logging",
19
+ "configure_app_logging",
20
+ "end_scope_logging",
21
+ "resolve_logs_path",
22
+ ]
23
+
24
+
25
+ def main() -> None:
26
+ print("utilityhub-logging: import and configure via `configure_app_logging()`")
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ from utilityhub_logging.context import clear_context
7
+ from utilityhub_logging.types import ManagedHandlerKind
8
+
9
+ _MANAGED_HANDLER_FLAG = "_utilityhub_managed"
10
+ _MANAGED_HANDLER_KIND = "_utilityhub_kind"
11
+ _MANAGED_HANDLER_PATH = "_utilityhub_log_path"
12
+
13
+
14
+ def mark_handler(
15
+ handler: logging.Handler,
16
+ *,
17
+ kind: ManagedHandlerKind,
18
+ file_path: Path | None = None,
19
+ ) -> logging.Handler:
20
+ setattr(handler, _MANAGED_HANDLER_FLAG, True)
21
+ setattr(handler, _MANAGED_HANDLER_KIND, kind.value)
22
+ setattr(handler, _MANAGED_HANDLER_PATH, file_path)
23
+ return handler
24
+
25
+
26
+ def is_managed_handler(handler: logging.Handler, *, kind: ManagedHandlerKind | None = None) -> bool:
27
+ if not getattr(handler, _MANAGED_HANDLER_FLAG, False):
28
+ return False
29
+ if kind is None:
30
+ return True
31
+ return getattr(handler, _MANAGED_HANDLER_KIND, None) == kind.value
32
+
33
+
34
+ def cleanup_logging(
35
+ logger: logging.Logger | None = None,
36
+ *,
37
+ kind: ManagedHandlerKind | None = None,
38
+ close_all_loggers: bool = False,
39
+ ) -> None:
40
+ if close_all_loggers:
41
+ logger_dict = logging.root.manager.loggerDict
42
+ for candidate in [logging.getLogger()] + [
43
+ value for value in logger_dict.values() if isinstance(value, logging.Logger)
44
+ ]:
45
+ cleanup_logging(candidate, kind=kind)
46
+ clear_context()
47
+ return
48
+
49
+ target = logging.getLogger() if logger is None else logger
50
+ handlers = list(target.handlers)
51
+ for handler in handlers:
52
+ if not is_managed_handler(handler, kind=kind):
53
+ continue
54
+ target.removeHandler(handler)
55
+ try:
56
+ handler.flush()
57
+ finally:
58
+ handler.close()
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ import contextvars
4
+ import logging
5
+ from collections.abc import Mapping
6
+ from contextlib import AbstractContextManager
7
+ from contextvars import Token
8
+ from typing import Any
9
+
10
+ _LOG_CONTEXT: contextvars.ContextVar[dict[str, Any] | None] = contextvars.ContextVar(
11
+ "utilityhub_logging_context",
12
+ default=None,
13
+ )
14
+
15
+ _RESERVED_LOG_KEYS = {
16
+ "args",
17
+ "asctime",
18
+ "created",
19
+ "exc_info",
20
+ "exc_text",
21
+ "filename",
22
+ "funcName",
23
+ "levelname",
24
+ "levelno",
25
+ "lineno",
26
+ "message",
27
+ "module",
28
+ "msecs",
29
+ "msg",
30
+ "name",
31
+ "pathname",
32
+ "process",
33
+ "processName",
34
+ "relativeCreated",
35
+ "stack_info",
36
+ "thread",
37
+ "threadName",
38
+ }
39
+
40
+
41
+ def get_context() -> dict[str, Any]:
42
+ return dict(_LOG_CONTEXT.get() or {})
43
+
44
+
45
+ class BoundContext(AbstractContextManager["BoundContext"]):
46
+ def __init__(self, token: Token[dict[str, Any] | None]) -> None:
47
+ self._token = token
48
+ self._closed = False
49
+
50
+ def __enter__(self) -> BoundContext:
51
+ return self
52
+
53
+ def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
54
+ self.close()
55
+
56
+ def close(self) -> None:
57
+ if not self._closed:
58
+ _LOG_CONTEXT.reset(self._token)
59
+ self._closed = True
60
+
61
+
62
+ def bind_context(**context: Any) -> BoundContext:
63
+ current = dict(_LOG_CONTEXT.get() or {})
64
+ current.update({key: value for key, value in context.items() if value is not None})
65
+ return BoundContext(_LOG_CONTEXT.set(current))
66
+
67
+
68
+ def clear_context() -> None:
69
+ _LOG_CONTEXT.set({})
70
+
71
+
72
+ class ContextFilter(logging.Filter):
73
+ def filter(self, record: logging.LogRecord) -> bool:
74
+ merged_context = get_context()
75
+ extra_context = getattr(record, "utilityhub_context", None)
76
+ if isinstance(extra_context, Mapping):
77
+ merged_context.update(extra_context)
78
+ record.utilityhub_context = merged_context
79
+ for key, value in merged_context.items():
80
+ if key.isidentifier() and key not in _RESERVED_LOG_KEYS:
81
+ setattr(record, key, value)
82
+ return True
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ from datetime import UTC, datetime
6
+ from typing import Any
7
+
8
+
9
+ def _format_timestamp(created: float) -> str:
10
+ dt = datetime.fromtimestamp(created, tz=UTC)
11
+ return dt.isoformat(timespec="milliseconds").replace("+00:00", "Z")
12
+
13
+
14
+ class PlainTextFormatter(logging.Formatter):
15
+ def format(self, record: logging.LogRecord) -> str:
16
+ timestamp = _format_timestamp(record.created)
17
+ message = record.getMessage()
18
+ context = getattr(record, "utilityhub_context", {})
19
+ context_text = ""
20
+ if context:
21
+ pairs = " ".join(f"{key}={value}" for key, value in sorted(context.items()))
22
+ context_text = f" [{pairs}]"
23
+
24
+ parts = [timestamp, record.levelname, record.name, "-", message]
25
+ rendered = " ".join(parts) + context_text
26
+
27
+ if record.exc_info:
28
+ rendered = f"{rendered}\n{self.formatException(record.exc_info)}"
29
+ if record.stack_info:
30
+ rendered = f"{rendered}\n{self.formatStack(record.stack_info)}"
31
+ return rendered
32
+
33
+
34
+ class JsonFormatter(logging.Formatter):
35
+ def format(self, record: logging.LogRecord) -> str:
36
+ payload: dict[str, Any] = {
37
+ "timestamp": _format_timestamp(record.created),
38
+ "level": record.levelname,
39
+ "logger": record.name,
40
+ "message": record.getMessage(),
41
+ }
42
+ context = getattr(record, "utilityhub_context", {})
43
+ if context:
44
+ payload["context"] = dict(sorted(context.items()))
45
+ if record.exc_info:
46
+ payload["exception"] = self.formatException(record.exc_info)
47
+ if record.stack_info:
48
+ payload["stack"] = self.formatStack(record.stack_info)
49
+ return json.dumps(payload, ensure_ascii=False, sort_keys=True)
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from utilityhub_logging.types import LogPathConvention
8
+
9
+
10
+ def resolve_logs_path(
11
+ app_name: str,
12
+ logs_path: str | os.PathLike[str] | None = None,
13
+ *,
14
+ default_convention: LogPathConvention | str = LogPathConvention.PLATFORM,
15
+ create: bool = True,
16
+ ) -> Path:
17
+ if not app_name.strip():
18
+ msg = "app_name must be a non-empty string"
19
+ raise ValueError(msg)
20
+
21
+ if logs_path is not None:
22
+ path = Path(logs_path).expanduser()
23
+ else:
24
+ convention = LogPathConvention(default_convention)
25
+ path = _resolve_default_logs_path(app_name=app_name, convention=convention)
26
+
27
+ if create:
28
+ path.mkdir(parents=True, exist_ok=True)
29
+
30
+ return path
31
+
32
+
33
+ def _resolve_default_logs_path(app_name: str, *, convention: LogPathConvention) -> Path:
34
+ if convention is LogPathConvention.CWD:
35
+ return Path.cwd() / "logs" / app_name
36
+
37
+ if convention is LogPathConvention.HOME_HIDDEN:
38
+ return Path.home() / f".{app_name}" / "logs"
39
+
40
+ if os.name == "nt":
41
+ local_app_data = os.getenv("LOCALAPPDATA")
42
+ if local_app_data:
43
+ return Path(local_app_data) / app_name / "Logs"
44
+ return Path.home() / "AppData" / "Local" / app_name / "Logs"
45
+
46
+ if sys.platform == "darwin":
47
+ return Path.home() / "Library" / "Logs" / app_name
48
+
49
+ return Path.home() / ".local" / "state" / app_name / "logs"
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ from utilityhub_logging.cleanup import cleanup_logging, mark_handler
7
+ from utilityhub_logging.context import ContextFilter, bind_context
8
+ from utilityhub_logging.paths import resolve_logs_path
9
+ from utilityhub_logging.setup import _build_formatter, _normalize_level, _slugify, _utc_now_stamp
10
+ from utilityhub_logging.types import LogFormat, LogPathConvention, ManagedHandlerKind
11
+
12
+
13
+ def begin_scope_logging(
14
+ scope_type: str,
15
+ scope_id: str,
16
+ *,
17
+ app_name: str,
18
+ level: int | str = "INFO",
19
+ logs_path: str | Path | None = None,
20
+ default_convention: LogPathConvention | str = LogPathConvention.PLATFORM,
21
+ log_format: LogFormat | str = LogFormat.PLAIN,
22
+ logger: logging.Logger | None = None,
23
+ propagate: bool = False,
24
+ ) -> tuple[logging.Logger, Path]:
25
+ if not scope_type.strip():
26
+ msg = "scope_type must be a non-empty string"
27
+ raise ValueError(msg)
28
+ if not scope_id.strip():
29
+ msg = "scope_id must be a non-empty string"
30
+ raise ValueError(msg)
31
+
32
+ target_logger = logger or logging.getLogger(f"{app_name}.{scope_type}.{scope_id}")
33
+ target_logger.setLevel(_normalize_level(level))
34
+ target_logger.propagate = propagate
35
+
36
+ cleanup_logging(target_logger, kind=ManagedHandlerKind.SCOPE)
37
+
38
+ root_logs_path = resolve_logs_path(
39
+ app_name=app_name,
40
+ logs_path=logs_path,
41
+ default_convention=default_convention,
42
+ )
43
+ scope_logs_path = root_logs_path / "scopes" / _slugify(scope_type)
44
+ scope_logs_path.mkdir(parents=True, exist_ok=True)
45
+ file_path = scope_logs_path / f"{_slugify(scope_id)}-{_utc_now_stamp()}.log"
46
+
47
+ handler = logging.FileHandler(file_path, encoding="utf-8")
48
+ handler.setLevel(_normalize_level(level))
49
+ handler.setFormatter(_build_formatter(log_format))
50
+ handler.addFilter(ContextFilter())
51
+ target_logger.addHandler(mark_handler(handler, kind=ManagedHandlerKind.SCOPE, file_path=file_path))
52
+
53
+ bind_context(scope_type=scope_type, scope_id=scope_id)
54
+ return target_logger, file_path
55
+
56
+
57
+ def end_scope_logging(logger: logging.Logger | None = None) -> None:
58
+ cleanup_logging(logger, kind=ManagedHandlerKind.SCOPE)
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import uuid
5
+ from datetime import UTC, datetime
6
+ from pathlib import Path
7
+
8
+ from utilityhub_logging.cleanup import cleanup_logging, mark_handler
9
+ from utilityhub_logging.context import ContextFilter, bind_context
10
+ from utilityhub_logging.formatters import JsonFormatter, PlainTextFormatter
11
+ from utilityhub_logging.paths import resolve_logs_path
12
+ from utilityhub_logging.types import LogFormat, LogPathConvention, ManagedHandlerKind
13
+
14
+
15
+ def configure_app_logging(
16
+ app_name: str,
17
+ *,
18
+ level: int | str = "INFO",
19
+ logs_path: str | Path | None = None,
20
+ default_convention: LogPathConvention | str = LogPathConvention.PLATFORM,
21
+ console: bool = True,
22
+ log_format: LogFormat | str = LogFormat.PLAIN,
23
+ logger: logging.Logger | None = None,
24
+ propagate: bool = False,
25
+ ) -> Path:
26
+ target_logger = logging.getLogger() if logger is None else logger
27
+ target_logger.setLevel(_normalize_level(level))
28
+ target_logger.propagate = propagate
29
+
30
+ cleanup_logging(target_logger, kind=ManagedHandlerKind.APP)
31
+
32
+ resolved_logs_path = resolve_logs_path(
33
+ app_name=app_name,
34
+ logs_path=logs_path,
35
+ default_convention=default_convention,
36
+ )
37
+ session_id = uuid.uuid4().hex[:8]
38
+ timestamp = _utc_now_stamp()
39
+ file_path = resolved_logs_path / f"{_slugify(app_name)}-{timestamp}-{session_id}.log"
40
+
41
+ formatter = _build_formatter(log_format)
42
+ file_handler = logging.FileHandler(file_path, encoding="utf-8")
43
+ file_handler.setLevel(_normalize_level(level))
44
+ file_handler.setFormatter(formatter)
45
+ file_handler.addFilter(ContextFilter())
46
+ target_logger.addHandler(mark_handler(file_handler, kind=ManagedHandlerKind.APP, file_path=file_path))
47
+
48
+ if console:
49
+ console_handler = logging.StreamHandler()
50
+ console_handler.setLevel(_normalize_level(level))
51
+ console_handler.setFormatter(formatter)
52
+ console_handler.addFilter(ContextFilter())
53
+ target_logger.addHandler(mark_handler(console_handler, kind=ManagedHandlerKind.APP))
54
+
55
+ bind_context(app_name=app_name, session_id=session_id)
56
+ return file_path
57
+
58
+
59
+ def _build_formatter(log_format: LogFormat | str) -> logging.Formatter:
60
+ selected = LogFormat(log_format)
61
+ if selected is LogFormat.JSON:
62
+ return JsonFormatter()
63
+ return PlainTextFormatter()
64
+
65
+
66
+ def _normalize_level(level: int | str) -> int:
67
+ if isinstance(level, int):
68
+ return level
69
+ normalized = logging.getLevelName(level.upper())
70
+ if isinstance(normalized, int):
71
+ return normalized
72
+ msg = f"Unsupported log level: {level!r}"
73
+ raise ValueError(msg)
74
+
75
+
76
+ def _utc_now_stamp() -> str:
77
+ return datetime.now(tz=UTC).strftime("%Y%m%dT%H%M%SZ")
78
+
79
+
80
+ def _slugify(value: str) -> str:
81
+ chars = [char.lower() if char.isalnum() else "-" for char in value.strip()]
82
+ slug = "".join(chars).strip("-")
83
+ while "--" in slug:
84
+ slug = slug.replace("--", "-")
85
+ return slug or "log"
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import StrEnum
5
+ from pathlib import Path
6
+
7
+
8
+ class LogFormat(StrEnum):
9
+ PLAIN = "plain"
10
+ JSON = "json"
11
+
12
+
13
+ class LogPathConvention(StrEnum):
14
+ PLATFORM = "platform"
15
+ HOME_HIDDEN = "home_hidden"
16
+ CWD = "cwd"
17
+
18
+
19
+ class ManagedHandlerKind(StrEnum):
20
+ APP = "app"
21
+ SCOPE = "scope"
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class ManagedHandlerRecord:
26
+ logger_name: str
27
+ handler_name: str
28
+ kind: ManagedHandlerKind
29
+ file_path: Path | None = None