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.
Files changed (69) hide show
  1. errortools-1.2.0/AUTHORS.txt +2 -0
  2. {errortools-1.0.0 → errortools-1.2.0}/LICENSE.txt +1 -1
  3. errortools-1.2.0/PKG-INFO +303 -0
  4. errortools-1.2.0/README.md +271 -0
  5. {errortools-1.0.0/_errortools/cached → errortools-1.2.0/_errortools}/cache.py +14 -15
  6. {errortools-1.0.0/_errortools → errortools-1.2.0/_errortools/classes}/abc.py +63 -45
  7. errortools-1.2.0/_errortools/classes/errorcodes.py +273 -0
  8. errortools-1.2.0/_errortools/classes/group.py +118 -0
  9. {errortools-1.0.0 → errortools-1.2.0}/_errortools/classes/warn.py +3 -3
  10. errortools-1.2.0/_errortools/cli.py +85 -0
  11. errortools-1.2.0/_errortools/future.py +23 -0
  12. errortools-1.2.0/_errortools/ignore.py +155 -0
  13. errortools-1.2.0/_errortools/logging/__init__.py +43 -0
  14. errortools-1.2.0/_errortools/logging/base.py +467 -0
  15. errortools-1.2.0/_errortools/logging/level.py +85 -0
  16. errortools-1.2.0/_errortools/logging/logger.py +13 -0
  17. errortools-1.2.0/_errortools/logging/record.py +109 -0
  18. errortools-1.2.0/_errortools/logging/sink.py +236 -0
  19. errortools-1.0.0/_errortools/_metadata.py → errortools-1.2.0/_errortools/metadata.py +1 -1
  20. {errortools-1.0.0 → errortools-1.2.0}/_errortools/raises.py +22 -22
  21. {errortools-1.0.0 → errortools-1.2.0}/_errortools/tools/_warps.py +5 -4
  22. errortools-1.2.0/_errortools/typing.py +91 -0
  23. errortools-1.0.0/_errortools/_version.py → errortools-1.2.0/_errortools/version.py +2 -2
  24. {errortools-1.0.0 → errortools-1.2.0}/errortools/__init__.py +56 -8
  25. errortools-1.2.0/errortools/__main__.py +4 -0
  26. errortools-1.2.0/errortools.egg-info/PKG-INFO +303 -0
  27. {errortools-1.0.0 → errortools-1.2.0}/errortools.egg-info/SOURCES.txt +19 -9
  28. errortools-1.2.0/errortools.egg-info/entry_points.txt +2 -0
  29. {errortools-1.0.0 → errortools-1.2.0}/setup.py +12 -11
  30. {errortools-1.0.0 → errortools-1.2.0}/tests/__init__.py +1 -1
  31. errortools-1.2.0/tests/run_tests.py +13 -0
  32. {errortools-1.0.0 → errortools-1.2.0}/tests/test_abc.py +67 -2
  33. {errortools-1.0.0 → errortools-1.2.0}/tests/test_cache.py +15 -35
  34. errortools-1.2.0/tests/test_descriptor.py +162 -0
  35. errortools-1.2.0/tests/test_errorcodes.py +395 -0
  36. {errortools-1.0.0 → errortools-1.2.0}/tests/test_groups.py +2 -2
  37. errortools-1.2.0/tests/test_ignore.py +314 -0
  38. errortools-1.2.0/tests/test_logging.py +676 -0
  39. {errortools-1.0.0 → errortools-1.2.0}/tests/test_mixins.py +4 -1
  40. errortools-1.2.0/tests/test_typing.py +150 -0
  41. errortools-1.0.0/PKG-INFO +0 -63
  42. errortools-1.0.0/README.md +0 -31
  43. errortools-1.0.0/_errortools/_types.py +0 -21
  44. errortools-1.0.0/_errortools/cached/__init__.py +0 -1
  45. errortools-1.0.0/_errortools/cached/wrapper.py +0 -76
  46. errortools-1.0.0/_errortools/classes/errorcodes.py +0 -148
  47. errortools-1.0.0/_errortools/groups.py +0 -8
  48. errortools-1.0.0/_errortools/ignore.py +0 -90
  49. errortools-1.0.0/_errortools/tools/error_msg.py +0 -15
  50. errortools-1.0.0/errortools.egg-info/PKG-INFO +0 -63
  51. errortools-1.0.0/tests/run_tests.py +0 -6
  52. errortools-1.0.0/tests/test_descriptor.py +0 -66
  53. errortools-1.0.0/tests/test_errorcodes.py +0 -150
  54. errortools-1.0.0/tests/test_ignore.py +0 -124
  55. {errortools-1.0.0 → errortools-1.2.0}/_errortools/__init__.py +0 -0
  56. {errortools-1.0.0 → errortools-1.2.0}/_errortools/classes/__init__.py +0 -0
  57. {errortools-1.0.0 → errortools-1.2.0}/_errortools/methods/__init__.py +0 -0
  58. {errortools-1.0.0 → errortools-1.2.0}/_errortools/methods/errorattr.py +0 -0
  59. {errortools-1.0.0 → errortools-1.2.0}/_errortools/methods/errordelattr.py +0 -0
  60. {errortools-1.0.0 → errortools-1.2.0}/_errortools/methods/errorhasattr.py +0 -0
  61. {errortools-1.0.0 → errortools-1.2.0}/_errortools/methods/errorsetattr.py +0 -0
  62. {errortools-1.0.0 → errortools-1.2.0}/_errortools/py.typed +0 -0
  63. {errortools-1.0.0 → errortools-1.2.0}/_errortools/tools/__init__.py +0 -0
  64. {errortools-1.0.0 → errortools-1.2.0}/errortools.egg-info/dependency_links.txt +0 -0
  65. {errortools-1.0.0 → errortools-1.2.0}/errortools.egg-info/top_level.txt +0 -0
  66. {errortools-1.0.0 → errortools-1.2.0}/setup.cfg +0 -0
  67. {errortools-1.0.0 → errortools-1.2.0}/tests/conftest.py +0 -0
  68. {errortools-1.0.0 → errortools-1.2.0}/tests/test_raises.py +0 -0
  69. {errortools-1.0.0 → errortools-1.2.0}/tests/test_warnings.py +0 -0
@@ -0,0 +1,2 @@
1
+ aiwonderland
2
+ qorexdev
@@ -1,4 +1,4 @@
1
- Copyright (c) 2026 aiwonderland
1
+ Copyright (c) 2026 authors see AUTHORS.txt
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining
4
4
  a copy of this software and associated documentation files (the
@@ -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, like lru_cache."""
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 _errortools.cached.wrapper import ErrorCacheWrapper
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 (like functools.lru_cache).
34
+ Decorator to cache exceptions raised by a function.
35
35
 
36
- This decorator automatically caches exceptions thrown by the wrapped function,
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 (same as lru_cache).
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")