truewire-core 0.1.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 (43) hide show
  1. truewire_core-0.1.0/LICENSE +21 -0
  2. truewire_core-0.1.0/PKG-INFO +159 -0
  3. truewire_core-0.1.0/README.md +129 -0
  4. truewire_core-0.1.0/pyproject.toml +40 -0
  5. truewire_core-0.1.0/setup.cfg +4 -0
  6. truewire_core-0.1.0/src/truewire_core/__init__.py +2 -0
  7. truewire_core-0.1.0/src/truewire_core/__init__.pyi +14 -0
  8. truewire_core-0.1.0/src/truewire_core/exceptions.py +41 -0
  9. truewire_core-0.1.0/src/truewire_core/grpc.py +94 -0
  10. truewire_core-0.1.0/src/truewire_core/http.py +68 -0
  11. truewire_core-0.1.0/src/truewire_core/py.typed +0 -0
  12. truewire_core-0.1.0/src/truewire_core/times/__init__.py +2 -0
  13. truewire_core-0.1.0/src/truewire_core/times/__init__.pyi +6 -0
  14. truewire_core-0.1.0/src/truewire_core/times/base.py +20 -0
  15. truewire_core-0.1.0/src/truewire_core/times/date.py +37 -0
  16. truewire_core-0.1.0/src/truewire_core/times/iso.py +45 -0
  17. truewire_core-0.1.0/src/truewire_core/times/ms.py +65 -0
  18. truewire_core-0.1.0/src/truewire_core/util/__init__.py +2 -0
  19. truewire_core-0.1.0/src/truewire_core/util/__init__.pyi +11 -0
  20. truewire_core-0.1.0/src/truewire_core/util/misc.py +16 -0
  21. truewire_core-0.1.0/src/truewire_core/util/paging.py +131 -0
  22. truewire_core-0.1.0/src/truewire_core/util/rate_limit.py +49 -0
  23. truewire_core-0.1.0/src/truewire_core/util/streams.py +95 -0
  24. truewire_core-0.1.0/src/truewire_core/validation.py +52 -0
  25. truewire_core-0.1.0/src/truewire_core/ws/__init__.py +2 -0
  26. truewire_core-0.1.0/src/truewire_core/ws/__init__.pyi +24 -0
  27. truewire_core-0.1.0/src/truewire_core/ws/rpc.py +54 -0
  28. truewire_core-0.1.0/src/truewire_core/ws/serial.py +53 -0
  29. truewire_core-0.1.0/src/truewire_core/ws/socket.py +232 -0
  30. truewire_core-0.1.0/src/truewire_core/ws/streams.py +134 -0
  31. truewire_core-0.1.0/src/truewire_core/ws/streams_rpc.py +168 -0
  32. truewire_core-0.1.0/src/truewire_core.egg-info/PKG-INFO +159 -0
  33. truewire_core-0.1.0/src/truewire_core.egg-info/SOURCES.txt +41 -0
  34. truewire_core-0.1.0/src/truewire_core.egg-info/dependency_links.txt +1 -0
  35. truewire_core-0.1.0/src/truewire_core.egg-info/requires.txt +15 -0
  36. truewire_core-0.1.0/src/truewire_core.egg-info/top_level.txt +1 -0
  37. truewire_core-0.1.0/test/test_exceptions.py +58 -0
  38. truewire_core-0.1.0/test/test_grpc.py +58 -0
  39. truewire_core-0.1.0/test/test_http.py +115 -0
  40. truewire_core-0.1.0/test/test_paging.py +110 -0
  41. truewire_core-0.1.0/test/test_socket.py +158 -0
  42. truewire_core-0.1.0/test/test_times.py +181 -0
  43. truewire_core-0.1.0/test/test_validation.py +75 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tribulnation Labs, S.L. and Truewire contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,159 @@
