amox 0.0.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.
amox-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: amox
3
+ Version: 0.0.1
4
+ Summary: Schema on read based logging
5
+ Keywords: json,logfmt,logging,structured-logging
6
+ Author: Kevin Montoya
7
+ Author-email: Kevin Montoya <me@kmontocam.com>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Software Development :: Libraries
18
+ Classifier: Topic :: System :: Logging
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.12
21
+ Project-URL: Issues, https://github.com/kmontocam/amox/issues
22
+ Project-URL: Repository, https://github.com/kmontocam/amox
23
+ Description-Content-Type: text/markdown
24
+
25
+ # amox
amox-0.0.1/README.md ADDED
@@ -0,0 +1 @@
1
+ # amox
@@ -0,0 +1,71 @@
1
+ [project]
2
+ authors = [{ name = "Kevin Montoya", email = "me@kmontocam.com" }]
3
+ classifiers = [
4
+ "Development Status :: 3 - Alpha",
5
+ "Intended Audience :: Developers",
6
+ "Operating System :: OS Independent",
7
+ "Programming Language :: Python :: 3 :: Only",
8
+ "Programming Language :: Python :: 3",
9
+ "Programming Language :: Python :: 3.12",
10
+ "Programming Language :: Python :: 3.13",
11
+ "Programming Language :: Python :: 3.14",
12
+ "Topic :: Software Development :: Libraries",
13
+ "Topic :: System :: Logging",
14
+ "Typing :: Typed",
15
+ ]
16
+ dependencies = []
17
+ description = "Schema on read based logging"
18
+ keywords = ["json", "logfmt", "logging", "structured-logging"]
19
+ license = "MIT"
20
+ name = "amox"
21
+ readme = "README.md"
22
+ requires-python = ">=3.12"
23
+ version = "0.0.1"
24
+
25
+ [project.urls]
26
+ Issues = "https://github.com/kmontocam/amox/issues"
27
+ Repository = "https://github.com/kmontocam/amox"
28
+
29
+ [build-system]
30
+ build-backend = "uv_build"
31
+ requires = ["uv_build>=0.11.14,<0.12.0"]
32
+
33
+ [dependency-groups]
34
+ dev = [
35
+ "jsonschema==4.26.0",
36
+ "mbake==1.4.6",
37
+ "pre-commit==4.6.0",
38
+ "pytest==9.0.3",
39
+ "pyyaml==6.0.3",
40
+ "ruff==0.15.14",
41
+ "taplo==0.9.3",
42
+ "ty==0.0.38",
43
+ "uvicorn==0.47.0",
44
+ ]
45
+
46
+ [tool.pytest.ini_options]
47
+ addopts = ["-p", "no:logging"]
48
+ markers = [
49
+ "functional: subprocess-based full API tests",
50
+ "integration: tests with third-party dependencies",
51
+ ]
52
+ pythonpath = ["."]
53
+ testpaths = ["tests"]
54
+
55
+ [tool.ruff]
56
+ line-length = 88
57
+ target-version = "py312"
58
+
59
+ [tool.ruff.lint]
60
+ ignore = ["COM812", "D203", "D212"]
61
+ select = ["ALL"]
62
+
63
+ [tool.ruff.lint.per-file-ignores]
64
+ "tests/**/*.py" = ["FBT001", "S101"]
65
+
66
+ [tool.ruff.format]
67
+ docstring-code-format = true
68
+ quote-style = "double"
69
+
70
+ [tool.pyright]
71
+ reportAny = false
@@ -0,0 +1,17 @@
1
+ """Amox."""
2
+
3
+ from amox.formatters import JsonFormatter, LogfmtFormatter, create_formatter
4
+ from amox.logging_ import config, get_logger, setup
5
+ from amox.types_ import FormatterOptions
6
+
7
+ __all__ = [
8
+ "FormatterOptions",
9
+ "JsonFormatter",
10
+ "LogfmtFormatter",
11
+ "config",
12
+ "create_formatter",
13
+ "get_logger",
14
+ "setup",
15
+ ]
16
+
17
+ __version__ = "0.0.1"
@@ -0,0 +1,30 @@
1
+ {
2
+ "$schema": "../../schema/dictConfig.schema.json",
3
+ "disable_existing_loggers": false,
4
+ "formatters": {
5
+ "amox": {
6
+ "()": "amox.create_formatter"
7
+ }
8
+ },
9
+ "handlers": {
10
+ "amox": {
11
+ "class": "logging.StreamHandler",
12
+ "formatter": "amox",
13
+ "stream": "ext://sys.stderr"
14
+ },
15
+ "amox.queue_handler": {
16
+ "class": "amox.handlers.LiveQueueHandler",
17
+ "handlers": [
18
+ "amox"
19
+ ],
20
+ "respect_handler_level": true
21
+ }
22
+ },
23
+ "root": {
24
+ "handlers": [
25
+ "amox.queue_handler"
26
+ ],
27
+ "level": "INFO"
28
+ },
29
+ "version": 1
30
+ }
@@ -0,0 +1,383 @@
1
+ """Schema on read formatters."""
2
+
3
+ import datetime as dt
4
+ import json
5
+ import logging
6
+ import os
7
+ import re
8
+ import typing as t
9
+ import warnings
10
+
11
+ from amox.types_ import (
12
+ FieldRemap,
13
+ FormatterOptions,
14
+ IncludeFields,
15
+ Json,
16
+ Logfmt,
17
+ LogFormat,
18
+ LogRecordAttr,
19
+ )
20
+
21
+ LOG_FORMAT_ENV = "AMOX_LOG_FORMAT"
22
+ """
23
+ Convention environment variable name to configure log format.
24
+ """
25
+
26
+ DEL_CHAR = 0x7F
27
+ """
28
+ ASCII DEL character ordinal.
29
+ """
30
+
31
+ LOG_RECORD_BUILTIN_ATTRS: set[LogRecordAttr] = set( # pyright: ignore[reportAssignmentType]
32
+ logging.makeLogRecord({"message": "", "asctime": ""}).__dict__.keys(),
33
+ ) # ty: ignore[invalid-assignment]
34
+ """
35
+ `logging.LogRecord` instance attributes to enrich logs.
36
+
37
+ Note:
38
+ `message` and `asctime` are seeded, since `makeLogRecord` materializes theme during
39
+ `Formatter.format()`.
40
+ """
41
+
42
+
43
+ LOG_FORMATS: set[LogFormat] = {
44
+ "json",
45
+ "logfmt",
46
+ }
47
+
48
+ DEFAULT_FORMAT: LogFormat = "logfmt"
49
+
50
+ DEFAULT_FIELD_REMAP: FieldRemap = {
51
+ "created": "ts",
52
+ "levelname": "level",
53
+ "name": "logger",
54
+ "msg": "msg",
55
+ "exc_info": "exception",
56
+ }
57
+ """
58
+ Default rename convention of `LogRecordAttr`.
59
+ """
60
+
61
+ DEFAULT_INCLUDE: tuple[LogRecordAttr, ...] = (
62
+ "created",
63
+ "levelname",
64
+ "name",
65
+ "msg",
66
+ "exc_info",
67
+ )
68
+
69
+ VERBOSE_INCLUDE: tuple[LogRecordAttr, ...] = (
70
+ *DEFAULT_INCLUDE,
71
+ "filename",
72
+ "lineno",
73
+ "funcName",
74
+ "threadName",
75
+ "processName",
76
+ )
77
+
78
+ ALL_EXCLUDE: set[LogRecordAttr] = {
79
+ "args",
80
+ "exc_text",
81
+ "relativeCreated",
82
+ "msecs",
83
+ }
84
+ """
85
+ Always excluded attributes from log record.
86
+ """
87
+
88
+
89
+ class AmoxFormatter(logging.Formatter):
90
+ """Base formatter with shared field extraction logic."""
91
+
92
+ configurable: frozenset[
93
+ t.Literal[
94
+ "datefmt",
95
+ "include",
96
+ "snake_case",
97
+ "field_remap",
98
+ ]
99
+ ] = frozenset(
100
+ {
101
+ "datefmt",
102
+ "include",
103
+ "snake_case",
104
+ "field_remap",
105
+ },
106
+ )
107
+ tz: dt.tzinfo | None = None
108
+
109
+ def __init__( # noqa: PLR0913
110
+ self,
111
+ fmt: str | None = None,
112
+ datefmt: str | None = None,
113
+ style: t.Literal["%", "{", "$"] = "%",
114
+ validate: bool = True, # noqa: FBT001, FBT002
115
+ *,
116
+ defaults: dict[str, object] | None = None,
117
+ include: IncludeFields = "minimal",
118
+ snake_case: bool = True,
119
+ field_remap: FieldRemap = DEFAULT_FIELD_REMAP,
120
+ ) -> None:
121
+ """
122
+ Initialize the structured formatter.
123
+
124
+ Args:
125
+ fmt: format string for `logging.Formatter` (e.g. `'%(message)s'`).
126
+ Included for protocol, not typically used with schema formatters.
127
+ datefmt: strftime format for `LogRecord.created` timestamp.
128
+ style: format string style character (`%`, `{`, or `$`).
129
+ validate: whether to validate the format string.
130
+ defaults: default values merged into every record's `__dict__`.
131
+ include: list of attributes of a record to include, or string based
132
+ convention.
133
+ snake_case: whether to snake_case the keys from the compiled records.
134
+ Applies to field_remap values as well.
135
+ field_remap: mapping to convert attribute keys to a different name
136
+
137
+ """
138
+ super().__init__(
139
+ fmt=fmt,
140
+ datefmt=datefmt,
141
+ style=style,
142
+ validate=validate,
143
+ defaults=defaults,
144
+ )
145
+ self.snake_case: bool = snake_case
146
+ self.field_remap: FieldRemap = field_remap
147
+
148
+ self.include: tuple[LogRecordAttr, ...] = self.includes(include)
149
+ self.field_remap_keys: list[LogRecordAttr] = list(field_remap.keys())
150
+
151
+ def includes(self, fields: IncludeFields) -> tuple[LogRecordAttr, ...]:
152
+ """Resolve record attributes to include on the record."""
153
+ if isinstance(fields, list):
154
+ return tuple(fields)
155
+ if fields == "minimal":
156
+ return DEFAULT_INCLUDE
157
+ if fields == "verbose":
158
+ return VERBOSE_INCLUDE
159
+
160
+ return tuple( # "all"
161
+ LOG_RECORD_BUILTIN_ATTRS - ALL_EXCLUDE,
162
+ )
163
+
164
+ def output_key(self, source: str) -> str:
165
+ """
166
+ Resolve the output key name for a `LogRecord` attribute.
167
+
168
+ Apply's `field_remap` rename if present, snake cases field if configured.
169
+ """
170
+ if source in self.field_remap_keys:
171
+ source = self.field_remap[source] # ty: ignore[invalid-argument-type]
172
+
173
+ return self.to_snake(source) if self.snake_case else source
174
+
175
+ def value_from_record(
176
+ self,
177
+ record: logging.LogRecord,
178
+ source: LogRecordAttr,
179
+ ) -> str | None:
180
+ """
181
+ Extract a value from a `LogRecord`.
182
+
183
+ Apply dedicated reader to attributes that are enhanced by standard record
184
+ formatters or custom.
185
+ """
186
+ if source == "msg":
187
+ return record.getMessage()
188
+ if source == "created":
189
+ return self.format_timestamp(record.created)
190
+
191
+ if source == "exc_info":
192
+ return (
193
+ self.formatException(exc)
194
+ if (exc := record.exc_info)
195
+ else record.exc_text
196
+ )
197
+ return getattr(record, source, None)
198
+
199
+ def compile_record(self, record: logging.LogRecord) -> dict[str, object]:
200
+ """
201
+ Build dictionary from a `LogRecord`, ready to deserialize.
202
+
203
+ Pre-process a raw log record object with configuration options. Produce
204
+ a mapping ready for formatting.
205
+ """
206
+ payload: dict[str, object] = {}
207
+
208
+ for key in self.include:
209
+ val = self.value_from_record(record, key)
210
+ if key == "exc_info" and val is None:
211
+ continue
212
+ payload[self.output_key(key)] = val
213
+
214
+ payload.update(
215
+ {
216
+ key: val
217
+ for key, val in record.__dict__.items()
218
+ if key not in LOG_RECORD_BUILTIN_ATTRS
219
+ },
220
+ )
221
+
222
+ return payload
223
+
224
+ def format_timestamp(self, created: float) -> str:
225
+ """
226
+ Format a unix timestamp using `datefmt`.
227
+
228
+ When `tz` is set, formats in that timezone. UTC gets a `Z` suffix.
229
+ Defaults to local time with ISO 8601 foramt.
230
+ """
231
+ timestamp = dt.datetime.fromtimestamp(created, tz=dt.UTC)
232
+ if self.tz is not None:
233
+ timestamp = timestamp.astimezone(self.tz)
234
+ else:
235
+ timestamp = timestamp.astimezone()
236
+ if self.datefmt:
237
+ return timestamp.strftime(self.datefmt)
238
+ iso = timestamp.isoformat()
239
+ if self.tz == dt.UTC:
240
+ return iso.replace("+00:00", "Z")
241
+ return iso
242
+
243
+ @staticmethod
244
+ def to_snake(camel: str) -> str:
245
+ """
246
+ Convert a camelCase string to snake_case.
247
+
248
+ Reference:
249
+ `https://github.com/pydantic/pydantic/blob/main/pydantic/alias_generators.py`
250
+ """
251
+ snake = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", camel)
252
+ snake = re.sub(r"([a-z])([A-Z])", r"\1_\2", snake)
253
+ snake = re.sub(r"([0-9])([A-Z])", r"\1_\2", snake)
254
+ snake = re.sub(r"([a-z])([0-9])", r"\1_\2", snake)
255
+ return snake.replace("-", "_").lower()
256
+
257
+
258
+ class LogfmtFormatter(AmoxFormatter):
259
+ """
260
+ Formats log records as logfmt key=value pairs.
261
+
262
+ Reference:
263
+ `https://brandur.org/logfmt`
264
+ """
265
+
266
+ @t.override
267
+ def format(self, record: logging.LogRecord) -> str:
268
+ payload = self.compile_record(record)
269
+ return " ".join(
270
+ f"{key}={self.encode_value(val)}" for key, val in payload.items()
271
+ )
272
+
273
+ def encode_value(self, value: object) -> str:
274
+ """
275
+ Encode a value for logfmt output.
276
+
277
+ Reference:
278
+ `https://github.com/go-logfmt/logfmt/blob/master/encode.go`
279
+ """
280
+ if value is None:
281
+ return "null"
282
+ if isinstance(value, bool):
283
+ return "true" if value else "false"
284
+ if isinstance(value, (int, float)):
285
+ return str(value)
286
+ return self.quote(str(value))
287
+
288
+ def quote(self, s: str) -> str:
289
+ """Apply logfmt quoting rules to a string value."""
290
+ if not s:
291
+ return '""'
292
+ if self.needs_quote(s):
293
+ escaped = (
294
+ s.replace("\\", "\\\\")
295
+ .replace('"', '\\"')
296
+ .replace("\n", "\\n")
297
+ .replace("\r", "\\r")
298
+ .replace("\t", "\\t")
299
+ )
300
+ return f'"{escaped}"'
301
+ return s
302
+
303
+ def needs_quote(self, s: str) -> bool:
304
+ """
305
+ Whether a logfmt value needs quoting.
306
+
307
+ Reference:
308
+ `https://github.com/go-logfmt/logfmt/blob/master/encode.go`
309
+ """
310
+ return any(c <= " " or c in {"=", '"', "\\"} or ord(c) == DEL_CHAR for c in s)
311
+
312
+
313
+ class JsonFormatter(AmoxFormatter):
314
+ """Formats log records as JSON."""
315
+
316
+ @t.override
317
+ def format(self, record: logging.LogRecord) -> str:
318
+ payload = self.compile_record(record)
319
+ return json.dumps(payload, default=str)
320
+
321
+
322
+ @t.overload
323
+ def create_formatter(
324
+ log_format: Json,
325
+ /,
326
+ **opts: t.Unpack[FormatterOptions],
327
+ ) -> JsonFormatter: ...
328
+
329
+
330
+ @t.overload
331
+ def create_formatter(
332
+ log_format: Logfmt | None = None,
333
+ /,
334
+ **opts: t.Unpack[FormatterOptions],
335
+ ) -> LogfmtFormatter: ...
336
+
337
+
338
+ def create_formatter(
339
+ log_format: LogFormat | None = None,
340
+ /,
341
+ **opts: t.Unpack[FormatterOptions],
342
+ ) -> JsonFormatter | LogfmtFormatter:
343
+ """
344
+ Create a formatter from a format identifier string.
345
+
346
+ Resolve the log format and return the corresponding formatter instance.
347
+ Used as the factory for `dictConfig`'s `()` protocol.
348
+ """
349
+ log_format = log_format or resolve_format()
350
+ tz = opts.get("tz")
351
+ field_remap: FieldRemap = {
352
+ **DEFAULT_FIELD_REMAP,
353
+ **(opts.get("field_remap") or {}),
354
+ }
355
+ cls = JsonFormatter if log_format == "json" else LogfmtFormatter
356
+ formatter = cls(
357
+ datefmt=opts.get("datefmt"),
358
+ include=opts.get("include", "minimal"),
359
+ snake_case=opts.get("snake_case", True),
360
+ field_remap=field_remap,
361
+ )
362
+ formatter.tz = tz
363
+ return formatter
364
+
365
+
366
+ def resolve_format() -> LogFormat:
367
+ """Resolve log format from environment variable convention."""
368
+ env = os.environ.get(LOG_FORMAT_ENV)
369
+ if env is None:
370
+ return DEFAULT_FORMAT
371
+ if env in LOG_FORMATS:
372
+ return env # ty: ignore[invalid-return-type]
373
+
374
+ warnings.warn(
375
+ (
376
+ f"{LOG_FORMAT_ENV}={env!r} is not valid."
377
+ f" Expected one of: {', '.join(sorted(LOG_FORMATS))}."
378
+ f" Falling back to {DEFAULT_FORMAT!r}."
379
+ ),
380
+ UserWarning,
381
+ stacklevel=2,
382
+ )
383
+ return DEFAULT_FORMAT
@@ -0,0 +1,55 @@
1
+ """Handlers."""
2
+
3
+ import atexit
4
+ import logging
5
+ import traceback
6
+ import typing as t
7
+ from logging.handlers import QueueHandler, QueueListener
8
+
9
+
10
+ class LiveQueueHandler(QueueHandler):
11
+ """
12
+ Queue-based log handler with automatic listener lifecycle.
13
+
14
+ The `QueueListener` is started as soon as it is attached and stopped on
15
+ interpreter shutdown via `atexit`.
16
+ """
17
+
18
+ listener: QueueListener | None = None
19
+
20
+ @t.override
21
+ def __setattr__(self, name: str, value: object) -> None:
22
+ super().__setattr__(name, value)
23
+ if name == "listener" and isinstance(value, QueueListener):
24
+ value.start()
25
+ _ = atexit.register(self.stop_listener)
26
+
27
+ def stop_listener(self) -> None:
28
+ """
29
+ Stop the listener if it is still running.
30
+
31
+ Guards against double-stop when atexit fires after an explicit `listener.stop()`
32
+ call.
33
+ """
34
+ if (listener := self.listener) is not None and listener._thread is not None: # noqa: SLF001
35
+ listener.stop()
36
+
37
+ @t.override
38
+ def prepare(self, record: logging.LogRecord) -> logging.LogRecord:
39
+ """
40
+ Preserve `exc_text` for downstream formatters.
41
+
42
+ Python 3.12's stdlib `prepare()` embeds the traceback into `record.msg`
43
+ and clears `exc_text`, making exception info inaccessible to downstream
44
+ formatters. Format `exc_info` into `exc_text` and only clear the tuple
45
+ (which holds frame references and is unpicklable).
46
+
47
+ Reference:
48
+ `https://github.com/python/cpython/issues/107801`
49
+ """
50
+ if record.exc_info:
51
+ record.exc_text = "".join(
52
+ traceback.format_exception(*record.exc_info),
53
+ ).rstrip("\n")
54
+ record.exc_info = None
55
+ return record