python-corekit 0.4.1__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.
corekit/html/state.py ADDED
@@ -0,0 +1,56 @@
1
+ import asyncio
2
+ from typing import Any
3
+
4
+ from corekit.utils import safe_list
5
+
6
+
7
+ class ScraperState:
8
+ # Sentinel value to signal the end of the stream
9
+ FINISHED_TOKEN = object()
10
+
11
+ _seen: set[str]
12
+ _queue: asyncio.Queue[str]
13
+
14
+ def __init__(self, start_urls: list[str] | None = None) -> None:
15
+ self._seen = set()
16
+ self._queue = asyncio.Queue()
17
+ self._output_queue: asyncio.Queue[Any] = asyncio.Queue()
18
+ self._lock: asyncio.Lock = asyncio.Lock()
19
+
20
+ for url in safe_list(start_urls):
21
+ self.enqueue_no_wait(url)
22
+
23
+ def enqueue_no_wait(self, url: str) -> None:
24
+ if url in self._seen:
25
+ return
26
+ self._seen.add(url)
27
+ self._queue.put_nowait(url)
28
+
29
+ async def enqueue(self, url: str) -> None:
30
+ async with self._lock:
31
+ if url in self._seen:
32
+ return
33
+ self._seen.add(url)
34
+ await self._queue.put(url)
35
+
36
+ async def get_next_url(self) -> str:
37
+ return await self._queue.get()
38
+
39
+ def task_done(self) -> None:
40
+ self._queue.task_done()
41
+
42
+ async def record_output(self, data: Any) -> None:
43
+ await self._output_queue.put(data)
44
+
45
+ async def get_output(self) -> Any:
46
+ return await self._output_queue.get()
47
+
48
+ def output_task_done(self) -> None:
49
+ self._output_queue.task_done()
50
+
51
+ def is_empty(self) -> bool:
52
+ return self._queue.empty()
53
+
54
+ async def join(self) -> None:
55
+ await self._queue.join()
56
+ await self.record_output(self.FINISHED_TOKEN)
corekit/http/__init__.py CHANGED
@@ -25,7 +25,7 @@ from corekit.http.exceptions import (
25
25
  URLMismatchError,
26
26
  )
27
27
  from corekit.http.exponential_backoff import ExponentialBackoff
28
- from corekit.http.response import BaseApiResponse
28
+ from corekit.http.response import BaseApiResponse, BaseHttpResponse
29
29
  from corekit.http.status import HTTPStatusCode
30
30
  from corekit.http.stream import SseDecoder, SseJsonDecoder, SseTextDecoder, StreamDecoder
31
31
 