1
+ Metadata-Version: 2.4
2
+ Name: truewire-core
3
+ Version: 0.1.0
4
+ Summary: Runtime for Truewire-generated API clients: async HTTP and WebSocket transport, response validation, paging, timestamps, errors.
5
+ Author: Truewire contributors
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/truewire-dev/truewire
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: typing-extensions
17
+ Requires-Dist: httpx
18
+ Requires-Dist: websockets
19
+ Requires-Dist: orjson
20
+ Requires-Dist: pydantic
21
+ Requires-Dist: lazy-loader
22
+ Provides-Extra: grpc
23
+ Requires-Dist: grpclib; extra == "grpc"
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest; extra == "dev"
26
+ Requires-Dist: pytest-asyncio; extra == "dev"
27
+ Requires-Dist: grpclib; extra == "dev"
28
+ Requires-Dist: ruff; extra == "dev"
29
+ Dynamic: license-file
30
+
31
+ # Truewire Core
32
+
33
+ > Runtime for Truewire-generated API clients
34
+
35
+ [![PyPI version](https://img.shields.io/pypi/v/truewire-core.svg)](https://pypi.org/project/truewire-core/)
36
+ [![License](https://img.shields.io/pypi/l/truewire-core.svg)](LICENSE)
37
+
38
+ Every client generated by [Truewire](https://github.com/truewire-dev/truewire) depends on this
39
+ package. It holds the parts of a client that are the same for every API — async HTTP and
40
+ WebSocket transport, response validation, paging, timestamp conversion and the error
41
+ hierarchy — so that generated code only has to carry what is genuinely specific to one API:
42
+ envelope extraction, error mapping, request signing, wire quirks.
43
+
44
+ You do not normally install it directly; a generated client lists it as a dependency. You
45
+ will import from it when catching errors, converting timestamps, or driving a paginated walk.
46
+
47
+ ## Installation
48
+
49
+ ```bash
50
+ pip install truewire-core
51
+ ```
52
+
53
+ Optional extras:
54
+
55
+ ```bash
56
+ pip install 'truewire-core[grpc]' # gRPC transport (grpclib)
57
+ ```
58
+
59
+ ## What it provides
60
+
61
+ | module | contents |
62
+ | --- | --- |
63
+ | `truewire_core.exceptions` | `Error`, `NetworkError`, `ValidationError`, `ApiError` (`BadRequest`, `AuthError`, `RateLimited`), `LogicError` |
64
+ | `truewire_core.http` | `HttpClient`, an async HTTP client wrapping `httpx` with lazy connection and `NetworkError` mapping |
65
+ | `truewire_core.ws` | WebSocket base classes: `Socket`, `Streams`, `Rpc`, `StreamsRpc`, `SerialReplies` |
66
+ | `truewire_core.grpc` | `GrpcClient`, `GrpcEndpoint`, `wrap_exceptions` (requires the `grpc` extra) |
67
+ | `truewire_core.validation` | `validator[T]`, a cached pydantic adapter that validates and dumps wire shapes, and a base `TypedDict` that tolerates undocumented fields |
68
+ | `truewire_core.times` | `TimeConverter`, `EpochConverter`, `IsoConverter`, `DateConverter` — parse/dump between a wire timestamp and a real `datetime`/`date` |
69
+ | `truewire_core.util` | `PaginatedResponse`/`Page`, `Stream`/`StreamManager`, `RateLimit`, and small numeric/path helpers |
70
+
71
+ ### Errors
72
+
73
+ Every failure a client raises derives from `truewire_core.exceptions.Error`:
74
+
75
+ ```python
76
+ from truewire_core.exceptions import ApiError, AuthError, NetworkError, RateLimited
77
+
78
+ try:
79
+ order = await client.orders.get(id='ord_123')
80
+ except AuthError:
81
+ ... # bad or missing credentials
82
+ except RateLimited:
83
+ ... # the API told us to slow down
84
+ except ApiError as e:
85
+ ... # any other error the API itself returned (BadRequest, ...)
86
+ except NetworkError:
87
+ ... # couldn't reach the server, or the connection dropped
88
+ ```
89
+
90
+ Generated clients re-export these from their own package root, so `from my_client import
91
+ AuthError` works too. Route such re-exports through `lazy_loader.attach_stub` (with a matching
92
+ `__init__.pyi`), not a plain `from truewire_core.exceptions import ...` in an `__init__.py` —
93
+ the latter breaks type-checking for every downstream consumer of a `py.typed` package.
94
+
95
+ ### Timestamps
96
+
97
+ APIs put timestamps on the wire in many shapes: epoch seconds, milliseconds, microseconds or
98
+ nanoseconds; RFC 3339 strings with or without a `Z`; plain calendar dates. Each converter
99
+ turns one such shape into a real `datetime` (or `date`) and back:
100
+
101
+ ```python
102
+ from datetime import timezone
103
+ from truewire_core.times import DateConverter, EpochConverter, IsoConverter
104
+
105
+ timestamp_millis = EpochConverter.milliseconds(tz=timezone.utc) # 1717072496123 <-> datetime
106
+ timestamp_seconds = EpochConverter.seconds(tz=timezone.utc) # 1717072496 <-> datetime
107
+ timestamp_iso = IsoConverter() # '2024-05-30T12:34:56Z' <-> datetime
108
+ calendar_date = DateConverter() # '2024-05-30' <-> date
109
+
110
+ dt = timestamp_iso.parse('2024-05-30T12:34:56.123456789Z') # any fraction length, any Python >= 3.10
111
+ timestamp_millis.dump(dt) # 1717072496123
112
+ ```
113
+
114
+ A generated client wires these into its field types through pydantic, so a response field
115
+ declared as a millisecond epoch arrives as a `datetime` and a request parameter typed as one
116
+ is serialized back to the wire format the API expects.
117
+
118
+ ### Paging
119
+
120
+ Every generated `<method>_paged` returns a `PaginatedResponse`: awaitable (every row,
121
+ flattened) and async-iterable (one page of rows at a time). Each page is one pure
122
+ `next(state)` call, so a caller can retry or resume a single page rather than the whole walk.
123
+
124
+ ```python
125
+ from truewire_core import PaginatedResponse
126
+
127
+ paging = client.orders.list_paged(status='open')
128
+ orders = await paging # every row, flattened
129
+ async for rows in paging: ... # one page at a time
130
+ async for page in paging.pages(): # Page(rows, state, next), for checkpointing
131
+ checkpoint(page.next)
132
+ paging.resume(saved_state) # restart from a checkpointed state
133
+ paging.via(retried) # route every page fetch through a middleware
134
+ ```
135
+
136
+ `via(call)` hands each page fetch to `call` as one zero-argument coroutine function, so a
137
+ retry or logging layer wraps a page without unrolling the loop by hand.
138
+
139
+ ### Validation
140
+
141
+ Generated response types are `TypedDict`s. `validator` wraps a cached pydantic `TypeAdapter`
142
+ around one and raises `truewire_core.exceptions.ValidationError` (not pydantic's own) when
143
+ the wire body doesn't match:
144
+
145
+ ```python
146
+ from truewire_core.validation import TypedDict, validator
147
+
148
+ class Order(TypedDict):
149
+ id: str
150
+ amount: str
151
+
152
+ order = validator(Order)(b'{"id": "ord_123", "amount": "10.5", "extra": true}')
153
+ # {'id': 'ord_123', 'amount': '10.5', 'extra': True} -- undocumented fields are kept, not rejected
154
+ validator(Order).dump(order) # b'{"id":"ord_123","amount":"10.5","extra":true}'
155
+ ```
156
+
157
+ ## License
158
+
159
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,129 @@
1
+ # Truewire Core
2
+
3
+ > Runtime for Truewire-generated API clients
4
+
5
+ [![PyPI version](https://img.shields.io/pypi/v/truewire-core.svg)](https://pypi.org/project/truewire-core/)
6
+ [![License](https://img.shields.io/pypi/l/truewire-core.svg)](LICENSE)
7
+
8
+ Every client generated by [Truewire](https://github.com/truewire-dev/truewire) depends on this
9
+ package. It holds the parts of a client that are the same for every API — async HTTP and
10
+ WebSocket transport, response validation, paging, timestamp conversion and the error
11
+ hierarchy — so that generated code only has to carry what is genuinely specific to one API:
12
+ envelope extraction, error mapping, request signing, wire quirks.
13
+
14
+ You do not normally install it directly; a generated client lists it as a dependency. You
15
+ will import from it when catching errors, converting timestamps, or driving a paginated walk.
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ pip install truewire-core
21
+ ```
22
+
23
+ Optional extras:
24
+
25
+ ```bash
26
+ pip install 'truewire-core[grpc]' # gRPC transport (grpclib)
27
+ ```
28
+
29
+ ## What it provides
30
+
31
+ | module | contents |
32
+ | --- | --- |
33
+ | `truewire_core.exceptions` | `Error`, `NetworkError`, `ValidationError`, `ApiError` (`BadRequest`, `AuthError`, `RateLimited`), `LogicError` |
34
+ | `truewire_core.http` | `HttpClient`, an async HTTP client wrapping `httpx` with lazy connection and `NetworkError` mapping |
35
+ | `truewire_core.ws` | WebSocket base classes: `Socket`, `Streams`, `Rpc`, `StreamsRpc`, `SerialReplies` |
36
+ | `truewire_core.grpc` | `GrpcClient`, `GrpcEndpoint`, `wrap_exceptions` (requires the `grpc` extra) |
37
+ | `truewire_core.validation` | `validator[T]`, a cached pydantic adapter that validates and dumps wire shapes, and a base `TypedDict` that tolerates undocumented fields |
38
+ | `truewire_core.times` | `TimeConverter`, `EpochConverter`, `IsoConverter`, `DateConverter` — parse/dump between a wire timestamp and a real `datetime`/`date` |
39
+ | `truewire_core.util` | `PaginatedResponse`/`Page`, `Stream`/`StreamManager`, `RateLimit`, and small numeric/path helpers |
40
+
41
+ ### Errors
42
+
43
+ Every failure a client raises derives from `truewire_core.exceptions.Error`:
44
+
45
+ ```python
46
+ from truewire_core.exceptions import ApiError, AuthError, NetworkError, RateLimited
47
+
48
+ try:
49
+ order = await client.orders.get(id='ord_123')
50
+ except AuthError:
51
+ ... # bad or missing credentials
52
+ except RateLimited:
53
+ ... # the API told us to slow down
54
+ except ApiError as e:
55
+ ... # any other error the API itself returned (BadRequest, ...)
56
+ except NetworkError:
57
+ ... # couldn't reach the server, or the connection dropped
58
+ ```
59
+
60
+ Generated clients re-export these from their own package root, so `from my_client import
61
+ AuthError` works too. Route such re-exports through `lazy_loader.attach_stub` (with a matching
62
+ `__init__.pyi`), not a plain `from truewire_core.exceptions import ...` in an `__init__.py` —
63
+ the latter breaks type-checking for every downstream consumer of a `py.typed` package.
64
+
65
+ ### Timestamps
66
+
67
+ APIs put timestamps on the wire in many shapes: epoch seconds, milliseconds, microseconds or
68
+ nanoseconds; RFC 3339 strings with or without a `Z`; plain calendar dates. Each converter
69
+ turns one such shape into a real `datetime` (or `date`) and back:
70
+
71
+ ```python
72
+ from datetime import timezone
73
+ from truewire_core.times import DateConverter, EpochConverter, IsoConverter
74
+
75
+ timestamp_millis = EpochConverter.milliseconds(tz=timezone.utc) # 1717072496123 <-> datetime
76
+ timestamp_seconds = EpochConverter.seconds(tz=timezone.utc) # 1717072496 <-> datetime
77
+ timestamp_iso = IsoConverter() # '2024-05-30T12:34:56Z' <-> datetime
78
+ calendar_date = DateConverter() # '2024-05-30' <-> date
79
+
80
+ dt = timestamp_iso.parse('2024-05-30T12:34:56.123456789Z') # any fraction length, any Python >= 3.10
81
+ timestamp_millis.dump(dt) # 1717072496123
82
+ ```
83
+
84
+ A generated client wires these into its field types through pydantic, so a response field
85
+ declared as a millisecond epoch arrives as a `datetime` and a request parameter typed as one
86
+ is serialized back to the wire format the API expects.
87
+
88
+ ### Paging
89
+
90
+ Every generated `<method>_paged` returns a `PaginatedResponse`: awaitable (every row,
91
+ flattened) and async-iterable (one page of rows at a time). Each page is one pure
92
+ `next(state)` call, so a caller can retry or resume a single page rather than the whole walk.
93
+
94
+ ```python
95
+ from truewire_core import PaginatedResponse
96
+
97
+ paging = client.orders.list_paged(status='open')
98
+ orders = await paging # every row, flattened
99
+ async for rows in paging: ... # one page at a time
100
+ async for page in paging.pages(): # Page(rows, state, next), for checkpointing
101
+ checkpoint(page.next)
102
+ paging.resume(saved_state) # restart from a checkpointed state
103
+ paging.via(retried) # route every page fetch through a middleware
104
+ ```
105
+
106
+ `via(call)` hands each page fetch to `call` as one zero-argument coroutine function, so a
107
+ retry or logging layer wraps a page without unrolling the loop by hand.
108
+
109
+ ### Validation
110
+
111
+ Generated response types are `TypedDict`s. `validator` wraps a cached pydantic `TypeAdapter`
112
+ around one and raises `truewire_core.exceptions.ValidationError` (not pydantic's own) when
113
+ the wire body doesn't match:
114
+
115
+ ```python
116
+ from truewire_core.validation import TypedDict, validator
117
+
118
+ class Order(TypedDict):
119
+ id: str
120
+ amount: str
121
+
122
+ order = validator(Order)(b'{"id": "ord_123", "amount": "10.5", "extra": true}')
123
+ # {'id': 'ord_123', 'amount': '10.5', 'extra': True} -- undocumented fields are kept, not rejected
124
+ validator(Order).dump(order) # b'{"id":"ord_123","amount":"10.5","extra":true}'
125
+ ```
126
+
127
+ ## License
128
+
129
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "truewire-core"
7
+ version = "0.1.0"
8
+ authors = [
9
+ {name="Truewire contributors"}
10
+ ]
11
+ description = "Runtime for Truewire-generated API clients: async HTTP and WebSocket transport, response validation, paging, timestamps, errors."
12
+ license = "MIT"
13
+ license-files = ["LICENSE"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Typing :: Typed",
20
+ ]
21
+ dependencies = [
22
+ "typing-extensions", "httpx", "websockets", "orjson", "pydantic",
23
+ "lazy-loader",
24
+ ]
25
+ requires-python = ">=3.10"
26
+ readme = {file="README.md", content-type="text/markdown"}
27
+
28
+ [project.optional-dependencies]
29
+ grpc = ["grpclib"]
30
+ dev = ["pytest", "pytest-asyncio", "grpclib", "ruff"]
31
+
32
+ [project.urls]
33
+ Repository = "https://github.com/truewire-dev/truewire"
34
+
35
+ [tool.setuptools.package-data]
36
+ truewire_core = ["py.typed", "**/*.pyi"]
37
+
38
+ [tool.pytest.ini_options]
39
+ asyncio_mode = "auto"
40
+ testpaths = ["test"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ import lazy_loader as lazy
2
+ __getattr__, __dir__, __all__ = lazy.attach_stub(__name__, __file__)
@@ -0,0 +1,14 @@
1
+ from .exceptions import (
2
+ Error, NetworkError, ValidationError,
3
+ ApiError, BadRequest, AuthError, RateLimited, LogicError
4
+ )
5
+ from .http import HttpClient
6
+ from . import ws
7
+ from .util import round2tick, trunc2tick, ceil2tick, path_join, PaginatedResponse, Page
8
+
9
+ __all__ = [
10
+ 'Error', 'NetworkError', 'ValidationError',
11
+ 'ApiError', 'BadRequest', 'AuthError', 'RateLimited', 'LogicError',
12
+ 'HttpClient', 'ws',
13
+ 'round2tick', 'trunc2tick', 'ceil2tick', 'path_join', 'PaginatedResponse', 'Page',
14
+ ]
@@ -0,0 +1,41 @@
1
+ class Error(Exception):
2
+ """Base exception"""
3
+ def __str__(self):
4
+ args = list(map(str, self.args))
5
+ args = self.args[0] if len(self.args) == 1 else ', '.join(map(str, self.args))
6
+ return f'{self.__class__.__name__}({args})'
7
+
8
+ class NetworkError(Error):
9
+ """Error reaching the server."""
10
+ def __str__(self):
11
+ return super().__str__()
12
+
13
+ class ValidationError(Error):
14
+ """Invalid response format."""
15
+ def __str__(self):
16
+ return super().__str__()
17
+
18
+ class ApiError(Error):
19
+ """Error returned by the API."""
20
+ def __str__(self):
21
+ return super().__str__()
22
+
23
+ class BadRequest(ApiError):
24
+ """Bad request: invalid request, invalid input, etc."""
25
+ def __str__(self):
26
+ return super().__str__()
27
+
28
+ class AuthError(ApiError):
29
+ """Authentication error: invalid API key, invalid API secret, etc."""
30
+ def __str__(self):
31
+ return super().__str__()
32
+
33
+ class RateLimited(ApiError):
34
+ """Rate limited: the API has reached the rate limit."""
35
+ def __str__(self):
36
+ return super().__str__()
37
+
38
+ class LogicError(Error):
39
+ """Logic error: invalid assumptions, logic, or other bugs on the SDK side."""
40
+ def __str__(self):
41
+ return super().__str__()
@@ -0,0 +1,94 @@
1
+ """Shared gRPC transport primitives."""
2
+
3
+ from collections.abc import Awaitable, Callable
4
+ from dataclasses import dataclass, field
5
+ from functools import wraps
6
+ from types import TracebackType
7
+
8
+ from grpclib.client import Channel
9
+ from grpclib.const import Status
10
+ from grpclib.exceptions import GRPCError, ProtocolError, StreamTerminatedError
11
+ from typing_extensions import ParamSpec, Self, TypeVar
12
+
13
+ from .exceptions import NetworkError
14
+
15
+ # gRPC statuses that represent transport failures rather than API/business errors.
16
+ _NETWORK_STATUSES = frozenset({Status.UNAVAILABLE})
17
+
18
+ P = ParamSpec('P')
19
+ T = TypeVar('T')
20
+
21
+ def wrap_exceptions(fn: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
22
+ """Map grpclib transport failures from a gRPC call to truewire_core NetworkError.
23
+
24
+ Transport-level failures (GOAWAY, HTTP/2 protocol errors, terminated streams, and
25
+ gRPC unavailable/502 responses) are raised as NetworkError. Business/API errors,
26
+ which arrive either in a successful response payload or as a non-transport GRPCError,
27
+ are left untouched.
28
+ """
29
+ @wraps(fn)
30
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
31
+ """Await the wrapped gRPC call, normalizing transport exceptions."""
32
+ try:
33
+ return await fn(*args, **kwargs)
34
+ except (ProtocolError, StreamTerminatedError, ConnectionError, TimeoutError) as exc:
35
+ raise NetworkError(str(exc)) from exc
36
+ except GRPCError as exc:
37
+ if exc.status in _NETWORK_STATUSES:
38
+ raise NetworkError(str(exc)) from exc
39
+ raise
40
+ return wrapper
41
+
42
+ @dataclass(kw_only=True)
43
+ class GrpcClient:
44
+ """Async gRPC transport that owns a lazily opened channel.
45
+
46
+ Not frozen, unlike `GrpcEndpoint` below -- it needs a real mutable `_channel` slot
47
+ to cache into, the same shape `HttpClient._client` already uses for the identical
48
+ lazy-open/close contract on the HTTP side. `GrpcEndpoint`'s own freeze is a separate
49
+ concern (matching every other `Endpoint` composition base) and doesn't require the
50
+ `Client` object it merely holds a reference to to be frozen too.
51
+ """
52
+
53
+ host: str
54
+ port: int = 443
55
+ ssl: bool = True
56
+ _channel: Channel | None = field(default=None, init=False, repr=False)
57
+
58
+ @property
59
+ def channel(self) -> Channel:
60
+ """Return the gRPC channel, creating it on first use."""
61
+ if self._channel is None:
62
+ self._channel = Channel(self.host, self.port, ssl=self.ssl)
63
+ return self._channel
64
+
65
+ async def __aenter__(self) -> Self:
66
+ """Take ownership without connecting -- the channel opens lazily on first use,
67
+ matching `HttpClient.__aenter__`'s own contract."""
68
+ return self
69
+
70
+ async def __aexit__(
71
+ self,
72
+ exc_type: type[BaseException] | None,
73
+ exc: BaseException | None,
74
+ traceback: TracebackType | None,
75
+ ):
76
+ """Close the channel for an async client context."""
77
+ self.close()
78
+
79
+ def close(self):
80
+ """Close the open channel, if one was created."""
81
+ if self._channel is not None:
82
+ self._channel.close()
83
+ self._channel = None
84
+
85
+ @dataclass(kw_only=True, frozen=True)
86
+ class GrpcEndpoint:
87
+ """Base for every generated/hand-written gRPC module -- talks only to `client`."""
88
+
89
+ client: GrpcClient
90
+
91
+ @property
92
+ def channel(self) -> Channel:
93
+ """Return the active shared gRPC channel."""
94
+ return self.client.channel
@@ -0,0 +1,68 @@
1
+ from typing_extensions import Any, Mapping
2
+ from dataclasses import dataclass, field
3
+ import asyncio
4
+ import os
5
+ import httpx
6
+
7
+ from truewire_core.exceptions import NetworkError
8
+
9
+ def _default_limits() -> httpx.Limits:
10
+ if os.environ.get('HTTPS_PROXY') or os.environ.get('HTTP_PROXY'):
11
+ return httpx.Limits(max_keepalive_connections=0)
12
+ return httpx.Limits()
13
+
14
+ @dataclass
15
+ class HttpClient:
16
+ """Managed HTTP client, wrapping `httpx.AsyncClient`.
17
+
18
+ ### Concurrency Contract
19
+ 1. Connection: single owner via `async with`, also supports lazy no-owner use
20
+ 2. Requests: many concurrent callers OK
21
+ """
22
+ limits: httpx.Limits = field(default_factory=_default_limits)
23
+ lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False)
24
+ _client: httpx.AsyncClient | None = None
25
+
26
+ @property
27
+ async def client(self) -> httpx.AsyncClient:
28
+ async with self.lock:
29
+ if self._client is None:
30
+ self._client = await httpx.AsyncClient(limits=self.limits).__aenter__()
31
+ return self._client
32
+
33
+ async def __aenter__(self):
34
+ """Take ownership without connecting; the underlying client opens lazily on first use."""
35
+ return self
36
+
37
+ async def __aexit__(self, exc_type, exc_value, traceback):
38
+ async with self.lock:
39
+ if self._client is not None:
40
+ await self._client.__aexit__(exc_type, exc_value, traceback)
41
+ self._client = None
42
+
43
+ async def request(
44
+ self, method: str, url: str,
45
+ *,
46
+ content: httpx._types.RequestContent | None = None,
47
+ data: httpx._types.RequestData | None = None,
48
+ files: httpx._types.RequestFiles | None = None,
49
+ json: Any | None = None,
50
+ params: Mapping[str, Any] | None = None,
51
+ headers: Mapping | None = None,
52
+ cookies: httpx._types.CookieTypes | None = None,
53
+ auth: httpx._types.AuthTypes | httpx._client.UseClientDefault | None = httpx.USE_CLIENT_DEFAULT,
54
+ follow_redirects: bool | httpx._client.UseClientDefault = httpx.USE_CLIENT_DEFAULT,
55
+ timeout: httpx._types.TimeoutTypes | httpx._client.UseClientDefault = httpx.USE_CLIENT_DEFAULT,
56
+ extensions: httpx._types.RequestExtensions | None = None,
57
+ ):
58
+ try:
59
+ client = await self.client
60
+ return await client.request(
61
+ method, url, params=params, cookies=cookies, json=json,
62
+ content=content, data=data, files=files, auth=auth, follow_redirects=follow_redirects,
63
+ timeout=timeout, extensions=extensions,
64
+ headers=headers,
65
+ )
66
+ except httpx.HTTPError as e:
67
+ req = f'{method} {url}'
68
+ raise NetworkError(f'Error sending request to {req}', *e.args) from e
File without changes
@@ -0,0 +1,2 @@
1
+ import lazy_loader as lazy
2
+ __getattr__, __dir__, __all__ = lazy.attach_stub(__name__, __file__)
@@ -0,0 +1,6 @@
1
+ from .base import TimeConverter
2
+ from .date import DateConverter
3
+ from .iso import IsoConverter
4
+ from .ms import EpochConverter
5
+
6
+ __all__ = ['TimeConverter', 'DateConverter', 'IsoConverter', 'EpochConverter']
@@ -0,0 +1,20 @@
1
+ from typing_extensions import TypeVar, Generic
2
+ from abc import ABC, abstractmethod
3
+ from datetime import datetime
4
+
5
+ T = TypeVar('T')
6
+
7
+ class TimeConverter(ABC, Generic[T]):
8
+ """Abstract base class for time conversion between wire format and `datetime`."""
9
+
10
+ @abstractmethod
11
+ def parse(self, value: T) -> datetime:
12
+ ...
13
+
14
+ @abstractmethod
15
+ def dump(self, dt: datetime) -> T:
16
+ ...
17
+
18
+ @abstractmethod
19
+ def now(self) -> T:
20
+ ...
@@ -0,0 +1,37 @@
1
+ from dataclasses import dataclass
2
+ from datetime import date, datetime
3
+
4
+ @dataclass(kw_only=True)
5
+ class DateConverter:
6
+ """Converter for a plain calendar date, with no time component.
7
+
8
+ Not a `TimeConverter` -- that base class's contract is fixed to `datetime` (see
9
+ `base.py`), and a calendar date has no time-of-day to round-trip through one. Widening
10
+ `TimeConverter` itself to cover this would ripple its return type into every existing
11
+ subclass for the sake of this one converter; standing alone keeps the blast radius to
12
+ just this file.
13
+ """
14
+
15
+ pattern: str = '%Y-%m-%d'
16
+ """`datetime.strptime`/`strftime` directive for the wire string, e.g. `'%Y%m%d'` for a
17
+ compact `YYYYMMDD` date with no separators (`"date": "20260101"`). Defaults to RFC
18
+ 3339's `YYYY-MM-DD`. Generic on the pattern the same way `EpochConverter` is generic on
19
+ `unit`/`tz` -- an API's exact wire encoding is a detail each project's own
20
+ `core_package` supplies, not something this converter should special-case per format.
21
+ """
22
+
23
+ def parse(self, value: str) -> date:
24
+ """Parse a wire calendar date.
25
+
26
+ Args:
27
+ value: The wire date, e.g. `'2026-08-03'` for the default pattern.
28
+ """
29
+ return datetime.strptime(value, self.pattern).date()
30
+
31
+ def dump(self, d: date) -> str:
32
+ """Render a `date` back to the wire pattern."""
33
+ return d.strftime(self.pattern)
34
+
35
+ def now(self) -> date:
36
+ """Today's date."""
37
+ return date.today()