errortools 1.0.0__tar.gz → 1.2.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.
- errortools-1.2.0/AUTHORS.txt +2 -0
- {errortools-1.0.0 → errortools-1.2.0}/LICENSE.txt +1 -1
- errortools-1.2.0/PKG-INFO +303 -0
- errortools-1.2.0/README.md +271 -0
- {errortools-1.0.0/_errortools/cached → errortools-1.2.0/_errortools}/cache.py +14 -15
- {errortools-1.0.0/_errortools → errortools-1.2.0/_errortools/classes}/abc.py +63 -45
- errortools-1.2.0/_errortools/classes/errorcodes.py +273 -0
- errortools-1.2.0/_errortools/classes/group.py +118 -0
- {errortools-1.0.0 → errortools-1.2.0}/_errortools/classes/warn.py +3 -3
- errortools-1.2.0/_errortools/cli.py +85 -0
- errortools-1.2.0/_errortools/future.py +23 -0
- errortools-1.2.0/_errortools/ignore.py +155 -0
- errortools-1.2.0/_errortools/logging/__init__.py +43 -0
- errortools-1.2.0/_errortools/logging/base.py +467 -0
- errortools-1.2.0/_errortools/logging/level.py +85 -0
- errortools-1.2.0/_errortools/logging/logger.py +13 -0
- errortools-1.2.0/_errortools/logging/record.py +109 -0
- errortools-1.2.0/_errortools/logging/sink.py +236 -0
- errortools-1.0.0/_errortools/_metadata.py → errortools-1.2.0/_errortools/metadata.py +1 -1
- {errortools-1.0.0 → errortools-1.2.0}/_errortools/raises.py +22 -22
- {errortools-1.0.0 → errortools-1.2.0}/_errortools/tools/_warps.py +5 -4
- errortools-1.2.0/_errortools/typing.py +91 -0
- errortools-1.0.0/_errortools/_version.py → errortools-1.2.0/_errortools/version.py +2 -2
- {errortools-1.0.0 → errortools-1.2.0}/errortools/__init__.py +56 -8
- errortools-1.2.0/errortools/__main__.py +4 -0
- errortools-1.2.0/errortools.egg-info/PKG-INFO +303 -0
- {errortools-1.0.0 → errortools-1.2.0}/errortools.egg-info/SOURCES.txt +19 -9
- errortools-1.2.0/errortools.egg-info/entry_points.txt +2 -0
- {errortools-1.0.0 → errortools-1.2.0}/setup.py +12 -11
- {errortools-1.0.0 → errortools-1.2.0}/tests/__init__.py +1 -1
- errortools-1.2.0/tests/run_tests.py +13 -0
- {errortools-1.0.0 → errortools-1.2.0}/tests/test_abc.py +67 -2
- {errortools-1.0.0 → errortools-1.2.0}/tests/test_cache.py +15 -35
- errortools-1.2.0/tests/test_descriptor.py +162 -0
- errortools-1.2.0/tests/test_errorcodes.py +395 -0
- {errortools-1.0.0 → errortools-1.2.0}/tests/test_groups.py +2 -2
- errortools-1.2.0/tests/test_ignore.py +314 -0
- errortools-1.2.0/tests/test_logging.py +676 -0
- {errortools-1.0.0 → errortools-1.2.0}/tests/test_mixins.py +4 -1
- errortools-1.2.0/tests/test_typing.py +150 -0
- errortools-1.0.0/PKG-INFO +0 -63
- errortools-1.0.0/README.md +0 -31
- errortools-1.0.0/_errortools/_types.py +0 -21
- errortools-1.0.0/_errortools/cached/__init__.py +0 -1
- errortools-1.0.0/_errortools/cached/wrapper.py +0 -76
- errortools-1.0.0/_errortools/classes/errorcodes.py +0 -148
- errortools-1.0.0/_errortools/groups.py +0 -8
- errortools-1.0.0/_errortools/ignore.py +0 -90
- errortools-1.0.0/_errortools/tools/error_msg.py +0 -15
- errortools-1.0.0/errortools.egg-info/PKG-INFO +0 -63
- errortools-1.0.0/tests/run_tests.py +0 -6
- errortools-1.0.0/tests/test_descriptor.py +0 -66
- errortools-1.0.0/tests/test_errorcodes.py +0 -150
- errortools-1.0.0/tests/test_ignore.py +0 -124
- {errortools-1.0.0 → errortools-1.2.0}/_errortools/__init__.py +0 -0
- {errortools-1.0.0 → errortools-1.2.0}/_errortools/classes/__init__.py +0 -0
- {errortools-1.0.0 → errortools-1.2.0}/_errortools/methods/__init__.py +0 -0
- {errortools-1.0.0 → errortools-1.2.0}/_errortools/methods/errorattr.py +0 -0
- {errortools-1.0.0 → errortools-1.2.0}/_errortools/methods/errordelattr.py +0 -0
- {errortools-1.0.0 → errortools-1.2.0}/_errortools/methods/errorhasattr.py +0 -0
- {errortools-1.0.0 → errortools-1.2.0}/_errortools/methods/errorsetattr.py +0 -0
- {errortools-1.0.0 → errortools-1.2.0}/_errortools/py.typed +0 -0
- {errortools-1.0.0 → errortools-1.2.0}/_errortools/tools/__init__.py +0 -0
- {errortools-1.0.0 → errortools-1.2.0}/errortools.egg-info/dependency_links.txt +0 -0
- {errortools-1.0.0 → errortools-1.2.0}/errortools.egg-info/top_level.txt +0 -0
- {errortools-1.0.0 → errortools-1.2.0}/setup.cfg +0 -0
- {errortools-1.0.0 → errortools-1.2.0}/tests/conftest.py +0 -0
- {errortools-1.0.0 → errortools-1.2.0}/tests/test_raises.py +0 -0
- {errortools-1.0.0 → errortools-1.2.0}/tests/test_warnings.py +0 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: errortools
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: errortools - a toolset for working with Python exceptions and warnings and logging.
|
|
5
|
+
Home-page: https://github.com/more-abc/errortools
|
|
6
|
+
Author: Evan Yang
|
|
7
|
+
Author-email: quantbit@126.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
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: Operating System :: OS Independent
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE.txt
|
|
21
|
+
License-File: AUTHORS.txt
|
|
22
|
+
Dynamic: author
|
|
23
|
+
Dynamic: author-email
|
|
24
|
+
Dynamic: classifier
|
|
25
|
+
Dynamic: description
|
|
26
|
+
Dynamic: description-content-type
|
|
27
|
+
Dynamic: home-page
|
|
28
|
+
Dynamic: license
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
Dynamic: requires-python
|
|
31
|
+
Dynamic: summary
|
|
32
|
+
|
|
33
|
+
# errortools
|
|
34
|
+
A lightweight Python exception handling utility library.
|
|
35
|
+
|
|
36
|
+
## Features
|
|
37
|
+
- **Raise Exceptions**: `raises()`, `raises_all()`, `reraise()` — batch raising and exception conversion
|
|
38
|
+
- **Catch & Suppress**: `ignore()`, `ignore_subclass()`, `ignore_warns()`, `fast_ignore()`, `super_fast_ignore()`, `timeout()` — graceful suppression of exceptions and warnings
|
|
39
|
+
- **Exception Caching**: `error_cache` — cache exceptions raised by functions (similar to `lru_cache`)
|
|
40
|
+
- **Custom Exceptions**: `PureBaseException`, `ContextException`, `BaseErrorCodes`, `BaseWarning` — structured exception classes with error codes, trace IDs, and context
|
|
41
|
+
- **Attribute Error Mixin**: Customize error behavior for attribute access, assignment, and deletion
|
|
42
|
+
- **Type Aliases**: `ExceptionType`, `AnyErrorCode`, `BaseErrorCodesType`, and more
|
|
43
|
+
- **Logging**: `logger` — loguru-inspired structured logger with leveled output, multiple sinks, context binding, and exception capture
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
```bash
|
|
47
|
+
pip install errortools
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Example
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
import warnings
|
|
56
|
+
from errortools import (
|
|
57
|
+
ignore, fast_ignore, ignore_subclass, ignore_warns, timeout,
|
|
58
|
+
reraise, raises, raises_all, assert_raises,
|
|
59
|
+
error_cache,
|
|
60
|
+
PureBaseException, ContextException, BaseErrorCodes, BaseWarning,
|
|
61
|
+
)
|
|
62
|
+
from errortools.future import super_fast_ignore
|
|
63
|
+
|
|
64
|
+
# ── 1. ignore ── context manager with full metadata ──────────────────────────
|
|
65
|
+
with ignore(KeyError) as err:
|
|
66
|
+
_ = {}["missing"]
|
|
67
|
+
|
|
68
|
+
assert err.be_ignore # True — was suppressed
|
|
69
|
+
assert err.name == "KeyError" # exception class name
|
|
70
|
+
assert err.count == 1 # how many times suppressed
|
|
71
|
+
assert err.exception # the original KeyError instance
|
|
72
|
+
assert err.traceback # full formatted traceback string
|
|
73
|
+
|
|
74
|
+
# ignore as a decorator
|
|
75
|
+
@ignore(ValueError, TypeError)
|
|
76
|
+
def parse(x: str) -> int:
|
|
77
|
+
return int(x)
|
|
78
|
+
|
|
79
|
+
parse("bad") # suppressed → returns None
|
|
80
|
+
|
|
81
|
+
# ── 2. fast_ignore / super_fast_ignore ── zero-overhead hot-path suppression ─
|
|
82
|
+
with fast_ignore(KeyError, IndexError):
|
|
83
|
+
_ = [][0] # suppressed, no metadata collected
|
|
84
|
+
|
|
85
|
+
with super_fast_ignore(KeyError):
|
|
86
|
+
_ = {}["x"] # absolute minimal overhead
|
|
87
|
+
|
|
88
|
+
# ── 3. ignore_subclass ── suppress any subclass of a base ────────────────────
|
|
89
|
+
with ignore_subclass(LookupError):
|
|
90
|
+
raise IndexError("out of range") # IndexError ⊆ LookupError — suppressed
|
|
91
|
+
|
|
92
|
+
# ── 4. ignore_warns ── silence warnings ──────────────────────────────────────
|
|
93
|
+
with ignore_warns(DeprecationWarning):
|
|
94
|
+
warnings.warn("old api", DeprecationWarning) # no output
|
|
95
|
+
|
|
96
|
+
with ignore_warns(): # suppress everything
|
|
97
|
+
warnings.warn("anything")
|
|
98
|
+
|
|
99
|
+
# ── 5. timeout ── cancel async functions that take too long ──────────────────
|
|
100
|
+
@timeout(5.0)
|
|
101
|
+
async def fetch_data(url: str) -> bytes:
|
|
102
|
+
... # any async operation
|
|
103
|
+
|
|
104
|
+
# asyncio.TimeoutError raised automatically if it exceeds 5 s
|
|
105
|
+
|
|
106
|
+
# ── 6. reraise ── convert exception types on the fly ─────────────────────────
|
|
107
|
+
with reraise(KeyError, ValueError):
|
|
108
|
+
raise KeyError("missing key") # → ValueError: 'missing key'
|
|
109
|
+
|
|
110
|
+
with reraise((KeyError, IndexError), RuntimeError):
|
|
111
|
+
_ = [][99] # → RuntimeError: list index out of range
|
|
112
|
+
|
|
113
|
+
# ── 7. raises / raises_all ── batch raise ────────────────────────────────────
|
|
114
|
+
raises([ValueError], ["bad input"]) # → ValueError: bad input
|
|
115
|
+
|
|
116
|
+
raises_all(
|
|
117
|
+
[ValueError, TypeError],
|
|
118
|
+
["bad input"],
|
|
119
|
+
) # → ExceptionGroup (2 sub-exceptions)
|
|
120
|
+
|
|
121
|
+
# ── 8. assert_raises ── assert a callable raises ─────────────────────────────
|
|
122
|
+
exc = assert_raises(int, [ValueError], "not-a-number")
|
|
123
|
+
print(exc) # invalid literal for int() with base 10: 'not-a-number'
|
|
124
|
+
|
|
125
|
+
# ── 9. error_cache ── cache exceptions by call arguments ─────────────────────
|
|
126
|
+
@error_cache(maxsize=64)
|
|
127
|
+
def load(user_id: int) -> dict:
|
|
128
|
+
if user_id < 0:
|
|
129
|
+
raise ValueError(f"invalid id: {user_id}")
|
|
130
|
+
return {"id": user_id}
|
|
131
|
+
|
|
132
|
+
with ignore(ValueError):
|
|
133
|
+
load(-1) # raises, exception cached for args (-1,)
|
|
134
|
+
|
|
135
|
+
print(load.cache_info()) # CacheInfo(hits=0, misses=1, maxsize=64, currsize=1)
|
|
136
|
+
load.clear_cache()
|
|
137
|
+
|
|
138
|
+
# ── 10. Custom exceptions — three layers ──────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
# Layer 1: PureBaseException — code + detail only
|
|
141
|
+
class AppError(PureBaseException):
|
|
142
|
+
code = 9000
|
|
143
|
+
default_detail = "Application error."
|
|
144
|
+
|
|
145
|
+
print(AppError()) # [9000] Application error.
|
|
146
|
+
print(repr(AppError())) # AppError(detail='Application error.', code=9000)
|
|
147
|
+
|
|
148
|
+
# Layer 2: ContextException — adds trace_id, context dict, exception chaining
|
|
149
|
+
class ServiceError(ContextException):
|
|
150
|
+
code = 9001
|
|
151
|
+
default_detail = "Service unavailable."
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
raise ConnectionError("db timeout")
|
|
155
|
+
except ConnectionError as cause:
|
|
156
|
+
err = (
|
|
157
|
+
ServiceError("downstream failed")
|
|
158
|
+
.with_context(service="postgres", retries=3)
|
|
159
|
+
.with_cause(cause)
|
|
160
|
+
)
|
|
161
|
+
print(err.trace_id) # 'a3f1c8...' — unique per instance
|
|
162
|
+
print(err.context) # {'service': 'postgres', 'retries': 3}
|
|
163
|
+
print(err.chain) # [{'type': 'ServiceError', 'code': 9001, ...}]
|
|
164
|
+
print(err.traceback) # compact stack trace joined by |
|
|
165
|
+
|
|
166
|
+
# Layer 3: BaseErrorCodes — predefined factory methods
|
|
167
|
+
raise BaseErrorCodes.invalid_input("username too short") # InvalidInputError [1001]
|
|
168
|
+
raise BaseErrorCodes.access_denied() # AccessDeniedError [2001]
|
|
169
|
+
raise BaseErrorCodes.not_found("user #42") # NotFoundError [3001]
|
|
170
|
+
raise BaseErrorCodes.runtime_failure("crash") # RuntimeFailure [4001]
|
|
171
|
+
raise BaseErrorCodes.timeout_failure() # TimeoutFailure [4002]
|
|
172
|
+
raise BaseErrorCodes.configuration_error("missing key") # ConfigurationError [5001]
|
|
173
|
+
|
|
174
|
+
# ── 11. BaseWarning ── structured warnings with factory methods ───────────────
|
|
175
|
+
class ExperimentalWarning(BaseWarning):
|
|
176
|
+
default_detail = "This feature is experimental."
|
|
177
|
+
|
|
178
|
+
ExperimentalWarning.emit() # uses default_detail
|
|
179
|
+
ExperimentalWarning.emit("use at your own risk") # custom message
|
|
180
|
+
|
|
181
|
+
BaseWarning.deprecated("use new_api() instead").emit() # DeprecatedWarning
|
|
182
|
+
BaseWarning.performance("O(n²) detected").emit() # PerformanceWarning
|
|
183
|
+
BaseWarning.resource("file handle leak").emit() # ResourceUsageWarning
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## Logging
|
|
189
|
+
|
|
190
|
+
`errortools.logging` is a loguru-inspired structured logger with no external dependencies.
|
|
191
|
+
|
|
192
|
+
### Quick start
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
from errortools.logging import logger
|
|
196
|
+
|
|
197
|
+
logger.info("Server started on port {}", 8080)
|
|
198
|
+
logger.warning("Disk at {pct:.1f}%", pct=92.5)
|
|
199
|
+
logger.success("All systems operational")
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Output (colourised in a terminal):
|
|
203
|
+
|
|
204
|
+
```
|
|
205
|
+
2026-01-01 12:00:00.123 | INFO | app:main:42 - Server started on port 8080
|
|
206
|
+
2026-01-01 12:00:00.124 | WARNING | app:main:43 - Disk at 92.5%
|
|
207
|
+
2026-01-01 12:00:00.125 | SUCCESS | app:main:44 - All systems operational
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Log levels
|
|
211
|
+
|
|
212
|
+
| Method | Level | No |
|
|
213
|
+
|---|---|---|
|
|
214
|
+
| `logger.trace()` | TRACE | 5 |
|
|
215
|
+
| `logger.debug()` | DEBUG | 10 |
|
|
216
|
+
| `logger.info()` | INFO | 20 |
|
|
217
|
+
| `logger.success()` | SUCCESS | 25 |
|
|
218
|
+
| `logger.warning()` | WARNING | 30 |
|
|
219
|
+
| `logger.error()` | ERROR | 40 |
|
|
220
|
+
| `logger.critical()` | CRITICAL | 50 |
|
|
221
|
+
|
|
222
|
+
### Sinks
|
|
223
|
+
|
|
224
|
+
Add and remove destinations at runtime. Each sink has its own level filter.
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
from errortools.logging import logger, Level
|
|
228
|
+
|
|
229
|
+
# stream (stderr by default, auto-detects TTY colour)
|
|
230
|
+
logger.add(sys.stdout, level="WARNING")
|
|
231
|
+
|
|
232
|
+
# file with rotation (bytes) and retention (number of old files to keep)
|
|
233
|
+
sid = logger.add("logs/app.log", rotation=10_485_760, retention=5)
|
|
234
|
+
|
|
235
|
+
# any callable
|
|
236
|
+
logger.add(print)
|
|
237
|
+
|
|
238
|
+
# remove by id, or pass no argument to remove all
|
|
239
|
+
logger.remove(sid)
|
|
240
|
+
logger.remove()
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Level filtering
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
logger.set_level("WARNING") # or Level.WARNING or numeric 30
|
|
247
|
+
logger.debug("dropped") # below threshold — not emitted
|
|
248
|
+
logger.warning("kept") # at threshold — emitted
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### Context binding
|
|
252
|
+
|
|
253
|
+
`bind()` returns a **new** logger that carries extra fields in every record. The original logger is unmodified.
|
|
254
|
+
|
|
255
|
+
```python
|
|
256
|
+
req_log = logger.bind(request_id="abc-123", user="alice")
|
|
257
|
+
req_log.info("Request received") # record.extra contains request_id and user
|
|
258
|
+
|
|
259
|
+
# Stacking
|
|
260
|
+
db_log = req_log.bind(db="postgres")
|
|
261
|
+
db_log.debug("Query OK") # extra: request_id, user, db
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### Exception capture
|
|
265
|
+
|
|
266
|
+
```python
|
|
267
|
+
# Attach the current traceback to any log call
|
|
268
|
+
try:
|
|
269
|
+
connect()
|
|
270
|
+
except ConnectionError:
|
|
271
|
+
logger.exception("DB connection failed") # logs at ERROR + traceback
|
|
272
|
+
|
|
273
|
+
# Equivalent long-hand
|
|
274
|
+
logger.opt(exception=True).error("DB connection failed")
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### catch() — auto-log and suppress
|
|
278
|
+
|
|
279
|
+
```python
|
|
280
|
+
# As a context manager
|
|
281
|
+
with logger.catch():
|
|
282
|
+
int("not a number") # logged at ERROR, then suppressed
|
|
283
|
+
|
|
284
|
+
# Re-raise after logging
|
|
285
|
+
with logger.catch(ConnectionError, reraise=True):
|
|
286
|
+
connect()
|
|
287
|
+
|
|
288
|
+
# As a decorator
|
|
289
|
+
@logger.catch(ValueError)
|
|
290
|
+
def parse(s: str) -> int:
|
|
291
|
+
return int(s)
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### Custom format string
|
|
295
|
+
|
|
296
|
+
```python
|
|
297
|
+
logger.add(
|
|
298
|
+
"debug.log",
|
|
299
|
+
fmt="{time} | {level} | {name}:{function}:{line} - {message}",
|
|
300
|
+
)
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Available placeholders: `{time}`, `{level}`, `{name}`, `{file}`, `{line}`, `{function}`, `{message}`.
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# errortools
|
|
2
|
+
A lightweight Python exception handling utility library.
|
|
3
|
+
|
|
4
|
+
## Features
|
|
5
|
+
- **Raise Exceptions**: `raises()`, `raises_all()`, `reraise()` — batch raising and exception conversion
|
|
6
|
+
- **Catch & Suppress**: `ignore()`, `ignore_subclass()`, `ignore_warns()`, `fast_ignore()`, `super_fast_ignore()`, `timeout()` — graceful suppression of exceptions and warnings
|
|
7
|
+
- **Exception Caching**: `error_cache` — cache exceptions raised by functions (similar to `lru_cache`)
|
|
8
|
+
- **Custom Exceptions**: `PureBaseException`, `ContextException`, `BaseErrorCodes`, `BaseWarning` — structured exception classes with error codes, trace IDs, and context
|
|
9
|
+
- **Attribute Error Mixin**: Customize error behavior for attribute access, assignment, and deletion
|
|
10
|
+
- **Type Aliases**: `ExceptionType`, `AnyErrorCode`, `BaseErrorCodesType`, and more
|
|
11
|
+
- **Logging**: `logger` — loguru-inspired structured logger with leveled output, multiple sinks, context binding, and exception capture
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
```bash
|
|
15
|
+
pip install errortools
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Example
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
import warnings
|
|
24
|
+
from errortools import (
|
|
25
|
+
ignore, fast_ignore, ignore_subclass, ignore_warns, timeout,
|
|
26
|
+
reraise, raises, raises_all, assert_raises,
|
|
27
|
+
error_cache,
|
|
28
|
+
PureBaseException, ContextException, BaseErrorCodes, BaseWarning,
|
|
29
|
+
)
|
|
30
|
+
from errortools.future import super_fast_ignore
|
|
31
|
+
|
|
32
|
+
# ── 1. ignore ── context manager with full metadata ──────────────────────────
|
|
33
|
+
with ignore(KeyError) as err:
|
|
34
|
+
_ = {}["missing"]
|
|
35
|
+
|
|
36
|
+
assert err.be_ignore # True — was suppressed
|
|
37
|
+
assert err.name == "KeyError" # exception class name
|
|
38
|
+
assert err.count == 1 # how many times suppressed
|
|
39
|
+
assert err.exception # the original KeyError instance
|
|
40
|
+
assert err.traceback # full formatted traceback string
|
|
41
|
+
|
|
42
|
+
# ignore as a decorator
|
|
43
|
+
@ignore(ValueError, TypeError)
|
|
44
|
+
def parse(x: str) -> int:
|
|
45
|
+
return int(x)
|
|
46
|
+
|
|
47
|
+
parse("bad") # suppressed → returns None
|
|
48
|
+
|
|
49
|
+
# ── 2. fast_ignore / super_fast_ignore ── zero-overhead hot-path suppression ─
|
|
50
|
+
with fast_ignore(KeyError, IndexError):
|
|
51
|
+
_ = [][0] # suppressed, no metadata collected
|
|
52
|
+
|
|
53
|
+
with super_fast_ignore(KeyError):
|
|
54
|
+
_ = {}["x"] # absolute minimal overhead
|
|
55
|
+
|
|
56
|
+
# ── 3. ignore_subclass ── suppress any subclass of a base ────────────────────
|
|
57
|
+
with ignore_subclass(LookupError):
|
|
58
|
+
raise IndexError("out of range") # IndexError ⊆ LookupError — suppressed
|
|
59
|
+
|
|
60
|
+
# ── 4. ignore_warns ── silence warnings ──────────────────────────────────────
|
|
61
|
+
with ignore_warns(DeprecationWarning):
|
|
62
|
+
warnings.warn("old api", DeprecationWarning) # no output
|
|
63
|
+
|
|
64
|
+
with ignore_warns(): # suppress everything
|
|
65
|
+
warnings.warn("anything")
|
|
66
|
+
|
|
67
|
+
# ── 5. timeout ── cancel async functions that take too long ──────────────────
|
|
68
|
+
@timeout(5.0)
|
|
69
|
+
async def fetch_data(url: str) -> bytes:
|
|
70
|
+
... # any async operation
|
|
71
|
+
|
|
72
|
+
# asyncio.TimeoutError raised automatically if it exceeds 5 s
|
|
73
|
+
|
|
74
|
+
# ── 6. reraise ── convert exception types on the fly ─────────────────────────
|
|
75
|
+
with reraise(KeyError, ValueError):
|
|
76
|
+
raise KeyError("missing key") # → ValueError: 'missing key'
|
|
77
|
+
|
|
78
|
+
with reraise((KeyError, IndexError), RuntimeError):
|
|
79
|
+
_ = [][99] # → RuntimeError: list index out of range
|
|
80
|
+
|
|
81
|
+
# ── 7. raises / raises_all ── batch raise ────────────────────────────────────
|
|
82
|
+
raises([ValueError], ["bad input"]) # → ValueError: bad input
|
|
83
|
+
|
|
84
|
+
raises_all(
|
|
85
|
+
[ValueError, TypeError],
|
|
86
|
+
["bad input"],
|
|
87
|
+
) # → ExceptionGroup (2 sub-exceptions)
|
|
88
|
+
|
|
89
|
+
# ── 8. assert_raises ── assert a callable raises ─────────────────────────────
|
|
90
|
+
exc = assert_raises(int, [ValueError], "not-a-number")
|
|
91
|
+
print(exc) # invalid literal for int() with base 10: 'not-a-number'
|
|
92
|
+
|
|
93
|
+
# ── 9. error_cache ── cache exceptions by call arguments ─────────────────────
|
|
94
|
+
@error_cache(maxsize=64)
|
|
95
|
+
def load(user_id: int) -> dict:
|
|
96
|
+
if user_id < 0:
|
|
97
|
+
raise ValueError(f"invalid id: {user_id}")
|
|
98
|
+
return {"id": user_id}
|
|
99
|
+
|
|
100
|
+
with ignore(ValueError):
|
|
101
|
+
load(-1) # raises, exception cached for args (-1,)
|
|
102
|
+
|
|
103
|
+
print(load.cache_info()) # CacheInfo(hits=0, misses=1, maxsize=64, currsize=1)
|
|
104
|
+
load.clear_cache()
|
|
105
|
+
|
|
106
|
+
# ── 10. Custom exceptions — three layers ──────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
# Layer 1: PureBaseException — code + detail only
|
|
109
|
+
class AppError(PureBaseException):
|
|
110
|
+
code = 9000
|
|
111
|
+
default_detail = "Application error."
|
|
112
|
+
|
|
113
|
+
print(AppError()) # [9000] Application error.
|
|
114
|
+
print(repr(AppError())) # AppError(detail='Application error.', code=9000)
|
|
115
|
+
|
|
116
|
+
# Layer 2: ContextException — adds trace_id, context dict, exception chaining
|
|
117
|
+
class ServiceError(ContextException):
|
|
118
|
+
code = 9001
|
|
119
|
+
default_detail = "Service unavailable."
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
raise ConnectionError("db timeout")
|
|
123
|
+
except ConnectionError as cause:
|
|
124
|
+
err = (
|
|
125
|
+
ServiceError("downstream failed")
|
|
126
|
+
.with_context(service="postgres", retries=3)
|
|
127
|
+
.with_cause(cause)
|
|
128
|
+
)
|
|
129
|
+
print(err.trace_id) # 'a3f1c8...' — unique per instance
|
|
130
|
+
print(err.context) # {'service': 'postgres', 'retries': 3}
|
|
131
|
+
print(err.chain) # [{'type': 'ServiceError', 'code': 9001, ...}]
|
|
132
|
+
print(err.traceback) # compact stack trace joined by |
|
|
133
|
+
|
|
134
|
+
# Layer 3: BaseErrorCodes — predefined factory methods
|
|
135
|
+
raise BaseErrorCodes.invalid_input("username too short") # InvalidInputError [1001]
|
|
136
|
+
raise BaseErrorCodes.access_denied() # AccessDeniedError [2001]
|
|
137
|
+
raise BaseErrorCodes.not_found("user #42") # NotFoundError [3001]
|
|
138
|
+
raise BaseErrorCodes.runtime_failure("crash") # RuntimeFailure [4001]
|
|
139
|
+
raise BaseErrorCodes.timeout_failure() # TimeoutFailure [4002]
|
|
140
|
+
raise BaseErrorCodes.configuration_error("missing key") # ConfigurationError [5001]
|
|
141
|
+
|
|
142
|
+
# ── 11. BaseWarning ── structured warnings with factory methods ───────────────
|
|
143
|
+
class ExperimentalWarning(BaseWarning):
|
|
144
|
+
default_detail = "This feature is experimental."
|
|
145
|
+
|
|
146
|
+
ExperimentalWarning.emit() # uses default_detail
|
|
147
|
+
ExperimentalWarning.emit("use at your own risk") # custom message
|
|
148
|
+
|
|
149
|
+
BaseWarning.deprecated("use new_api() instead").emit() # DeprecatedWarning
|
|
150
|
+
BaseWarning.performance("O(n²) detected").emit() # PerformanceWarning
|
|
151
|
+
BaseWarning.resource("file handle leak").emit() # ResourceUsageWarning
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Logging
|
|
157
|
+
|
|
158
|
+
`errortools.logging` is a loguru-inspired structured logger with no external dependencies.
|
|
159
|
+
|
|
160
|
+
### Quick start
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
from errortools.logging import logger
|
|
164
|
+
|
|
165
|
+
logger.info("Server started on port {}", 8080)
|
|
166
|
+
logger.warning("Disk at {pct:.1f}%", pct=92.5)
|
|
167
|
+
logger.success("All systems operational")
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Output (colourised in a terminal):
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
2026-01-01 12:00:00.123 | INFO | app:main:42 - Server started on port 8080
|
|
174
|
+
2026-01-01 12:00:00.124 | WARNING | app:main:43 - Disk at 92.5%
|
|
175
|
+
2026-01-01 12:00:00.125 | SUCCESS | app:main:44 - All systems operational
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Log levels
|
|
179
|
+
|
|
180
|
+
| Method | Level | No |
|
|
181
|
+
|---|---|---|
|
|
182
|
+
| `logger.trace()` | TRACE | 5 |
|
|
183
|
+
| `logger.debug()` | DEBUG | 10 |
|
|
184
|
+
| `logger.info()` | INFO | 20 |
|
|
185
|
+
| `logger.success()` | SUCCESS | 25 |
|
|
186
|
+
| `logger.warning()` | WARNING | 30 |
|
|
187
|
+
| `logger.error()` | ERROR | 40 |
|
|
188
|
+
| `logger.critical()` | CRITICAL | 50 |
|
|
189
|
+
|
|
190
|
+
### Sinks
|
|
191
|
+
|
|
192
|
+
Add and remove destinations at runtime. Each sink has its own level filter.
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
from errortools.logging import logger, Level
|
|
196
|
+
|
|
197
|
+
# stream (stderr by default, auto-detects TTY colour)
|
|
198
|
+
logger.add(sys.stdout, level="WARNING")
|
|
199
|
+
|
|
200
|
+
# file with rotation (bytes) and retention (number of old files to keep)
|
|
201
|
+
sid = logger.add("logs/app.log", rotation=10_485_760, retention=5)
|
|
202
|
+
|
|
203
|
+
# any callable
|
|
204
|
+
logger.add(print)
|
|
205
|
+
|
|
206
|
+
# remove by id, or pass no argument to remove all
|
|
207
|
+
logger.remove(sid)
|
|
208
|
+
logger.remove()
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Level filtering
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
logger.set_level("WARNING") # or Level.WARNING or numeric 30
|
|
215
|
+
logger.debug("dropped") # below threshold — not emitted
|
|
216
|
+
logger.warning("kept") # at threshold — emitted
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### Context binding
|
|
220
|
+
|
|
221
|
+
`bind()` returns a **new** logger that carries extra fields in every record. The original logger is unmodified.
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
req_log = logger.bind(request_id="abc-123", user="alice")
|
|
225
|
+
req_log.info("Request received") # record.extra contains request_id and user
|
|
226
|
+
|
|
227
|
+
# Stacking
|
|
228
|
+
db_log = req_log.bind(db="postgres")
|
|
229
|
+
db_log.debug("Query OK") # extra: request_id, user, db
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### Exception capture
|
|
233
|
+
|
|
234
|
+
```python
|
|
235
|
+
# Attach the current traceback to any log call
|
|
236
|
+
try:
|
|
237
|
+
connect()
|
|
238
|
+
except ConnectionError:
|
|
239
|
+
logger.exception("DB connection failed") # logs at ERROR + traceback
|
|
240
|
+
|
|
241
|
+
# Equivalent long-hand
|
|
242
|
+
logger.opt(exception=True).error("DB connection failed")
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### catch() — auto-log and suppress
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
# As a context manager
|
|
249
|
+
with logger.catch():
|
|
250
|
+
int("not a number") # logged at ERROR, then suppressed
|
|
251
|
+
|
|
252
|
+
# Re-raise after logging
|
|
253
|
+
with logger.catch(ConnectionError, reraise=True):
|
|
254
|
+
connect()
|
|
255
|
+
|
|
256
|
+
# As a decorator
|
|
257
|
+
@logger.catch(ValueError)
|
|
258
|
+
def parse(s: str) -> int:
|
|
259
|
+
return int(s)
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### Custom format string
|
|
263
|
+
|
|
264
|
+
```python
|
|
265
|
+
logger.add(
|
|
266
|
+
"debug.log",
|
|
267
|
+
fmt="{time} | {level} | {name}:{function}:{line} - {message}",
|
|
268
|
+
)
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Available placeholders: `{time}`, `{level}`, `{name}`, `{file}`, `{line}`, `{function}`, `{message}`.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""A helper tool for caching exceptions raised by functions
|
|
1
|
+
"""A helper tool for caching exceptions raised by functions."""
|
|
2
2
|
|
|
3
3
|
import functools
|
|
4
4
|
from typing import (
|
|
@@ -9,7 +9,7 @@ from typing import (
|
|
|
9
9
|
overload,
|
|
10
10
|
)
|
|
11
11
|
|
|
12
|
-
from
|
|
12
|
+
from .wrappers.cache import ErrorCacheWrapper
|
|
13
13
|
|
|
14
14
|
_T = TypeVar("_T", bound=Callable[..., Any])
|
|
15
15
|
|
|
@@ -31,19 +31,9 @@ def error_cache( # type: ignore
|
|
|
31
31
|
func: Optional[_T] = None, maxsize: Optional[int] = 128
|
|
32
32
|
) -> Any:
|
|
33
33
|
"""
|
|
34
|
-
Decorator to cache exceptions raised by a function
|
|
34
|
+
Decorator to cache exceptions raised by a function.
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
keyed by the function's arguments. If the function succeeds, the cached exception
|
|
38
|
-
(if any) for those arguments is removed.
|
|
39
|
-
|
|
40
|
-
Key features (aligned with lru_cache):
|
|
41
|
-
- maxsize: Maximum number of cached errors (None = unlimited, default=128)
|
|
42
|
-
- LRU eviction: Evicts least recently used entries when maxsize is reached
|
|
43
|
-
- cache_info(): Returns hits/misses/maxsize/currsize stats
|
|
44
|
-
- clear_cache(): Clears cache and resets statistics
|
|
45
|
-
|
|
46
|
-
Usage (same as lru_cache):
|
|
36
|
+
Usage:
|
|
47
37
|
|
|
48
38
|
@error_cache # Default maxsize=128
|
|
49
39
|
def risky_func(x: int) -> int: ...
|
|
@@ -65,9 +55,18 @@ def error_cache( # type: ignore
|
|
|
65
55
|
Wrapped function with error caching functionality.
|
|
66
56
|
|
|
67
57
|
Raises:
|
|
68
|
-
TypeError: If non-hashable arguments are passed
|
|
58
|
+
TypeError: If non-hashable arguments are passed.
|
|
69
59
|
"""
|
|
70
60
|
|
|
61
|
+
# NOTE: This decorator automatically caches exceptions thrown by the wrapped function,
|
|
62
|
+
# keyed by the function's arguments. If the function succeeds, the cached exception
|
|
63
|
+
# (if any) for those arguments is removed.
|
|
64
|
+
|
|
65
|
+
# Key features:
|
|
66
|
+
# - maxsize: Maximum number of cached errors (None = unlimited, default=128)
|
|
67
|
+
# - LRU eviction: Evicts least recently used entries when maxsize is reached
|
|
68
|
+
# - cache_info(): Returns hits/misses/maxsize/currsize stats
|
|
69
|
+
# - clear_cache(): Clears cache and resets statistics
|
|
71
70
|
def decorator(f: _T) -> ErrorCacheWrapper[_T]:
|
|
72
71
|
if not callable(f):
|
|
73
72
|
raise TypeError(f"Expected a callable, got {type(f).__name__} instead")
|