@@ -35,6 +35,7 @@ __all__ = [
35
35
  "BaseApiClient",
36
36
  "BaseApiResponse",
37
37
  "BaseHttpClient",
38
+ "BaseHttpResponse",
38
39
  "ConflictErrorException",
39
40
  "ExponentialBackoff",
40
41
  "ForbiddenException",
corekit/http/api.py CHANGED
@@ -1,13 +1,29 @@
1
1
  """
2
- API clients: a ``BaseHttpClient`` that always talks to one origin.
2
+ API clients: a ``BaseHttpClient`` that always talks to one origin and parses JSON.
3
3
  """
4
4
 
5
+ from http import HTTPMethod
5
6
  from typing import Any
6
7
 
8
+ import httpx
9
+
10
+ from corekit.concurrency.decorators import allow_sync
7
11
  from corekit.http.client import BaseHttpClient
12
+ from corekit.http.response import BaseApiResponse
13
+ from corekit.http.status import HTTPStatusCode
8
14
 
9
15
  __all__ = ["BaseApiClient"]
10
16
 
17
+ _API_METHODS = frozenset(
18
+ {
19
+ HTTPMethod.GET,
20
+ HTTPMethod.POST,
21
+ HTTPMethod.PUT,
22
+ HTTPMethod.PATCH,
23
+ HTTPMethod.DELETE,
24
+ }
25
+ )
26
+
11
27
 
12
28
  class BaseApiClient(BaseHttpClient):
13
29
  """
@@ -16,9 +32,61 @@ class BaseApiClient(BaseHttpClient):
16
32
  Strict by default, so an absolute URL that does not share ``base_url`` is
17
33
  a bug rather than a request to somewhere else. ``base_url`` must be
18
34
  available during ``__init__`` — a property on the subclass is the usual way.
35
+
36
+ Supports the usual mutating verbs. Responses are ``BaseApiResponse`` with
37
+ structured ``data`` when the body is JSON.
19
38
  """
20
39
 
21
- def __init__(self, *args: Any, strict: bool = True, **kwargs: Any) -> None:
22
- super().__init__(*args, strict=strict, **kwargs)
40
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
41
+ kwargs.setdefault("strict", True)
42
+ super().__init__(*args, **kwargs)
23
43
  if not self.base_url:
24
44
  raise ValueError("BaseApiClient requires a non-empty base_url")
45
+
46
+ @property
47
+ def supported_methods(self) -> frozenset[HTTPMethod]:
48
+ """
49
+ API clients may send the common REST verbs.
50
+ """
51
+ return _API_METHODS
52
+
53
+ def _to_response(self, raw: httpx.Response) -> BaseApiResponse:
54
+ """
55
+ Normalize a httpx response into a ``BaseApiResponse`` with parsed ``data``.
56
+ """
57
+ try:
58
+ loaded: Any = raw.json()
59
+ except ValueError:
60
+ loaded = None
61
+
62
+ return BaseApiResponse(
63
+ status_code=HTTPStatusCode.coerce(raw.status_code),
64
+ text=raw.text,
65
+ data=BaseApiResponse.parse_data(loaded) if loaded is not None else None,
66
+ headers=dict(raw.headers),
67
+ cookies=dict(raw.cookies),
68
+ url=str(raw.url),
69
+ )
70
+
71
+ async def request(self, method: HTTPMethod, url: str, **kwargs: Any) -> BaseApiResponse:
72
+ return await super().request(method, url, **kwargs) # type: ignore[return-value]
73
+
74
+ @allow_sync
75
+ async def get(self, url: str, **kwargs: Any) -> BaseApiResponse:
76
+ return await self.request(HTTPMethod.GET, url, **kwargs)
77
+
78
+ @allow_sync
79
+ async def post(self, url: str, **kwargs: Any) -> BaseApiResponse:
80
+ return await self.request(HTTPMethod.POST, url, **kwargs)
81
+
82
+ @allow_sync
83
+ async def put(self, url: str, **kwargs: Any) -> BaseApiResponse:
84
+ return await self.request(HTTPMethod.PUT, url, **kwargs)
85
+
86
+ @allow_sync
87
+ async def patch(self, url: str, **kwargs: Any) -> BaseApiResponse:
88
+ return await self.request(HTTPMethod.PATCH, url, **kwargs)
89
+
90
+ @allow_sync
91
+ async def delete(self, url: str, **kwargs: Any) -> BaseApiResponse:
92
+ return await self.request(HTTPMethod.DELETE, url, **kwargs)
corekit/http/client.py CHANGED
@@ -22,10 +22,10 @@ absolute URL should be a bug.
22
22
 
23
23
  client = GithubClient()
24
24
  response = client.get("/user") # or await client.get("/user")
25
- response.data["login"]
25
+ response.data.login
26
26
 
27
- Every response comes back as a ``BaseApiResponse``, so callers see one shape
28
- regardless of what the endpoint returned.
27
+ ``BaseHttpClient`` returns a ``BaseHttpResponse`` (text + metadata).
28
+ ``BaseApiClient`` adds structured JSON on ``data``.
29
29
  """
30
30
 
31
31
  from collections.abc import AsyncIterator
@@ -39,9 +39,10 @@ import httpx
39
39
  from corekit.concurrency.decorators import allow_sync
40
40
  from corekit.http.exceptions import RETRYABLE_STATUS_CODES, UnsupportedMethodError, URLMismatchError
41
41
  from corekit.http.exponential_backoff import ExponentialBackoff
42
- from corekit.http.response import BaseApiResponse
42
+ from corekit.http.response import BaseHttpResponse
43
43
  from corekit.http.status import HTTPStatusCode
44
44
  from corekit.observability.benchmarkable import Benchmarkable
45
+ from corekit.utils import safe_dict
45
46
 
46
47
  __all__ = ["BaseHttpClient"]
47
48
 
@@ -54,27 +55,22 @@ def _without_query(url: str) -> str:
54
55
  return urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
55
56
 
56
57
 
57
- _DEFAULT_METHODS = frozenset(
58
- {
59
- HTTPMethod.GET,
60
- HTTPMethod.POST,
61
- HTTPMethod.PUT,
62
- HTTPMethod.PATCH,
63
- HTTPMethod.DELETE,
64
- }
65
- )
58
+ _DEFAULT_METHODS = frozenset({HTTPMethod.GET})
66
59
 
67
60
 
68
61
  class BaseHttpClient(Benchmarkable):
69
62
  """
70
63
  Base for HTTP clients. Override ``base_url`` and, usually, ``headers``.
71
64
 
72
- ``strict=False`` accepts a foreign absolute URL as-is. ``BaseApiClient``
73
- turns that into an error.
65
+ GET-only by default — fetching is the transport's job. Subclass
66
+ ``BaseApiClient`` (or override ``supported_methods``) when mutating verbs
67
+ are required. ``strict=False`` accepts a foreign absolute URL as-is;
68
+ ``BaseApiClient`` turns that into an error.
74
69
  """
75
70
 
76
71
  def __init__(
77
72
  self,
73
+ base_url: str = "",
78
74
  retries: int = 3,
79
75
  timeout: float = 30.0,
80
76
  strict: bool = False,
@@ -82,6 +78,7 @@ class BaseHttpClient(Benchmarkable):
82
78
  http_client: httpx.AsyncClient | None = None,
83
79
  ) -> None:
84
80
  super().__init__()
81
+ self._base_url = base_url
85
82
  self.retries = retries
86
83
  self.timeout = timeout
87
84
  self.strict = strict
@@ -93,7 +90,7 @@ class BaseHttpClient(Benchmarkable):
93
90
  @property
94
91
  def supported_methods(self) -> frozenset[HTTPMethod]:
95
92
  """
96
- HTTP methods this client will send. Override to narrow or extend the set.
93
+ HTTP methods this client will send. Defaults to GET only.
97
94
  """
98
95
  return _DEFAULT_METHODS
99
96
 
@@ -109,7 +106,7 @@ class BaseHttpClient(Benchmarkable):
109
106
  """
110
107
  The root every relative path is resolved against.
111
108
  """
112
- return ""
109
+ return self._base_url
113
110
 
114
111
  def _build_url(self, url: str) -> str:
115
112
  """
@@ -124,30 +121,21 @@ class BaseHttpClient(Benchmarkable):
124
121
  return url
125
122
  return urljoin(self.base_url, url)
126
123
 
127
- @staticmethod
128
- def _to_response(raw: httpx.Response) -> BaseApiResponse:
124
+ def _to_response(self, raw: httpx.Response) -> BaseHttpResponse:
129
125
  """
130
- Normalize a httpx response into a BaseApiResponse.
126
+ Normalize a httpx response into a ``BaseHttpResponse``.
131
127
 
132
- The body is decoded as JSON when it parses, and left as text otherwise,
133
- so a caller never has to guard against a non-JSON error page.
128
+ Text and metadata only — JSON parsing belongs on ``BaseApiClient``.
134
129
  """
135
- try:
136
- data = raw.json()
137
- except ValueError:
138
- data = {}
139
- if not isinstance(data, dict):
140
- data = {"data": data}
141
-
142
- return BaseApiResponse(
130
+ return BaseHttpResponse(
143
131
  status_code=HTTPStatusCode.coerce(raw.status_code),
144
132
  text=raw.text,
145
- data=data,
146
133
  headers=dict(raw.headers),
147
134
  cookies=dict(raw.cookies),
135
+ url=str(raw.url),
148
136
  )
149
137
 
150
- async def request(self, method: HTTPMethod, url: str, **kwargs: Any) -> BaseApiResponse:
138
+ async def request(self, method: HTTPMethod, url: str, **kwargs: Any) -> BaseHttpResponse:
151
139
  """
152
140
  Send a request asynchronously, retrying retryable statuses.
153
141
  """
@@ -156,9 +144,7 @@ class BaseHttpClient(Benchmarkable):
156
144
 
157
145
  target = self._build_url(url)
158
146
  headers = dict(self.headers)
159
- extra_headers = kwargs.pop("headers", None)
160
- if extra_headers:
161
- headers.update(extra_headers)
147
+ headers.update(safe_dict(kwargs.pop("headers", {})))
162
148
  # Fresh each request: ExponentialBackoff keeps its own attempt count.
163
149
  backoff = ExponentialBackoff(retries=self.retries, base_delay=self.base_delay)
164
150
 
@@ -176,7 +162,7 @@ class BaseHttpClient(Benchmarkable):
176
162
  headers: dict[str, str],
177
163
  backoff: ExponentialBackoff,
178
164
  kwargs: dict[str, Any],
179
- ) -> BaseApiResponse:
165
+ ) -> BaseHttpResponse:
180
166
  while True:
