python-corekit 0.4.0__py3-none-any.whl → 0.5.0__py3-none-any.whl

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 (37) hide show
  1. corekit/connections/sql/connection.py +6 -2
  2. corekit/exceptionator/__init__.py +68 -0
  3. corekit/exceptionator/actions.py +46 -0
  4. corekit/exceptionator/asgi.py +115 -0
  5. corekit/exceptionator/constants.py +7 -0
  6. corekit/exceptionator/exceptionator.py +62 -0
  7. corekit/exceptionator/fingerprint.py +86 -0
  8. corekit/exceptionator/guard.py +53 -0
  9. corekit/exceptionator/jobs.py +52 -0
  10. corekit/exceptionator/models.py +98 -0
  11. corekit/html/__init__.py +22 -0
  12. corekit/html/client.py +22 -0
  13. corekit/html/models.py +20 -0
  14. corekit/html/parser.py +272 -0
  15. corekit/html/scraper.py +127 -0
  16. corekit/html/settings.py +197 -0
  17. corekit/html/state.py +56 -0
  18. corekit/http/__init__.py +2 -1
  19. corekit/http/api.py +71 -3
  20. corekit/http/client.py +24 -54
  21. corekit/http/response.py +38 -5
  22. corekit/jobs/runner.py +22 -2
  23. corekit/llm/client.py +3 -2
  24. corekit/llm/tools/loop.py +5 -2
  25. corekit/llm/wire.py +11 -5
  26. corekit/schemas/__init__.py +8 -1
  27. corekit/schemas/models/arbitrary.py +49 -5
  28. corekit/utils/__init__.py +50 -8
  29. corekit/utils/coercion.py +45 -59
  30. corekit/utils/collections.py +136 -3
  31. corekit/utils/parsers.py +205 -0
  32. corekit/utils/text.py +21 -2
  33. {python_corekit-0.4.0.dist-info → python_corekit-0.5.0.dist-info}/METADATA +31 -7
  34. {python_corekit-0.4.0.dist-info → python_corekit-0.5.0.dist-info}/RECORD +37 -20
  35. {python_corekit-0.4.0.dist-info → python_corekit-0.5.0.dist-info}/WHEEL +0 -0
  36. {python_corekit-0.4.0.dist-info → python_corekit-0.5.0.dist-info}/licenses/LICENSE +0 -0
  37. {python_corekit-0.4.0.dist-info → python_corekit-0.5.0.dist-info}/top_level.txt +0 -0
corekit/utils/coercion.py CHANGED
@@ -6,17 +6,36 @@ empty value rather than raising. Use them at boundaries where the input is
6
6
  genuinely unknown -- an API payload, a config value, a scraped field -- and a
7
7
  crash would be worse than a default.
8
8
 
9
+ These are thin wrappers around ``corekit.utils.parsers``. Extraction logic lives
10
+ there once; here we only fix the fallback to the type's empty value so the
11
+ return type is guaranteed.
12
+
9
13
  The tradeoff is real: ``safe_int("banana")`` is ``0``, indistinguishable from a
10
- genuine zero. Where telling those apart matters, parse explicitly instead.
14
+ genuine zero. Where telling those apart matters, call the parser directly and
15
+ inspect ``ParsedValue.success``, or pass a sentinel ``default``.
11
16
  """
12
17
 
13
- import json
14
- from datetime import date, datetime
15
18
  from typing import Any
16
19
 
17
- from corekit.utils.text import list_to_english
18
-
19
- __all__ = ["safe_dict", "safe_float", "safe_int", "safe_list", "safe_string", "safe_tuple"]
20
+ from corekit.utils.parsers import (
21
+ parse_dict,
22
+ parse_float,
23
+ parse_int,
24
+ parse_list,
25
+ parse_set,
26
+ parse_string,
27
+ parse_tuple,
28
+ )
29
+
30
+ __all__ = [
31
+ "safe_dict",
32
+ "safe_float",
33
+ "safe_int",
34
+ "safe_list",
35
+ "safe_set",
36
+ "safe_string",
37
+ "safe_tuple",
38
+ ]
20
39
 
21
40
 
22
41
  def safe_string(item: Any, pretty: bool = False) -> str:
@@ -27,54 +46,21 @@ def safe_string(item: Any, pretty: bool = False) -> str:
27
46
  list instead -- ``"a, b, c"``, or ``"key: value, ..."`` for a mapping --
28
47
  which reads better in a notification than raw JSON.
29
48
  """
