xtr-logging-contracts 1.0.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xterr
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,179 @@
1
+ Metadata-Version: 2.4
2
+ Name: xtr-logging-contracts
3
+ Version: 1.0.0
4
+ Summary: The logging contract: one interface, eight severities, and a logger that discards everything.
5
+ Keywords: logging,contracts,interface,protocol,structured-logging
6
+ Author: Razvan Ceana
7
+ Author-email: Razvan Ceana <razvan@ceana.ro>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: System :: Logging
17
+ Classifier: Typing :: Typed
18
+ Requires-Dist: typing-extensions>=4.4
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+
22
+ <div align="center">
23
+
24
+ # xtr-logging-contracts
25
+
26
+ **The logging contract, and nothing else — so a library that logs installs nothing else.**
27
+
28
+ <img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
29
+ <img alt="core dependencies: 1" src="https://img.shields.io/badge/core%20deps-1-3FB950">
30
+ <img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
31
+ <img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
32
+
33
+ </div>
34
+
35
+ ---
36
+
37
+ ## Why?
38
+
39
+ A library that logs should not decide where records go. It takes a `LoggerInterface`, defaults to
40
+ a `NullLogger`, and leaves handlers, formatters and configuration to the application that wires
41
+ it.
42
+
43
+ Which means a library needs the *contract*, not an implementation — and should not pay for one.
44
+ Depending on [xtr-logging](https://github.com/xterr/python-xtr-logging) to annotate one parameter
45
+ drags in a JSON codec, a clock and seventeen handlers the library never touches. This package is
46
+ that dependency, reduced to what the seam is actually made of:
47
+
48
+ - ðŸ§Đ **`LoggerInterface`** — eight severities plus `log()`, with a context mapping.
49
+ - ðŸ•ģïļ **`NullLogger`** — what makes logging optional for your callers.
50
+ - 🊜 **`Level`** — the eight RFC 5424 severities, comparable as integers.
51
+ - 🗂ïļ **`Context`** — the mapping every method takes, and the key an exception goes under.
52
+ - ðŸŠķ **One dependency** — `typing-extensions`, for `@override` on 3.11.
53
+
54
+ ```python
55
+ from xtr_logging_contracts import LoggerInterface, NullLogger
56
+
57
+
58
+ class Checkout:
59
+ def __init__(self, logger: LoggerInterface | None = None) -> None:
60
+ self._logger = logger or NullLogger()
61
+
62
+ def pay(self, order: Order) -> None:
63
+ self._logger.error("payment {order} failed", {"order": order.id})
64
+ ```
65
+
66
+ ## Install
67
+
68
+ ```sh
69
+ uv add xtr-logging-contracts
70
+ ```
71
+
72
+ Requires Python 3.11+.
73
+
74
+ ## Who installs what
75
+
76
+ | | Depends on |
77
+ | --- | --- |
78
+ | **A library that logs** | `xtr-logging-contracts` at runtime, `xtr-logging` as a dev dependency — its tests build real loggers and assert on a `TestHandler`. |
79
+ | **An application** | `xtr-logging`, which implements this contract and wires channels, handlers and processors from configuration. |
80
+
81
+ `xtr-logging` **re-exports** every symbol here rather than redefining it, so
82
+ `xtr_logging.LoggerInterface is xtr_logging_contracts.LoggerInterface`. That identity is what lets
83
+ a container register a logger under the interface and a library, which never imported
84
+ `xtr-logging`, receive it.
85
+
86
+ ## The interface
87
+
88
+ ```python
89
+ class LoggerInterface(Protocol):
90
+ def emergency(self, message: str, /, context: Context | None = None) -> None: ...
91
+ def alert(self, message: str, /, context: Context | None = None) -> None: ...
92
+ def critical(self, message: str, /, context: Context | None = None) -> None: ...
93
+ def error(self, message: str, /, context: Context | None = None) -> None: ...
94
+ def warning(self, message: str, /, context: Context | None = None) -> None: ...
95
+ def notice(self, message: str, /, context: Context | None = None) -> None: ...
96
+ def info(self, message: str, /, context: Context | None = None) -> None: ...
97
+ def debug(self, message: str, /, context: Context | None = None) -> None: ...
98
+ def log(self, level: LevelLike, message: str, /, context: Context | None = None) -> None: ...
99
+ ```
100
+
101
+ The rules:
102
+
103
+ - `context` is a mapping of anything. Formatters describe what they cannot serialise; a value
104
+ never makes logging fail.
105
+ - An exception to report goes under `context["exception"]`.
106
+ - `{key}` placeholders in the message are filled from context downstream, not by the logger, so a
107
+ handler can still see the template and the values apart.
108
+
109
+ `AbstractLogger` implements the eight severity methods on top of `log()`, so an implementation
110
+ writes one method. `LoggerAware` gives a class a `logger` that is a `NullLogger` until
111
+ `set_logger()` is called.
112
+
113
+ > ruff's `PLE1205` assumes every `logger.info(...)` is the standard library's and flags the
114
+ > context mapping as a stray format argument. Ignore it in projects using this interface.
115
+
116
+ ### Levels
117
+
118
+ | Level | Value | RFC 5424 | | Level | Value | RFC 5424 |
119
+ | --- | --- | --- | --- | --- | --- | --- |
120
+ | `DEBUG` | 100 | 7 | | `ERROR` | 400 | 3 |
121
+ | `INFO` | 200 | 6 | | `CRITICAL` | 500 | 2 |
122
+ | `NOTICE` | 250 | 5 | | `ALERT` | 550 | 1 |
123
+ | `WARNING` | 300 | 4 | | `EMERGENCY` | 600 | 0 |
124
+
125
+ Anywhere a level is accepted, `Level.parse` reads it: a `Level`, its value, an RFC 5424 severity,
126
+ or a name in any case (`"error"`). Anything else raises `InvalidLevelError`.
127
+
128
+ ### Context
129
+
130
+ `Context` is `Mapping[str, object]` — the second argument to every method above. Values are
131
+ `object` because a caller may log anything; whatever writes the record normalises what it cannot
132
+ serialise rather than refusing it. An exception to report goes under `EXCEPTION_KEY`:
133
+
134
+ ```python
135
+ from xtr_logging_contracts import EXCEPTION_KEY
136
+
137
+ logger.error("payment failed", {"order": order.id, EXCEPTION_KEY: error})
138
+ ```
139
+
140
+ ## What is not here
141
+
142
+ Everything that *acts* on what was logged: `LogRecord`, `Logger`, `LoggerFactory`,
143
+ `LoggingConfig`, the seventeen handlers, the processors, the formatters, ambient
144
+ `bound_context()`, and the standard-library bridge. All of that is
145
+ [xtr-logging](https://github.com/xterr/python-xtr-logging).
146
+
147
+ `LogRecord` in particular belongs there, not here: it appears in no signature above. A library
148
+ that logs never builds one or sees one — it is made inside the logger and consumed by handlers
149
+ and processors, which are implementation.
150
+
151
+ `HandlerInterface`, `ProcessorInterface` and `FormatterInterface` are deliberately absent for the
152
+ same reason, plus one more: nothing outside `xtr-logging` implements them yet, and a contract
153
+ package earns its stability by staying small. They move here when a third-party handler needs
154
+ them — and `LogRecord` would move with them.
155
+
156
+ ## Errors
157
+
158
+ | Error | Raised when |
159
+ | --- | --- |
160
+ | `LoggingError` | Never directly — the base every logging error derives from, `xtr-logging`'s included |
161
+ | `InvalidLevelError` | A value names no level (also a `ValueError`) |
162
+
163
+ ## Development
164
+
165
+ Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
166
+ `packages/xtr-logging-contracts`; run the commands below from there. The `python-xtr-logging-contracts` repository is a
167
+ read-only copy, so send issues and pull requests to the monorepo.
168
+
169
+ ```sh
170
+ uv sync
171
+ uv run ruff check . && uv run ruff format --check .
172
+ uv run basedpyright
173
+ uv run ty check
174
+ uv run pytest
175
+ ```
176
+
177
+ ## License
178
+
179
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,158 @@
1
+ <div align="center">
2
+
3
+ # xtr-logging-contracts
4
+
5
+ **The logging contract, and nothing else — so a library that logs installs nothing else.**
6
+
7
+ <img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
8
+ <img alt="core dependencies: 1" src="https://img.shields.io/badge/core%20deps-1-3FB950">
9
+ <img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
10
+ <img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
11
+
12
+ </div>
13
+
14
+ ---
15
+
16
+ ## Why?
17
+
18
+ A library that logs should not decide where records go. It takes a `LoggerInterface`, defaults to
19
+ a `NullLogger`, and leaves handlers, formatters and configuration to the application that wires
20
+ it.
21
+
22
+ Which means a library needs the *contract*, not an implementation — and should not pay for one.
23
+ Depending on [xtr-logging](https://github.com/xterr/python-xtr-logging) to annotate one parameter
24
+ drags in a JSON codec, a clock and seventeen handlers the library never touches. This package is
25
+ that dependency, reduced to what the seam is actually made of:
26
+
27
+ - ðŸ§Đ **`LoggerInterface`** — eight severities plus `log()`, with a context mapping.
28
+ - ðŸ•ģïļ **`NullLogger`** — what makes logging optional for your callers.
29
+ - 🊜 **`Level`** — the eight RFC 5424 severities, comparable as integers.
30
+ - 🗂ïļ **`Context`** — the mapping every method takes, and the key an exception goes under.
31
+ - ðŸŠķ **One dependency** — `typing-extensions`, for `@override` on 3.11.
32
+
33
+ ```python
34
+ from xtr_logging_contracts import LoggerInterface, NullLogger
35
+
36
+
37
+ class Checkout:
38
+ def __init__(self, logger: LoggerInterface | None = None) -> None:
39
+ self._logger = logger or NullLogger()
40
+
41
+ def pay(self, order: Order) -> None:
42
+ self._logger.error("payment {order} failed", {"order": order.id})
43
+ ```
44
+
45
+ ## Install
46
+
47
+ ```sh
48
+ uv add xtr-logging-contracts
49
+ ```
50
+
51
+ Requires Python 3.11+.
52
+
53
+ ## Who installs what
54
+
55
+ | | Depends on |
56
+ | --- | --- |
57
+ | **A library that logs** | `xtr-logging-contracts` at runtime, `xtr-logging` as a dev dependency — its tests build real loggers and assert on a `TestHandler`. |
58
+ | **An application** | `xtr-logging`, which implements this contract and wires channels, handlers and processors from configuration. |
59
+
60
+ `xtr-logging` **re-exports** every symbol here rather than redefining it, so
61
+ `xtr_logging.LoggerInterface is xtr_logging_contracts.LoggerInterface`. That identity is what lets
62
+ a container register a logger under the interface and a library, which never imported
63
+ `xtr-logging`, receive it.
64
+
65
+ ## The interface
66
+
67
+ ```python
68
+ class LoggerInterface(Protocol):
69
+ def emergency(self, message: str, /, context: Context | None = None) -> None: ...
70
+ def alert(self, message: str, /, context: Context | None = None) -> None: ...
71
+ def critical(self, message: str, /, context: Context | None = None) -> None: ...
72
+ def error(self, message: str, /, context: Context | None = None) -> None: ...
73
+ def warning(self, message: str, /, context: Context | None = None) -> None: ...
74
+ def notice(self, message: str, /, context: Context | None = None) -> None: ...
75
+ def info(self, message: str, /, context: Context | None = None) -> None: ...
76
+ def debug(self, message: str, /, context: Context | None = None) -> None: ...
77
+ def log(self, level: LevelLike, message: str, /, context: Context | None = None) -> None: ...
78
+ ```
79
+
80
+ The rules:
81
+
82
+ - `context` is a mapping of anything. Formatters describe what they cannot serialise; a value
83
+ never makes logging fail.
84
+ - An exception to report goes under `context["exception"]`.
85
+ - `{key}` placeholders in the message are filled from context downstream, not by the logger, so a
86
+ handler can still see the template and the values apart.
87
+
88
+ `AbstractLogger` implements the eight severity methods on top of `log()`, so an implementation
89
+ writes one method. `LoggerAware` gives a class a `logger` that is a `NullLogger` until
90
+ `set_logger()` is called.
91
+
92
+ > ruff's `PLE1205` assumes every `logger.info(...)` is the standard library's and flags the
93
+ > context mapping as a stray format argument. Ignore it in projects using this interface.
94
+
95
+ ### Levels
96
+
97
+ | Level | Value | RFC 5424 | | Level | Value | RFC 5424 |
98
+ | --- | --- | --- | --- | --- | --- | --- |
99
+ | `DEBUG` | 100 | 7 | | `ERROR` | 400 | 3 |
100
+ | `INFO` | 200 | 6 | | `CRITICAL` | 500 | 2 |
101
+ | `NOTICE` | 250 | 5 | | `ALERT` | 550 | 1 |
102
+ | `WARNING` | 300 | 4 | | `EMERGENCY` | 600 | 0 |
103
+
104
+ Anywhere a level is accepted, `Level.parse` reads it: a `Level`, its value, an RFC 5424 severity,
105
+ or a name in any case (`"error"`). Anything else raises `InvalidLevelError`.
106
+
107
+ ### Context
108
+
109
+ `Context` is `Mapping[str, object]` — the second argument to every method above. Values are
110
+ `object` because a caller may log anything; whatever writes the record normalises what it cannot
111
+ serialise rather than refusing it. An exception to report goes under `EXCEPTION_KEY`:
112
+
113
+ ```python
114
+ from xtr_logging_contracts import EXCEPTION_KEY
115
+
116
+ logger.error("payment failed", {"order": order.id, EXCEPTION_KEY: error})
117
+ ```
118
+
119
+ ## What is not here
120
+
121
+ Everything that *acts* on what was logged: `LogRecord`, `Logger`, `LoggerFactory`,
122
+ `LoggingConfig`, the seventeen handlers, the processors, the formatters, ambient
123
+ `bound_context()`, and the standard-library bridge. All of that is
124
+ [xtr-logging](https://github.com/xterr/python-xtr-logging).
125
+
126
+ `LogRecord` in particular belongs there, not here: it appears in no signature above. A library
127
+ that logs never builds one or sees one — it is made inside the logger and consumed by handlers
128
+ and processors, which are implementation.
129
+
130
+ `HandlerInterface`, `ProcessorInterface` and `FormatterInterface` are deliberately absent for the
131
+ same reason, plus one more: nothing outside `xtr-logging` implements them yet, and a contract
132
+ package earns its stability by staying small. They move here when a third-party handler needs
133
+ them — and `LogRecord` would move with them.
134
+
135
+ ## Errors
136
+
137
+ | Error | Raised when |
138
+ | --- | --- |
139
+ | `LoggingError` | Never directly — the base every logging error derives from, `xtr-logging`'s included |
140
+ | `InvalidLevelError` | A value names no level (also a `ValueError`) |
141
+
142
+ ## Development
143
+
144
+ Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
145
+ `packages/xtr-logging-contracts`; run the commands below from there. The `python-xtr-logging-contracts` repository is a
146
+ read-only copy, so send issues and pull requests to the monorepo.
147
+
148
+ ```sh
149
+ uv sync
150
+ uv run ruff check . && uv run ruff format --check .
151
+ uv run basedpyright
152
+ uv run ty check
153
+ uv run pytest
154
+ ```
155
+
156
+ ## License
157
+
158
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,140 @@
1
+ [project]
2
+ name = "xtr-logging-contracts"
3
+ version = "1.0.0"
4
+ description = "The logging contract: one interface, eight severities, and a logger that discards everything."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ keywords = [
10
+ "logging",
11
+ "contracts",
12
+ "interface",
13
+ "protocol",
14
+ "structured-logging",
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Programming Language :: Python :: 3.14",
23
+ "Topic :: System :: Logging",
24
+ "Typing :: Typed",
25
+ ]
26
+ dependencies = ["typing-extensions>=4.4"]
27
+
28
+ [[project.authors]]
29
+ name = "Razvan Ceana"
30
+ email = "razvan@ceana.ro"
31
+
32
+ [dependency-groups]
33
+ dev = [
34
+ "basedpyright>=1.21",
35
+ "ruff>=0.8",
36
+ "pytest>=8",
37
+ "pytest-cov>=5",
38
+ "ty>=0.0.83",
39
+ ]
40
+
41
+ [build-system]
42
+ requires = ["uv_build>=0.9.18,<0.10.0"]
43
+ build-backend = "uv_build"
44
+
45
+ [tool.basedpyright]
46
+ typeCheckingMode = "all"
47
+ pythonVersion = "3.11"
48
+ pythonPlatform = "All"
49
+ include = [
50
+ "src",
51
+ "tests",
52
+ ]
53
+ exclude = [
54
+ "**/__pycache__",
55
+ "**/.venv",
56
+ "**/build",
57
+ "**/dist",
58
+ ".tmp",
59
+ ]
60
+ reportUnusedCallResult = "warning"
61
+ reportUnnecessaryTypeIgnoreComment = "error"
62
+ reportUnusedVariable = "error"
63
+ reportMissingParameterType = "error"
64
+ reportPrivateUsage = "error"
65
+
66
+ [tool.ruff]
67
+ target-version = "py311"
68
+ line-length = 100
69
+ src = [
70
+ "src",
71
+ "tests",
72
+ ]
73
+
74
+ [tool.ruff.lint]
75
+ select = ["ALL"]
76
+ ignore = [
77
+ "COM812",
78
+ "ISC001",
79
+ "D203",
80
+ "D213",
81
+ "CPY001",
82
+ "FBT001",
83
+ "FBT002",
84
+ "TD002",
85
+ "TD003",
86
+ "FIX002",
87
+ "TRY003",
88
+ "EM101",
89
+ "EM102",
90
+ "PLE1205",
91
+ "PLE1206",
92
+ ]
93
+ fixable = ["ALL"]
94
+ unfixable = []
95
+
96
+ [tool.ruff.lint.per-file-ignores]
97
+ "tests/**/*.py" = [
98
+ "S101",
99
+ "ARG",
100
+ "PLR2004",
101
+ "SLF001",
102
+ "D",
103
+ ]
104
+
105
+ [tool.ruff.lint.pydocstyle]
106
+ convention = "google"
107
+
108
+ [tool.ruff.format]
109
+ quote-style = "double"
110
+ indent-style = "space"
111
+ docstring-code-format = true
112
+ docstring-code-line-length = "dynamic"
113
+
114
+ [tool.ty.src]
115
+ include = [
116
+ "src",
117
+ "tests",
118
+ ]
119
+
120
+ [tool.pytest.ini_options]
121
+ minversion = "8.0"
122
+ testpaths = ["tests"]
123
+ addopts = [
124
+ "-ra",
125
+ "--strict-config",
126
+ "--strict-markers",
127
+ ]
128
+ filterwarnings = ["error"]
129
+
130
+ [tool.coverage.run]
131
+ source = ["src"]
132
+ branch = true
133
+
134
+ [tool.coverage.report]
135
+ exclude_lines = [
136
+ "pragma: no cover",
137
+ "if TYPE_CHECKING:",
138
+ "raise NotImplementedError",
139
+ '^\s*\.\.\.$',
140
+ ]
@@ -0,0 +1,139 @@
1
+ [project]
2
+ name = "xtr-logging-contracts"
3
+ version = "1.0.0"
4
+ description = "The logging contract: one interface, eight severities, and a logger that discards everything."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Razvan Ceana", email = "razvan@ceana.ro" }
8
+ ]
9
+ requires-python = ">=3.11"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ keywords = ["logging", "contracts", "interface", "protocol", "structured-logging"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Programming Language :: Python :: 3.14",
20
+ "Topic :: System :: Logging",
21
+ "Typing :: Typed",
22
+ ]
23
+ # One dependency, for `@override` on 3.11, where typing has none. A library
24
+ # that only logs should install nothing else.
25
+ dependencies = [
26
+ "typing-extensions>=4.4",
27
+ ]
28
+
29
+ [dependency-groups]
30
+ dev = [
31
+ "basedpyright>=1.21",
32
+ "ruff>=0.8",
33
+ "pytest>=8",
34
+ "pytest-cov>=5",
35
+ "ty>=0.0.83",
36
+ ]
37
+
38
+ [build-system]
39
+ requires = ["uv_build>=0.9.18,<0.10.0"]
40
+ build-backend = "uv_build"
41
+
42
+ # ─────────────────────────────────────────────────────────────────
43
+ # basedpyright — typeCheckingMode = "all" sets every report flag to error
44
+ # ─────────────────────────────────────────────────────────────────
45
+ [tool.basedpyright]
46
+ typeCheckingMode = "all"
47
+ pythonVersion = "3.11"
48
+ pythonPlatform = "All"
49
+ include = ["src", "tests"]
50
+ exclude = ["**/__pycache__", "**/.venv", "**/build", "**/dist", ".tmp"]
51
+
52
+ reportUnusedCallResult = "warning"
53
+ reportUnnecessaryTypeIgnoreComment = "error"
54
+ reportUnusedVariable = "error"
55
+ reportMissingParameterType = "error"
56
+ reportPrivateUsage = "error"
57
+
58
+ # ─────────────────────────────────────────────────────────────────
59
+ # ruff — select = ["ALL"], minimal justified ignores
60
+ # ─────────────────────────────────────────────────────────────────
61
+ [tool.ruff]
62
+ target-version = "py311"
63
+ line-length = 100
64
+ src = ["src", "tests"]
65
+
66
+ [tool.ruff.lint]
67
+ select = ["ALL"]
68
+ ignore = [
69
+ # Formatter conflicts (ruff documents these)
70
+ "COM812",
71
+ "ISC001",
72
+ # Mutually-exclusive docstring conventions; keep the modern one
73
+ "D203",
74
+ "D213",
75
+ # Project-specific noise
76
+ "CPY001",
77
+ "FBT001",
78
+ "FBT002",
79
+ "TD002",
80
+ "TD003",
81
+ "FIX002",
82
+ # Exceptions compose their own messages from typed fields; the string at a
83
+ # raise site is a data field. See xtr_logging_contracts/exception/.
84
+ "TRY003",
85
+ "EM101",
86
+ "EM102",
87
+ # A context mapping after the message is this interface's signature, not a stray
88
+ # %-format argument: these rules assume every `logger.info` is stdlib's.
89
+ "PLE1205",
90
+ "PLE1206",
91
+ ]
92
+ fixable = ["ALL"]
93
+ unfixable = []
94
+
95
+ [tool.ruff.lint.per-file-ignores]
96
+ "tests/**/*.py" = [
97
+ "S101", # assert is the point of pytest
98
+ "ARG", # fixtures look unused
99
+ "PLR2004", # magic numbers in test data
100
+ "SLF001", # tests reach into privates
101
+ "D", # test names are the docs
102
+ ]
103
+
104
+ [tool.ruff.lint.pydocstyle]
105
+ convention = "google"
106
+
107
+ [tool.ruff.format]
108
+ quote-style = "double"
109
+ indent-style = "space"
110
+ docstring-code-format = true
111
+ docstring-code-line-length = "dynamic"
112
+
113
+ # ─────────────────────────────────────────────────────────────────
114
+ # ty — a second opinion on the source
115
+ # ─────────────────────────────────────────────────────────────────
116
+ [tool.ty.src]
117
+ include = ["src", "tests"]
118
+
119
+ # ─────────────────────────────────────────────────────────────────
120
+ # pytest
121
+ # ─────────────────────────────────────────────────────────────────
122
+ [tool.pytest.ini_options]
123
+ minversion = "8.0"
124
+ testpaths = ["tests"]
125
+ addopts = ["-ra", "--strict-config", "--strict-markers"]
126
+ filterwarnings = ["error"]
127
+
128
+ [tool.coverage.run]
129
+ source = ["src"]
130
+ branch = true
131
+
132
+ [tool.coverage.report]
133
+ exclude_lines = [
134
+ "pragma: no cover",
135
+ "if TYPE_CHECKING:",
136
+ "raise NotImplementedError",
137
+ # A Protocol body states a contract; there is nothing there to run.
138
+ "^\\s*\\.\\.\\.$",
139
+ ]
@@ -0,0 +1,55 @@
1
+ """The logging contract: what code that logs depends on, and nothing more.
2
+
3
+ A library that logs should not decide where records go. It takes a
4
+ :class:`LoggerInterface`, defaults to a :class:`NullLogger`, and leaves
5
+ handlers, formatters and configuration to the application that wires it — so
6
+ depending on this package costs a library nothing but the contract.
7
+
8
+ What is here is only what appears in that contract: the interface itself, the
9
+ :class:`Context` and :class:`Level` its methods accept, and the two ways of
10
+ satisfying it — :class:`AbstractLogger` to implement it in one method, and
11
+ :class:`NullLogger` to implement it in none. Everything that *acts* on what was
12
+ logged — records, handlers, processors, formatters, configuration — is
13
+ ``xtr-logging``, which implements this contract and re-exports it, so the two
14
+ are never two different objects.
15
+
16
+ from xtr_logging_contracts import LoggerInterface, NullLogger
17
+
18
+
19
+ class Checkout:
20
+ def __init__(self, logger: LoggerInterface | None = None) -> None:
21
+ self._logger = logger or NullLogger()
22
+ """
23
+
24
+ from importlib.metadata import PackageNotFoundError, version
25
+
26
+ from .abstract_logger import AbstractLogger
27
+ from .context import EXCEPTION_KEY, Context
28
+ from .exception import InvalidLevelError, LoggingError
29
+ from .level import Level, LevelLike
30
+ from .logger_aware import LoggerAware
31
+ from .logger_aware_interface import LoggerAwareInterface
32
+ from .logger_interface import LoggerInterface
33
+ from .null_logger import NullLogger
34
+
35
+ try:
36
+ __version__ = version("xtr-logging-contracts")
37
+ except PackageNotFoundError: # pragma: no cover
38
+ # Running from a source tree or a vendored copy, with no installed
39
+ # metadata to read. Having no version is better than refusing to import.
40
+ __version__ = "0+unknown"
41
+
42
+ __all__ = [
43
+ "EXCEPTION_KEY",
44
+ "AbstractLogger",
45
+ "Context",
46
+ "InvalidLevelError",
47
+ "Level",
48
+ "LevelLike",
49
+ "LoggerAware",
50
+ "LoggerAwareInterface",
51
+ "LoggerInterface",
52
+ "LoggingError",
53
+ "NullLogger",
54
+ "__version__",
55
+ ]
@@ -0,0 +1,69 @@
1
+ """A logger whose eight severity methods all funnel into ``log``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import TYPE_CHECKING
7
+
8
+ from typing_extensions import override
9
+
10
+ from .level import Level
11
+ from .logger_interface import LoggerInterface
12
+
13
+ if TYPE_CHECKING:
14
+ from .context import Context
15
+ from .level import LevelLike
16
+
17
+ __all__ = ["AbstractLogger"]
18
+
19
+
20
+ class AbstractLogger(LoggerInterface, ABC):
21
+ """Implements every severity method in terms of :meth:`log`.
22
+
23
+ Subclass it and write ``log`` alone.
24
+ """
25
+
26
+ @abstractmethod
27
+ @override
28
+ def log(self, level: LevelLike, message: str, /, context: Context | None = None) -> None:
29
+ """Log ``message`` at ``level``."""
30
+
31
+ @override
32
+ def emergency(self, message: str, /, context: Context | None = None) -> None:
33
+ """Log at :attr:`Level.EMERGENCY`."""
34
+ self.log(Level.EMERGENCY, message, context)
35
+
36
+ @override
37
+ def alert(self, message: str, /, context: Context | None = None) -> None:
38
+ """Log at :attr:`Level.ALERT`."""
39
+ self.log(Level.ALERT, message, context)
40
+
41
+ @override
42
+ def critical(self, message: str, /, context: Context | None = None) -> None:
43
+ """Log at :attr:`Level.CRITICAL`."""
44
+ self.log(Level.CRITICAL, message, context)
45
+
46
+ @override
47
+ def error(self, message: str, /, context: Context | None = None) -> None:
48
+ """Log at :attr:`Level.ERROR`."""
49
+ self.log(Level.ERROR, message, context)
50
+
51
+ @override
52
+ def warning(self, message: str, /, context: Context | None = None) -> None:
53
+ """Log at :attr:`Level.WARNING`."""
54
+ self.log(Level.WARNING, message, context)
55
+
56
+ @override
57
+ def notice(self, message: str, /, context: Context | None = None) -> None:
58
+ """Log at :attr:`Level.NOTICE`."""
59
+ self.log(Level.NOTICE, message, context)
60
+
61
+ @override
62
+ def info(self, message: str, /, context: Context | None = None) -> None:
63
+ """Log at :attr:`Level.INFO`."""
64
+ self.log(Level.INFO, message, context)
65
+
66
+ @override
67
+ def debug(self, message: str, /, context: Context | None = None) -> None:
68
+ """Log at :attr:`Level.DEBUG`."""
69
+ self.log(Level.DEBUG, message, context)
@@ -0,0 +1,23 @@
1
+ """The structured data a caller attaches to what it logs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Final, TypeAlias
7
+
8
+ __all__ = ["EXCEPTION_KEY", "Context"]
9
+
10
+ Context: TypeAlias = Mapping[str, object]
11
+ """Structured data attached to what is logged.
12
+
13
+ Values are ``object`` because a caller may log anything; an implementation
14
+ normalises what it cannot serialise rather than refusing it.
15
+ """
16
+
17
+ EXCEPTION_KEY: Final = "exception"
18
+ """The context key reserved for an exception to report.
19
+
20
+ Part of the contract rather than of any implementation: a library puts the
21
+ exception it is reporting here, and whatever writes the record knows to look
22
+ for it.
23
+ """
@@ -0,0 +1,17 @@
1
+ """The errors the contract itself can raise.
2
+
3
+ Only two live here, because only two can be raised by the contract rather than
4
+ by an implementation: :class:`LoggingError`, the root every logging error
5
+ derives from — including every one ``xtr-logging`` adds — and
6
+ :class:`InvalidLevelError`, which :meth:`Level.parse` raises. Catching
7
+ :class:`LoggingError` therefore catches anything logging can go wrong with,
8
+ whichever package raised it.
9
+ """
10
+
11
+ from .invalid_level_error import InvalidLevelError
12
+ from .logging_error import LoggingError
13
+
14
+ __all__ = [
15
+ "InvalidLevelError",
16
+ "LoggingError",
17
+ ]
@@ -0,0 +1,26 @@
1
+ """A value names no log level."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .logging_error import LoggingError
6
+
7
+ __all__ = ["InvalidLevelError"]
8
+
9
+ _ACCEPTED = "a Level, a level name, a value from 100 to 600, or an RFC 5424 severity from 0 to 7"
10
+
11
+
12
+ class InvalidLevelError(LoggingError, ValueError):
13
+ """A value names no log level.
14
+
15
+ Also a :class:`ValueError`, which is what lets a configuration parser
16
+ report the field that held it.
17
+ """
18
+
19
+ value: int | str
20
+
21
+ def __init__(self, value: int | str) -> None:
22
+ """Record the value that could not be read as a level."""
23
+ self.value = value
24
+ super().__init__(
25
+ f"{value!r} is not a log level; expected {_ACCEPTED}",
26
+ )
@@ -0,0 +1,14 @@
1
+ """The root every error in this library derives from."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["LoggingError"]
6
+
7
+
8
+ class LoggingError(Exception):
9
+ """Base class for every error raised by this library.
10
+
11
+ Catch this to handle anything logging can go wrong with; catch a subclass
12
+ to handle one cause. Every subclass carries the data a caller needs as
13
+ typed attributes and composes its own message from them.
14
+ """
@@ -0,0 +1,108 @@
1
+ """The eight RFC 5424 severities, valued 100 to 600.
2
+
3
+ The values leave room between them, so a level added later still sorts where
4
+ it belongs, and they compare as integers: ``Level.ERROR > Level.WARNING``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from enum import IntEnum
10
+ from typing import TYPE_CHECKING, Final, TypeAlias
11
+
12
+ from .exception.invalid_level_error import InvalidLevelError
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Mapping
16
+
17
+ __all__ = ["Level", "LevelLike"]
18
+
19
+ _LOWEST_RFC5424: Final = 0
20
+ _HIGHEST_RFC5424: Final = 7
21
+
22
+
23
+ class Level(IntEnum):
24
+ """A log level, ordered from least to most severe."""
25
+
26
+ DEBUG = 100
27
+ INFO = 200
28
+ NOTICE = 250
29
+ WARNING = 300
30
+ ERROR = 400
31
+ CRITICAL = 500
32
+ ALERT = 550
33
+ EMERGENCY = 600
34
+
35
+ @classmethod
36
+ def parse(cls, value: LevelLike) -> Level:
37
+ """Read any accepted spelling of a level.
38
+
39
+ Accepts a :class:`Level`, its value (``400``), an RFC 5424 severity
40
+ (``3``), or a name in any case (``"error"``, ``"ERROR"``).
41
+
42
+ Raises:
43
+ InvalidLevelError: If ``value`` names no level.
44
+ """
45
+ match value:
46
+ case Level():
47
+ return value
48
+ case bool():
49
+ raise InvalidLevelError(value)
50
+ case int():
51
+ if _LOWEST_RFC5424 <= value <= _HIGHEST_RFC5424:
52
+ return _BY_RFC5424[value]
53
+ if value in _BY_VALUE:
54
+ return _BY_VALUE[value]
55
+ raise InvalidLevelError(value)
56
+ case str():
57
+ return cls.from_name(value)
58
+
59
+ @classmethod
60
+ def from_name(cls, name: str) -> Level:
61
+ """Return the level called ``name``, in any case.
62
+
63
+ Raises:
64
+ InvalidLevelError: If no level has that name.
65
+ """
66
+ found = cls.__members__.get(name.upper())
67
+ if found is None:
68
+ raise InvalidLevelError(name)
69
+ return found
70
+
71
+ @property
72
+ def lower_name(self) -> str:
73
+ """The name of this level in lower case, as configuration writes it."""
74
+ return self.name.lower()
75
+
76
+ @property
77
+ def rfc5424(self) -> int:
78
+ """The RFC 5424 severity: 0 for EMERGENCY through 7 for DEBUG."""
79
+ return _TO_RFC5424[self]
80
+
81
+ def includes(self, other: Level) -> bool:
82
+ """Whether a threshold of this level lets ``other`` through."""
83
+ return self <= other
84
+
85
+ def is_higher_than(self, other: Level) -> bool:
86
+ """Whether this level is strictly more severe than ``other``."""
87
+ return self > other
88
+
89
+ def is_lower_than(self, other: Level) -> bool:
90
+ """Whether this level is strictly less severe than ``other``."""
91
+ return self < other
92
+
93
+
94
+ LevelLike: TypeAlias = Level | int | str
95
+ """Anything :meth:`Level.parse` accepts."""
96
+
97
+ _TO_RFC5424: Final[Mapping[Level, int]] = {
98
+ Level.EMERGENCY: 0,
99
+ Level.ALERT: 1,
100
+ Level.CRITICAL: 2,
101
+ Level.ERROR: 3,
102
+ Level.WARNING: 4,
103
+ Level.NOTICE: 5,
104
+ Level.INFO: 6,
105
+ Level.DEBUG: 7,
106
+ }
107
+ _BY_RFC5424: Final[Mapping[int, Level]] = {rfc: level for level, rfc in _TO_RFC5424.items()}
108
+ _BY_VALUE: Final[Mapping[int, Level]] = {level.value: level for level in Level}
@@ -0,0 +1,36 @@
1
+ """A mixin giving a class a logger that is never missing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, ClassVar
6
+
7
+ from typing_extensions import override
8
+
9
+ from .logger_aware_interface import LoggerAwareInterface
10
+ from .null_logger import NullLogger
11
+
12
+ if TYPE_CHECKING:
13
+ from .logger_interface import LoggerInterface
14
+
15
+ __all__ = ["LoggerAware"]
16
+
17
+
18
+ class LoggerAware(LoggerAwareInterface):
19
+ """Holds a logger, defaulting to a :class:`NullLogger`.
20
+
21
+ Code in the class logs through :attr:`logger` without checking whether one
22
+ was ever set.
23
+ """
24
+
25
+ _default: ClassVar[LoggerInterface] = NullLogger()
26
+ _logger: LoggerInterface | None = None
27
+
28
+ @property
29
+ def logger(self) -> LoggerInterface:
30
+ """The logger set last, or one that discards everything."""
31
+ return self._logger if self._logger is not None else self._default
32
+
33
+ @override
34
+ def set_logger(self, logger: LoggerInterface, /) -> None:
35
+ """Log through ``logger`` from now on."""
36
+ self._logger = logger
@@ -0,0 +1,23 @@
1
+ """Something that can be handed a logger after it is built."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
6
+
7
+ if TYPE_CHECKING:
8
+ from .logger_interface import LoggerInterface
9
+
10
+ __all__ = ["LoggerAwareInterface"]
11
+
12
+
13
+ @runtime_checkable
14
+ class LoggerAwareInterface(Protocol):
15
+ """Accepts a logger through a setter.
16
+
17
+ Prefer a constructor argument. This exists for objects a framework builds
18
+ for you, where the constructor is not yours to extend.
19
+ """
20
+
21
+ def set_logger(self, logger: LoggerInterface, /) -> None:
22
+ """Log through ``logger`` from now on."""
23
+ ...
@@ -0,0 +1,75 @@
1
+ """The logging surface code writes through."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
6
+
7
+ if TYPE_CHECKING:
8
+ from .context import Context
9
+ from .level import LevelLike
10
+
11
+ __all__ = ["LoggerInterface"]
12
+
13
+
14
+ @runtime_checkable
15
+ class LoggerInterface(Protocol):
16
+ """Writes messages at one of eight severities, with structured context.
17
+
18
+ The only type code that logs should depend on. Whatever implements it —
19
+ a channel of ``xtr-logging``'s ``Logger``, a
20
+ :class:`~xtr_logging_contracts.null_logger.NullLogger`, an adapter over a
21
+ standard library logger — can be swapped without touching a call site.
22
+
23
+ The rules:
24
+
25
+ - ``message`` may hold ``{key}`` placeholders naming entries of
26
+ ``context``. The logger does not substitute them; something later in the
27
+ pipeline does — ``xtr-logging``'s ``PlaceholderProcessor`` — so a handler
28
+ may still see the template and the values apart.
29
+ - An exception to report goes under ``context["exception"]``.
30
+ - Context is data, never code: an implementation must not fail because of
31
+ what it holds.
32
+
33
+ ``message`` is positional-only, so an implementation may name it freely;
34
+ ``context`` may also be passed by keyword.
35
+ """
36
+
37
+ def emergency(self, message: str, /, context: Context | None = None) -> None:
38
+ """The system is unusable."""
39
+ ...
40
+
41
+ def alert(self, message: str, /, context: Context | None = None) -> None:
42
+ """Action must be taken immediately — the whole site is down, say."""
43
+ ...
44
+
45
+ def critical(self, message: str, /, context: Context | None = None) -> None:
46
+ """A critical condition, such as an unavailable component."""
47
+ ...
48
+
49
+ def error(self, message: str, /, context: Context | None = None) -> None:
50
+ """A runtime error that needs no immediate action but must be seen."""
51
+ ...
52
+
53
+ def warning(self, message: str, /, context: Context | None = None) -> None:
54
+ """Something exceptional that is not an error, like a deprecated call."""
55
+ ...
56
+
57
+ def notice(self, message: str, /, context: Context | None = None) -> None:
58
+ """A normal but significant event."""
59
+ ...
60
+
61
+ def info(self, message: str, /, context: Context | None = None) -> None:
62
+ """An interesting event, such as a user logging in."""
63
+ ...
64
+
65
+ def debug(self, message: str, /, context: Context | None = None) -> None:
66
+ """Detailed information for debugging."""
67
+ ...
68
+
69
+ def log(self, level: LevelLike, message: str, /, context: Context | None = None) -> None:
70
+ """Log at ``level``, given in any form :meth:`Level.parse` accepts.
71
+
72
+ Raises:
73
+ InvalidLevelError: If ``level`` names no level.
74
+ """
75
+ ...
@@ -0,0 +1,32 @@
1
+ """A logger that discards everything."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, final
6
+
7
+ from typing_extensions import override
8
+
9
+ from .abstract_logger import AbstractLogger
10
+
11
+ if TYPE_CHECKING:
12
+ from .context import Context
13
+ from .level import LevelLike
14
+
15
+ __all__ = ["NullLogger"]
16
+
17
+
18
+ @final
19
+ class NullLogger(AbstractLogger):
20
+ """Discards everything it is given.
21
+
22
+ The default for a library that accepts a logger: logging stays optional
23
+ for its callers, and the library's code never has to ask whether it has
24
+ one.
25
+ """
26
+
27
+ __slots__ = ()
28
+
29
+ @override
30
+ def log(self, level: LevelLike, message: str, /, context: Context | None = None) -> None:
31
+ """Do nothing — not even check ``level``, which costs a call site nothing."""
32
+ del level, message, context