181
167
  raw = await client.request(str(method), target, headers=headers, **kwargs)
182
168
  if raw.status_code not in RETRYABLE_STATUS_CODES or backoff.did_timeout():
@@ -191,7 +177,7 @@ class BaseHttpClient(Benchmarkable):
191
177
  Open a streaming response and yield the raw ``httpx.Response``.
192
178
 
193
179
  Holds the connection for the life of the ``async with`` block. Unlike
194
- ``request``, the body is not buffered into a ``BaseApiResponse`` — the
180
+ ``request``, the body is not buffered into a response model — the
195
181
  caller reads it (for example with ``aiter_lines`` for SSE). Retries are
196
182
  not applied: a mid-stream failure cannot be replayed safely.
197
183
  """
@@ -214,21 +200,5 @@ class BaseHttpClient(Benchmarkable):
214
200
  yield response
215
201
 
216
202
  @allow_sync
217
- async def get(self, url: str, **kwargs: Any) -> BaseApiResponse:
203
+ async def get(self, url: str, **kwargs: Any) -> BaseHttpResponse:
218
204
  return await self.request(HTTPMethod.GET, url, **kwargs)
219
-
220
- @allow_sync
221
- async def post(self, url: str, **kwargs: Any) -> BaseApiResponse:
222
- return await self.request(HTTPMethod.POST, url, **kwargs)
223
-
224
- @allow_sync
225
- async def put(self, url: str, **kwargs: Any) -> BaseApiResponse:
226
- return await self.request(HTTPMethod.PUT, url, **kwargs)
227
-
228
- @allow_sync
229
- async def patch(self, url: str, **kwargs: Any) -> BaseApiResponse:
230
- return await self.request(HTTPMethod.PATCH, url, **kwargs)
231
-
232
- @allow_sync
233
- async def delete(self, url: str, **kwargs: Any) -> BaseApiResponse:
234
- return await self.request(HTTPMethod.DELETE, url, **kwargs)
corekit/http/response.py CHANGED
@@ -1,3 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
1
5
  from pydantic import BaseModel, Field
2
6
 
3
7
  from corekit.exceptions import CoreHTTPException
@@ -14,16 +18,23 @@ from corekit.http.exceptions import (
14
18
  UnprocessableEntityException,
15
19
  )
16
20
  from corekit.http.status import HTTPStatusCode
17
- from corekit.schemas.pydantic.fields import DefaultDictField
21
+ from corekit.schemas.models.arbitrary import ArbitraryModel
22
+ from corekit.schemas.pydantic.fields import DefaultDictField, DefaultEmptyField
18
23
  from corekit.schemas.types import AnyDict
19
24
 
20
25
 
21
- class BaseApiResponse(BaseModel):
26
+ class BaseHttpResponse(BaseModel):
27
+ """
28
+ Transport-level HTTP response: status, decoded body text, and metadata.
29
+
30
+ No JSON parsing happens here — subclasses add structured ``data`` when needed.
31
+ """
32
+
22
33
  status_code: HTTPStatusCode | int = Field(..., description="HTTP status code")
23
- text: str = Field(..., description="Response text")
24
- data: AnyDict = DefaultDictField()
34
+ text: str = Field(..., description="Decoded response body")
25
35
  headers: AnyDict = DefaultDictField()
26
36
  cookies: AnyDict = DefaultDictField()
37
+ url: str = Field(default="", description="Final response URL after redirects")
27
38
 
28
39
  @property
29
40
  def ok(self) -> bool:
@@ -32,7 +43,7 @@ class BaseApiResponse(BaseModel):
32
43
  """