30
- if isinstance(item, bytes):
31
- item = item.decode("utf-8", errors="ignore")
32
- if isinstance(item, str):
33
- return item.strip()
34
- # bool is a subclass of int, so both land here and render as "True"/"1".
35
- if isinstance(item, (int, float)):
36
- return str(item)
37
- if isinstance(item, (list, tuple, set, dict)):
38
- if pretty:
39
- if isinstance(item, dict):
40
- return list_to_english([f"{key}: {value}" for key, value in item.items()])
41
- return list_to_english([safe_string(element) for element in item])
42
- return json.dumps(item)
43
- if isinstance(item, (date, datetime)):
44
- return item.isoformat()
45
- return ""
49
+ return parse_string(item, pretty=pretty, default="").value
46
50
 
47
51
 
48
52
  def safe_int(item: Any) -> int:
49
53
  """
50
54
  Convert anything to an int, or ``0`` if it will not convert.
51
55
  """
52
- if isinstance(item, int):
53
- return item
54
- if isinstance(item, bytes):
55
- item = item.decode("utf-8", errors="ignore")
56
- if isinstance(item, (float, str)):
57
- try:
58
- return int(item)
59
- except ValueError:
60
- return 0
61
- return 0
56
+ return parse_int(item, default=0).value
62
57
 
63
58
 
64
59
  def safe_float(item: Any) -> float:
65
60
  """
66
61
  Convert anything to a float, or ``0.0`` if it will not convert.
67
62
  """
68
- if isinstance(item, float):
69
- return item
70
- if isinstance(item, bytes):
71
- item = item.decode("utf-8", errors="ignore")
72
- if isinstance(item, (int, str)):
73
- try:
74
- return float(item)
75
- except ValueError:
76
- return 0.0
77
- return 0.0
63
+ return parse_float(item, default=0.0).value
78
64
 
79
65
 
80
66
  def safe_list(item: Any) -> list[Any]:
@@ -84,13 +70,7 @@ def safe_list(item: Any) -> list[Any]:
84
70
  A mapping yields its values. A string is *not* treated as a collection --
85
71
  exploding it into characters is almost never what the caller meant.
86
72
  """
87
- if isinstance(item, list):
88
- return item
89
- if isinstance(item, (tuple, set)):
90
- return list(item)
91
- if isinstance(item, dict):
92
- return list(item.values())
93
- return []
73
+ return parse_list(item, default=[]).value
94
74
 
95
75
 
96
76
  def safe_tuple(item: Any) -> tuple[Any, ...]:
@@ -100,19 +80,25 @@ def safe_tuple(item: Any) -> tuple[Any, ...]:
100
80
  A mapping yields its values. A string is *not* treated as a collection --
101
81
  exploding it into characters is almost never what the caller meant.
102
82
  """
103
- if isinstance(item, tuple):
104
- return item
105
- if isinstance(item, (list, set)):
106
- return tuple(item)
107
- if isinstance(item, dict):
108
- return tuple(item.values())
109
- return ()
83
+ return parse_tuple(item, default=()).value
84
+
85
+
86
+ def safe_set(item: Any) -> set[Any]:
87
+ """
88
+ Convert a collection to a set, or ``set()`` for anything else.
89
+
90
+ A mapping yields its values. A string is *not* treated as a collection --
91
+ exploding it into characters is almost never what the caller meant.
92
+ Unhashable elements yield ``set()``.
93
+ """
94
+ return parse_set(item, default=set()).value
110
95
 
