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.
- truewire_core-0.1.0/LICENSE +21 -0
- truewire_core-0.1.0/PKG-INFO +159 -0
- truewire_core-0.1.0/README.md +129 -0
- truewire_core-0.1.0/pyproject.toml +40 -0
- truewire_core-0.1.0/setup.cfg +4 -0
- truewire_core-0.1.0/src/truewire_core/__init__.py +2 -0
- truewire_core-0.1.0/src/truewire_core/__init__.pyi +14 -0
- truewire_core-0.1.0/src/truewire_core/exceptions.py +41 -0
- truewire_core-0.1.0/src/truewire_core/grpc.py +94 -0
- truewire_core-0.1.0/src/truewire_core/http.py +68 -0
- truewire_core-0.1.0/src/truewire_core/py.typed +0 -0
- truewire_core-0.1.0/src/truewire_core/times/__init__.py +2 -0
- truewire_core-0.1.0/src/truewire_core/times/__init__.pyi +6 -0
- truewire_core-0.1.0/src/truewire_core/times/base.py +20 -0
- truewire_core-0.1.0/src/truewire_core/times/date.py +37 -0
- truewire_core-0.1.0/src/truewire_core/times/iso.py +45 -0
- truewire_core-0.1.0/src/truewire_core/times/ms.py +65 -0
- truewire_core-0.1.0/src/truewire_core/util/__init__.py +2 -0
- truewire_core-0.1.0/src/truewire_core/util/__init__.pyi +11 -0
- truewire_core-0.1.0/src/truewire_core/util/misc.py +16 -0
- truewire_core-0.1.0/src/truewire_core/util/paging.py +131 -0
- truewire_core-0.1.0/src/truewire_core/util/rate_limit.py +49 -0
- truewire_core-0.1.0/src/truewire_core/util/streams.py +95 -0
- truewire_core-0.1.0/src/truewire_core/validation.py +52 -0
- truewire_core-0.1.0/src/truewire_core/ws/__init__.py +2 -0
- truewire_core-0.1.0/src/truewire_core/ws/__init__.pyi +24 -0
- truewire_core-0.1.0/src/truewire_core/ws/rpc.py +54 -0
- truewire_core-0.1.0/src/truewire_core/ws/serial.py +53 -0
- truewire_core-0.1.0/src/truewire_core/ws/socket.py +232 -0
- truewire_core-0.1.0/src/truewire_core/ws/streams.py +134 -0
- truewire_core-0.1.0/src/truewire_core/ws/streams_rpc.py +168 -0
- truewire_core-0.1.0/src/truewire_core.egg-info/PKG-INFO +159 -0
- truewire_core-0.1.0/src/truewire_core.egg-info/SOURCES.txt +41 -0
- truewire_core-0.1.0/src/truewire_core.egg-info/dependency_links.txt +1 -0
- truewire_core-0.1.0/src/truewire_core.egg-info/requires.txt +15 -0
- truewire_core-0.1.0/src/truewire_core.egg-info/top_level.txt +1 -0
- truewire_core-0.1.0/test/test_exceptions.py +58 -0
- truewire_core-0.1.0/test/test_grpc.py +58 -0
- truewire_core-0.1.0/test/test_http.py +115 -0
- truewire_core-0.1.0/test/test_paging.py +110 -0
- truewire_core-0.1.0/test/test_socket.py +158 -0
- truewire_core-0.1.0/test/test_times.py +181 -0
- 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
|
+
[](https://pypi.org/project/truewire-core/)
|
|
36
|
+
[](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
|
+
[](https://pypi.org/project/truewire-core/)
|
|
6
|
+
[](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,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,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()
|