33
44
  return int(self.status_code) < 400
34
45
 
35
- def raise_for_status(self) -> "BaseApiResponse":
46
+ def raise_for_status(self) -> BaseHttpResponse:
36
47
  """
37
48
  Raise a typed HTTP exception for a failure status.
38
49
 
@@ -59,3 +70,25 @@ class BaseApiResponse(BaseModel):
59
70
  if exception_type is None:
60
71
  raise CoreHTTPException(status_code=int(self.status_code), detail=detail)
61
72
  raise exception_type(detail=detail)
73
+
74
+
75
+ class BaseApiResponse(BaseHttpResponse):
76
+ """
77
+ API response with structured ``data`` parsed from JSON when present.
78
+
79
+ ``data`` keeps the wire shape: objects become ``ArbitraryModel``, arrays stay
80
+ lists, primitives stay primitives, and non-JSON bodies yield ``None``.
81
+ """
82
+
83
+ data: Any = DefaultEmptyField(description="Structured JSON body, if any")
84
+
85
+ def raise_for_status(self) -> BaseApiResponse:
86
+ super().raise_for_status()
87
+ return self
88
+
89
+ @staticmethod
90
+ def parse_data(raw_json: Any) -> Any:
91
+ """
92
+ Coerce a decoded JSON value into structured ``data``.
93
+ """
94
+ return ArbitraryModel.coerce(raw_json)
corekit/llm/client.py CHANGED
@@ -15,6 +15,7 @@ from typing import Any, Literal, overload
15
15
  from corekit.concurrency.decorators import allow_sync
16
16
  from corekit.http import BaseApiClient, BaseApiResponse, SseJsonDecoder
17
17
  from corekit.llm.messages import ChatTurn, Message
18
+ from corekit.schemas import ArbitraryModel
18
19
 
19
20
  __all__ = ["ChatCompletionsClient", "DEFAULT_LLM_TIMEOUT"]
20
21
 
@@ -162,9 +163,9 @@ class ChatCompletionsClient(BaseApiClient):
162
163
  )
163
164
  response = await self.post("chat/completions", json=body)
164
165
  response.raise_for_status()
165
- if not response.data:
166
+ if not isinstance(response.data, ArbitraryModel):
166
167
  raise ValueError(f"chat/completions returned non-JSON body: {response.text!r}")
167
- return dict(response.data)
168
+ return response.data.model_dump()
168
169
 
169
170
  async def _complete_stream(self, body: dict[str, Any]) -> AsyncIterator[dict[str, Any]]:
170
171
  self.info(
corekit/llm/tools/loop.py CHANGED
@@ -24,7 +24,7 @@ from corekit.llm.tools.detection import MAX_TOOL_ITERATIONS, ToolCallLoopDetecto
24
24
  from corekit.llm.tools.registry import ToolRegistry
25
25
  from corekit.llm.wire import ChoiceDelta, CompletionTurn, UsageInfo
26
26
  from corekit.observability import Benchmarkable
27
- from corekit.utils.coercion import safe_dict
27
+ from corekit.utils import parse_dict
28
28
 
29
29
  __all__ = ["ToolLoop"]
30
30
 
@@ -224,12 +224,15 @@ class ToolLoop(Benchmarkable):
224
224
  async def _execute_ui_tool(self, call: ToolCall) -> UIComponentEvent:
225
225
  result = await self._registry.aexecute(call.name, call.id, call.arguments)
226
226
  self.info(f"[ToolLoop] UI result — is_error: {result.is_error}")
227
+ parsed = parse_dict(result.content, default={})
227
228
  if result.is_error:
228
229
  self.warning(f"[ToolLoop] UI tool failed: {result.content}")
230
+ elif not parsed.success:
231
+ self.warning(f"[ToolLoop] UI tool {call.name!r} returned non-mapping component: {result.content!r}")
229
232
  return UIComponentEvent(
230
233
  tool_call_id=call.id,
231
234
  name=call.name,
232
- component=safe_dict(result.content),
235
+ component=parsed.value,
233
236
  )
234
237
 
235
238
  def _record_metrics(
corekit/llm/wire.py CHANGED
@@ -13,7 +13,7 @@ from pydantic import BaseModel, Field
13
13
  from corekit.llm.enum import WireField
14
14
  from corekit.llm.events import UsageEvent
15
15
  from corekit.llm.tools.base import ToolCall
16
- from corekit.utils import safe_dict, safe_string
16
+ from corekit.utils import parse_dict, safe_dict, safe_string
17
17
  from corekit.utils.collections import attr_or_key
18
18
 
19
19
  __all__ = [
@@ -89,12 +89,18 @@ class ToolCallAccumulator(BaseModel):
89
89
  """