111
96
 
112
97
  def safe_dict(item: Any) -> dict[str, Any]:
113
98
  """
114
99
  Return the mapping unchanged, or ``{}`` for anything else.
100
+
101
+ Also accepts objects that expose ``to_dict``, ``model_dump``, or ``dict``,
102
+ and falls back to ``__dict__`` when present.
115
103
  """
116
- if isinstance(item, dict):
117
- return item
118
- return {}
104
+ return parse_dict(item, default={}).value
@@ -7,10 +7,29 @@ once. Built for JSON from somewhere you do not control, where any level may be
7
7
  missing or the wrong type.
8
8
  """
9
9
 
10
- from copy import deepcopy
11
- from typing import Any, Callable
10
+ from __future__ import annotations
12
11
 
13
- __all__ = ["MultiMatch", "attr_or_key", "keygetter", "repeated_get", "split_list"]
12
+ import inspect
13
+ from collections.abc import AsyncIterable, AsyncIterator, Iterable, Mapping
14
+ from copy import deepcopy
15
+ from typing import Any, Awaitable, Callable, Iterator, Literal
16
+
17
+ __all__ = [
18
+ "MultiMatch",
19
+ "aiterator",
20
+ "attr_or_key",
21
+ "iterator",
22
+ "keygetter",
23
+ "kv_string",
24
+ "repeated_get",
25
+ "split_list",
26
+ ]
27
+
28
+ WhereSeq = Callable[[Any], bool]
29
+ WhereMap = Callable[[Any, Any], bool]
30
+ AsyncWhereSeq = Callable[[Any], bool | Awaitable[bool]]
31
+ AsyncWhereMap = Callable[[Any, Any], bool | Awaitable[bool]]
32
+ IteratorMode = Literal["keys", "values", "items"]
14
33
 
15
34
  KEY_SEPARATOR = "."
16
35
 
@@ -40,6 +59,120 @@ def attr_or_key(obj: Any, name: str) -> Any:
40
59
  return getattr(obj, name, None)
41
60
 
42
61
 
62
+ def _mapping_item(key: Any, value: Any, mode: IteratorMode) -> Any:
63
+ if mode == "values":
64
+ return value
65
+ if mode == "items":
66
+ return key, value
67
+ return key
68
+
69
+
70
+ def iterator(
71
+ obj: Iterable[Any] | Mapping[Any, Any] | None = None,
72
+ *,
73
+ mode: IteratorMode = "keys",
74
+ where: WhereSeq | WhereMap | None = None,
75
+ compact: bool = False,
76
+ ) -> Iterator[Any]:
77
+ """
78
+ Iterate an iterable or mapping, optionally filtered.
79
+
80
+ Non-mappings ignore ``mode`` and yield elements. Mappings yield keys,
81
+ values, or items according to ``mode`` (default ``keys``).
82
+
83
+ ``compact=True`` skips ``None`` items, or mapping entries whose **value**
84
+ is ``None``. ``where`` is a sync predicate: one argument for iterables,
85
+ ``(key, value)`` for mappings — the entry is filtered, then ``mode``
86
+ selects what is yielded.
87
+
88
+ ``None`` yields nothing.
89
+ """
90
+ if obj is None:
91
+ return
92
+
93
+ if isinstance(obj, Mapping):
94
+ for key, value in obj.items():
95
+ if compact and value is None:
96
+ continue
97
+ if where is not None and not where(key, value): # type: ignore[call-arg, arg-type]
98
+ continue
99
+ yield _mapping_item(key, value, mode)
100
+ return
101
+
102
+ for item in obj:
103
+ if compact and item is None:
104
+ continue
105
+ if where is not None and not where(item): # type: ignore[call-arg, arg-type]
106
+ continue
107
+ yield item
108
+
109
+
110
+ async def aiterator(
111
+ obj: AsyncIterable[Any] | Iterable[Any] | Mapping[Any, Any] | None = None,
112
+ *,
113
+ mode: IteratorMode = "keys",
114
+ where: AsyncWhereSeq | AsyncWhereMap | None = None,
115
+ compact: bool = False,
116
+ ) -> AsyncIterator[Any]:
117
+ """
118
+ Async counterpart to ``iterator``.
119
+
120
+ Accepts async iterables, sync iterables, and mappings. ``where`` may be
121
+ sync or async; mapping predicates still receive ``(key, value)``. Use with
122
+ ``async for``.
123
+ """
124
+ if obj is None:
125
+ return
126
+
127
+ async def _allow_mapping(_key: Any, _value: Any) -> bool:
128
+ if where is None:
129
+ return True
130
+ result = where(_key, _value) # type: ignore[call-arg, arg-type]
131
+ if inspect.isawaitable(result):
132
+ result = await result
133
+ return bool(result)
134
+
135
+ async def _allow_item(_item: Any) -> bool:
136
+ if where is None:
137
+ return True
138
+ result = where(_item) # type: ignore[call-arg, arg-type]
139
+ if inspect.isawaitable(result):
140
+ result = await result
141
+ return bool(result)
142
+
143
+ if isinstance(obj, Mapping):
144
+ for key, value in obj.items():
145
+ if compact and value is None:
146
+ continue
147
+ if not await _allow_mapping(key, value):
148
+ continue
149
+ yield _mapping_item(key, value, mode)
150
+ return
151
+
152
+ if isinstance(obj, AsyncIterable):
153
+ async for item in obj:
154
+ if compact and item is None:
155
+ continue
156
+ if not await _allow_item(item):
157
+ continue
158
+ yield item
159
+ return
160
+
161
+ for item in obj:
162
+ if compact and item is None:
163
+ continue
164
+ if not await _allow_item(item):
165
+ continue
166
+ yield item
167
+
168
+
169
+ def kv_string(key: str, value: Any, sep: str = ": ", prefix: str = "") -> str:
170
+ """
171
+ Format a key-value pair into a string.
172
+ """
173
+ return f"{prefix}{key}{sep}{value}"
174
+
175
+
43
176
  class MultiMatch(dict[str, Any]):
44
177
  """