90
90
  Parse accumulated argument JSON into a ``ToolCall``.
91
91
  """
92
- parsed: dict[str, Any] = {}
93
92
  try:
94
- parsed = json.loads(self.arguments)
93
+ loaded: Any = json.loads(self.arguments)
95
94
  except json.JSONDecodeError:
96
- logger.warning("Bad tool args for %r: %r", self.name, self.arguments)
97
- return ToolCall(id=self.id, name=self.name, arguments=safe_dict(parsed))
95
+ logger.warning(f"Bad tool args for {self.name!r}: {self.arguments!r}")
96
+ return ToolCall(id=self.id, name=self.name, arguments={})
97
+
98
+ parsed = parse_dict(loaded, default={})
99
+ if not parsed.success:
100
+ logger.warning(
101
+ f"Tool args for {self.name!r} decoded to non-mapping {type(loaded).__name__}: {self.arguments!r}",
102
+ )
103
+ return ToolCall(id=self.id, name=self.name, arguments=parsed.value)
98
104
 
99
105
 
100
106
  class ChoiceDelta(BaseModel):
@@ -6,6 +6,13 @@ raises where it is introduced rather than somewhere further along.
6
6
  """
7
7
 
8
8
  from corekit.schemas.enum import IntegerEnum, StringEnum, ValidatingEnum
9
+ from corekit.schemas.models.arbitrary import ArbitraryModel
9
10
  from corekit.schemas.version import SemanticVersion
10
11
 
11
- __all__ = ["IntegerEnum", "SemanticVersion", "StringEnum", "ValidatingEnum"]
12
+ __all__ = [
13
+ "ArbitraryModel",
14
+ "IntegerEnum",
15
+ "SemanticVersion",
16
+ "StringEnum",
17
+ "ValidatingEnum",
18
+ ]
@@ -1,11 +1,55 @@
1
- from pydantic import BaseModel
1
+ """
2
+ Recursive attribute access over unstructured JSON-shaped data.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any, Mapping
8
+
9
+ from pydantic import BaseModel, ConfigDict, model_validator
2
10
 
3
11
 
4
12
  class ArbitraryModel(BaseModel):
5
13
  """
6
- Base model for arbitrary data. This model is used to take unstructured data and convert it into
7
- a structured format that can be easily mapped to a model. This model is capable of parsing any type of data
8
- and converting it into a structured format that can be easily accessed and manipulated.
14
+ Accept any object payload and expose nested dicts as models.
15
+
16
+ Lists keep their shape; dict elements inside them are coerced the same way.
17
+ ``model_dump()`` returns plain nested dicts; ``model["key"]`` works for
18
+ callers that still treat the payload like a mapping.
9
19
  """
10
20
 
11
- pass
21
+ model_config = ConfigDict(extra="allow")
22
+
23
+ @classmethod
24
+ def coerce(cls, value: Any) -> Any:
25
+ """
26
+ Recursively turn dicts into ``ArbitraryModel`` instances.
27
+ """
28
+ if isinstance(value, cls):
29
+ return value
30
+ if isinstance(value, Mapping) and not isinstance(value, (str, bytes)):
31
+ return cls.model_validate(dict(value))
32
+ if isinstance(value, list):
33
+ return [cls.coerce(item) for item in value]
34
+ return value
35
+
36
+ @model_validator(mode="after")
37
+ def _coerce_extras(self) -> ArbitraryModel:
38
+ extras = self.__pydantic_extra__
39
+ if not extras:
40
+ return self
41
+ for key, value in list(extras.items()):
42
+ extras[key] = type(self).coerce(value)
43
+ return self
44
+
45
+ def __bool__(self) -> bool:
46
+ return bool(self.model_dump())
47
+
48
+ def __getitem__(self, key: str) -> Any:
49
+ try:
50
+ return getattr(self, key)
51
+ except AttributeError as exc:
52
+ raise KeyError(key) from exc
53
+
54
+ def __contains__(self, key: object) -> bool:
55
+ return isinstance(key, str) and key in self.model_dump()
corekit/utils/__init__.py CHANGED
@@ -1,15 +1,34 @@
1
1
  """