45
178
  The result of a path step that matched more than one key.
@@ -0,0 +1,205 @@
1
+ """
2
+ Flexible parsers with caller-controlled fallbacks.
3
+
4
+ Each helper attempts to extract the target type and returns a ``ParsedValue``
5
+ carrying both the result and a ``success`` flag. On failure the ``value`` is
6
+ whatever ``default`` the caller supplied (``None`` unless overridden).
7
+
8
+ These are the extraction layer. For a guaranteed target type with that type's
9
+ empty value as the fallback, use the ``safe_*`` wrappers in
10
+ ``corekit.utils.coercion``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from datetime import date, datetime
17
+ from typing import Any, NamedTuple
18
+
19
+ from corekit.utils.text import list_to_english
20
+
21
+ __all__ = [
22
+ "ParsedValue",
23
+ "parse_dict",
24
+ "parse_float",
25
+ "parse_int",
26
+ "parse_list",
27
+ "parse_set",
28
+ "parse_string",
29
+ "parse_tuple",
30
+ ]
31
+
32
+
33
+ class ParsedValue(NamedTuple):
34
+ """Outcome of a parse attempt."""
35
+
36
+ value: Any
37
+ success: bool
38
+
39
+ @classmethod
40
+ def ok(cls, value: Any) -> ParsedValue:
41
+ return cls(value=value, success=True)
42
+
43
+ @classmethod
44
+ def error(cls, value: Any) -> ParsedValue:
45
+ return cls(value=value, success=False)
46
+
47
+
48
+ def parse_string(
49
+ item: Any,
50
+ *,
51
+ pretty: bool = False,
52
+ strip: bool = True,
53
+ default: Any = None,
54
+ ) -> ParsedValue:
55
+ """
56
+ Extract a string rendering of ``item``, or ``default`` if there is none.
57
+
58
+ Collections become JSON by default. With ``pretty`` they become an English
59
+ list instead -- ``"a, b, c"``, or ``"key: value, ..."`` for a mapping --
60
+ which reads better in a notification than raw JSON.
61
+ """
62
+
63
+ def _clean(text: str) -> str:
64
+ return text.strip() if strip else text
65
+
66
+ def _parse_collection(collection: Any) -> str:
67
+ if pretty:
68
+ if isinstance(collection, dict):
69
+ return list_to_english([f"{key}: {value}" for key, value in collection.items()])
70
+ return list_to_english(
71
+ [parse_string(element, pretty=pretty, strip=strip, default="").value for element in collection]
72
+ )
73
+ return json.dumps(collection)
74
+
75
+ if isinstance(item, bytes):
76
+ item = item.decode("utf-8", errors="ignore")
77
+ if isinstance(item, str):
78
+ return ParsedValue.ok(_clean(item))
79
+ # bool is a subclass of int, so both land here and render as "True"/"1".
80
+ if isinstance(item, (int, float)):
81
+ return ParsedValue.ok(str(item))
82
+ if isinstance(item, (list, tuple, set, dict)):
83
+ return ParsedValue.ok(_parse_collection(item))
84
+ if isinstance(item, (date, datetime)):
85
+ return ParsedValue.ok(item.isoformat())
86
+ return ParsedValue.error(default)
87
+
88
+
89
+ def parse_int(item: Any, *, default: Any = None) -> ParsedValue:
90
+ """
91
+ Extract an int from ``item``, or ``default`` if it will not convert.
92
+ """
93
+ if isinstance(item, bool):
94
+ return ParsedValue.ok(int(item))
95
+ if isinstance(item, int):
96
+ return ParsedValue.ok(item)
97
+ if isinstance(item, bytes):
98
+ item = item.decode("utf-8", errors="ignore")
99
+ if isinstance(item, (float, str)):
100
+ try:
101
+ return ParsedValue.ok(int(item))
102
+ except (TypeError, ValueError):
103
+ return ParsedValue.error(default)
104
+ return ParsedValue.error(default)
105
+
106
+
107
+ def parse_float(item: Any, *, default: Any = None) -> ParsedValue:
108
+ """
109
+ Extract a float from ``item``, or ``default`` if it will not convert.
110
+ """
111
+ if isinstance(item, bool):
112
+ return ParsedValue.ok(float(item))
113
+ if isinstance(item, float):
114
+ return ParsedValue.ok(item)
115
+ if isinstance(item, bytes):
116
+ item = item.decode("utf-8", errors="ignore")
117
+ if isinstance(item, (int, str)):
118
+ try:
119
+ return ParsedValue.ok(float(item))
120
+ except (TypeError, ValueError):
121
+ return ParsedValue.error(default)
122
+ return ParsedValue.error(default)
123
+
124
+
125
+ def parse_list(item: Any, *, default: Any = None) -> ParsedValue:
126
+ """
127
+ Extract a list from a collection, or ``default`` for anything else.
128
+
129
+ A mapping yields its values. A string is *not* treated as a collection --
130
+ exploding it into characters is almost never what the caller meant.
131
+ """
132
+ if isinstance(item, list):
133
+ return ParsedValue.ok(item)
134
+ if isinstance(item, (tuple, set)):
135
+ return ParsedValue.ok(list(item))
136
+ if isinstance(item, dict):
137
+ return ParsedValue.ok(list(item.values()))
138
+ return ParsedValue.error(default)
139
+
140
+
141
+ def parse_tuple(item: Any, *, default: Any = None) -> ParsedValue:
142
+ """
143
+ Extract a tuple from a collection, or ``default`` for anything else.
144
+
145
+ A mapping yields its values. A string is *not* treated as a collection --
146
+ exploding it into characters is almost never what the caller meant.
147
+ """
148
+ if isinstance(item, tuple):
149
+ return ParsedValue.ok(item)
150
+ if isinstance(item, (list, set)):
151
+ return ParsedValue.ok(tuple(item))
152
+ if isinstance(item, dict):
153
+ return ParsedValue.ok(tuple(item.values()))
154
+ return ParsedValue.error(default)
155
+
156
+
157
+ def parse_set(item: Any, *, default: Any = None) -> ParsedValue:
158
+ """
159
+ Extract a set from a collection, or ``default`` for anything else.
160
+
161
+ A mapping yields its values. A string is *not* treated as a collection --
162
+ exploding it into characters is almost never what the caller meant.
163
+ Unhashable elements fail the conversion and yield ``default``.
164
+ """
165
+ if isinstance(item, set):
166
+ return ParsedValue.ok(item)
167
+ if isinstance(item, (list, tuple)):
168
+ try:
169
+ return ParsedValue.ok(set(item))
170
+ except TypeError:
171
+ return ParsedValue.error(default)
172
+ if isinstance(item, dict):
173
+ try:
174
+ return ParsedValue.ok(set(item.values()))
175
+ except TypeError:
176
+ return ParsedValue.error(default)
177
+ return ParsedValue.error(default)
178
+
179
+
180
+ def parse_dict(item: Any, *, default: Any = None) -> ParsedValue:
181
+ """
182
+ Extract a mapping from ``item``, or ``default`` if none is available.
183
+
184
+ Accepts a ``dict`` directly, then common dump protocols (``to_dict``,
185
+ ``model_dump``, ``dict``), then a plain ``__dict__``.
186
+ """
187
+ if isinstance(item, dict):
188
+ return ParsedValue.ok(item)
189
+
190
+ for method_name in ("to_dict", "model_dump", "dict"):
191
+ method = getattr(item, method_name, None)
192
+ if not callable(method):
193
+ continue
194
+ try:
195
+ result = method()
196
+ except Exception:
197
+ continue
198
+ if isinstance(result, dict):
199
+ return ParsedValue.ok(result)
200
+
201
+ namespace = getattr(item, "__dict__", None)
202
+ if isinstance(namespace, dict):
203
+ return ParsedValue.ok(namespace)
204
+
205
+ return ParsedValue.error(default)
corekit/utils/text.py CHANGED
@@ -2,13 +2,14 @@
2
2
  String assembly helpers.
3
3
 
4
4
  Small formatters for turning collections into text a human will read -- log
5
- lines, notification bodies, error messages -- plus filename sanitization.
5
+ lines, notification bodies, error messages -- plus filename sanitization and
6
+ length limits.
6
7
  """
7
8
 
8
9
  import re
9
10
  from typing import Any, Literal
10
11
 
11
- __all__ = ["join_lines", "list_to_english", "sanitize_filename"]
12
+ __all__ = ["join_lines", "list_to_english", "sanitize_filename", "truncate"]
12
13
 
13
14
  FALLBACK_FILENAME = "unnamed"
14
15
  _UNSAFE_CHARS = re.compile(r"[^a-z0-9\-]")
@@ -43,6 +44,24 @@ def list_to_english(items: list[str], ending: Literal["and", "or"] | None = None
43
44
  return f"{buffer}, {ending} {last}"
44
45
 
45
46
 
47
+ def truncate(text: str, limit: int, *, suffix: str = "") -> str:
48
+ """
49
+ Cut ``text`` to at most ``limit`` characters, appending ``suffix`` when cut.
50
+
51
+ ``suffix`` counts toward ``limit``, so the returned string is never longer
52
+ than ``limit`` (unless ``suffix`` itself is longer than ``limit``, in which
53
+ case ``suffix`` is returned truncated to ``limit``).
54
+ """
55
+ if limit <= 0:
56
+ return ""
57
+ if len(text) <= limit:
58
+ return text
59
+ if len(suffix) >= limit:
60
+ return suffix[:limit]
61
+ keep = limit - len(suffix)
62
+ return text[:keep] + suffix
63
+
64
+
46
65
  def sanitize_filename(raw: str, fallback: str = FALLBACK_FILENAME) -> str:
47
66
  """
48
67
  Reduce a string to lowercase kebab-case safe for use as a filename stem.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-corekit
3
- Version: 0.4.0
4
- Summary: Shared foundations for Python projects: logging, benchmarking, registries, FastAPI application and routers, SQL statements and migrations, background tasks, ETL, and LLM chat/tool primitives
3
+ Version: 0.5.0
4
+ Summary: Shared foundations for Python projects: logging, benchmarking, registries, FastAPI application and routers, SQL statements and migrations, background tasks, ETL, LLM chat/tool primitives, and HTML scraping
5
5
  Author: Steven Jacobsen
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/stevejaker/corekit
@@ -23,6 +23,7 @@ Requires-Dist: sqlmodel<0.1,>=0.0.16
23
23
  Requires-Dist: SQLAlchemy<3,>=2.0
24
24
  Requires-Dist: redis<7,>=5.0
25
25
  Requires-Dist: httpx<1,>=0.27
26
+ Requires-Dist: selectolax<1,>=0.3
26
27
  Requires-Dist: docker<8,>=7.0
27
28
  Requires-Dist: PyYAML<7,>=6.0
28
29
  Requires-Dist: dill<0.5,>=0.3.8
@@ -90,6 +91,29 @@ class Report(Benchmarkable):
90
91
  self.timing("queried") # logs the time since the previous split
91
92
  ```
92
93
 
94
+ ## Exceptionator
95
+
96
+ Catch an exception, run one action, then rethrow so the existing failure path
97
+ still happens. Built-in actions are no-op and log-traceback; apps supply their
98
+ own (persist, notify, …).
99
+
100
+ ```python
101
+ from corekit.exceptionator import (
102
+ Exceptionator,
103
+ ExceptionatorMiddleware,
104
+ LogTracebackAction,
105
+ install_run_task_hook,
106
+ set_process_exceptionator,
107
+ )
108
+
109
+ exceptionator = Exceptionator(LogTracebackAction(), service="api")
110
+ set_process_exceptionator(exceptionator)
111
+ install_run_task_hook(exceptionator) # optional; run_task captures with task name
112
+ ```
113
+
114
+ `ExceptionatorMiddleware` is raw ASGI (not `BaseHTTPMiddleware`). With no
115
+ `run_task` hook installed, jobs behave as in 0.4.0.
116
+
93
117
  ## Assembling an application
94
118
 
95
119
  `Application` is a plain `FastAPI` subclass — every constructor argument,
@@ -324,13 +348,13 @@ class GithubClient(BaseApiClient):
324
348
  return "https://api.github.com"
325
349
 
326
350
  response = GithubClient().get("/users/octocat")
327
- response.data["login"]
351
+ response.data.login
328
352
  ```
329
353
 
330
- Retries 429 and 5xx with exponential backoff. Every response is a
331
- `BaseApiResponse`, so a non-JSON error page leaves `data` empty rather than
332
- raising. The same methods are awaitable inside a running event loop:
333
- `await client.get(...)`.
354
+ Retries 429 and 5xx with exponential backoff. ``BaseHttpClient`` returns a
355
+ ``BaseHttpResponse`` (text + metadata); ``BaseApiClient`` adds structured
356
+ ``data`` from JSON (``None`` when the body is not JSON). The same methods are
357
+ awaitable inside a running event loop: `await client.get(...)`.
334
358
 
335
359
  ## Serialization
336
360