2
2
  Small, dependency-free helpers.
3
3
 
4
- Four groups: total conversions that never raise (``safe_*``), string assembly
5
- for human-readable output, identifier generation, and nested-dictionary lookup.
6
- Plus timezone-aware time helpers and the placeholder callbacks (``void``,
7
- ``raise_exc``, ``true_validator``, ``false_validator``) that callbacks-by-
8
- configuration need.
4
+ Five groups: flexible parsers with caller-controlled fallbacks (``parse_*``),
5
+ total conversions that never raise (``safe_*`` wrappers around those parsers),
6
+ string assembly for human-readable output, identifier generation, and
7
+ nested-dictionary lookup. Plus timezone-aware time helpers and the placeholder
8
+ callbacks (``void``, ``raise_exc``, ``true_validator``, ``false_validator``)
9
+ that callbacks-by-configuration need.
9
10
  """
10
11
 
11
- from corekit.utils.coercion import safe_dict, safe_float, safe_int, safe_list, safe_string, safe_tuple
12
- from corekit.utils.collections import UNSET, MultiMatch, attr_or_key, keygetter, repeated_get, split_list
12
+ from corekit.utils.coercion import (
13
+ safe_dict,
14
+ safe_float,
15
+ safe_int,
16
+ safe_list,
17
+ safe_set,
18
+ safe_string,
19
+ safe_tuple,
20
+ )
21
+ from corekit.utils.collections import (
22
+ UNSET,
23
+ MultiMatch,
24
+ aiterator,
25
+ attr_or_key,
26
+ iterator,
27
+ keygetter,
28
+ kv_string,
29
+ repeated_get,
30
+ split_list,
31
+ )
13
32
  from corekit.utils.ids import (
14
33
  SHORTCODE_CHARS,
15
34
  generate_session_token,
@@ -17,6 +36,16 @@ from corekit.utils.ids import (
17
36
  generate_shortcodes,
18
37
  generate_uuid,
19
38
  )
39
+ from corekit.utils.parsers import (
40
+ ParsedValue,
41
+ parse_dict,
42
+ parse_float,
43
+ parse_int,
44
+ parse_list,
45
+ parse_set,
46
+ parse_string,
47
+ parse_tuple,
48
+ )
20
49
  from corekit.utils.payload import Payload, decode_payload, encode_payload
21
50
  from corekit.utils.raise_exc import raise_exc
22
51
  from corekit.utils.text import join_lines, list_to_english, sanitize_filename, truncate
@@ -28,7 +57,9 @@ __all__ = [
28
57
  "SHORTCODE_CHARS",
29
58
  "UNSET",
30
59
  "MultiMatch",
60
+ "ParsedValue",
31
61
  "Payload",
62
+ "aiterator",
32
63
  "attr_or_key",
33
64
  "decode_payload",
34
65
  "encode_payload",
@@ -38,16 +69,26 @@ __all__ = [
38
69
  "generate_shortcodes",
39
70
  "generate_uuid",
40
71
  "isoformat_now",
72
+ "iterator",
41
73
  "join_lines",
42
74
  "keygetter",
75
+ "kv_string",
43
76
  "list_to_english",
77
+ "parse_dict",
44
78
  "parse_docker_timestamp",
79
+ "parse_float",
80
+ "parse_int",
81
+ "parse_list",
82
+ "parse_set",
83
+ "parse_string",
84
+ "parse_tuple",
45
85
  "raise_exc",
46
86
  "repeated_get",
47
87
  "safe_dict",
48
88
  "safe_float",
49
89
  "safe_int",
50
90
  "safe_list",
91
+ "safe_set",
51
92
  "safe_string",
52
93
  "safe_tuple",
53
94
  "sanitize_filename",