spitzeisen 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.
- spitzeisen-0.1.0/PKG-INFO +182 -0
- spitzeisen-0.1.0/README.md +157 -0
- spitzeisen-0.1.0/examples/build_sync.py +102 -0
- spitzeisen-0.1.0/examples/demo.py +91 -0
- spitzeisen-0.1.0/examples/inventory_sdk.py +75 -0
- spitzeisen-0.1.0/examples/responses/current_weather.json +47 -0
- spitzeisen-0.1.0/examples/responses/forecast.json +92 -0
- spitzeisen-0.1.0/examples/tests/conftest.py +42 -0
- spitzeisen-0.1.0/examples/tests/test_client.py +82 -0
- spitzeisen-0.1.0/examples/tests/test_custom_strategies.py +34 -0
- spitzeisen-0.1.0/examples/weather_sdk/__init__.py +51 -0
- spitzeisen-0.1.0/examples/weather_sdk/_async/__init__.py +1 -0
- spitzeisen-0.1.0/examples/weather_sdk/_async/api/__init__.py +1 -0
- spitzeisen-0.1.0/examples/weather_sdk/_async/api/current_weather.py +104 -0
- spitzeisen-0.1.0/examples/weather_sdk/_async/api/forecast.py +111 -0
- spitzeisen-0.1.0/examples/weather_sdk/_async/base.py +45 -0
- spitzeisen-0.1.0/examples/weather_sdk/_async/client.py +31 -0
- spitzeisen-0.1.0/examples/weather_sdk/_async/config.py +56 -0
- spitzeisen-0.1.0/examples/weather_sdk/_sync/__init__.py +3 -0
- spitzeisen-0.1.0/examples/weather_sdk/_sync/api/__init__.py +3 -0
- spitzeisen-0.1.0/examples/weather_sdk/_sync/api/current_weather.py +106 -0
- spitzeisen-0.1.0/examples/weather_sdk/_sync/api/forecast.py +113 -0
- spitzeisen-0.1.0/examples/weather_sdk/_sync/base.py +47 -0
- spitzeisen-0.1.0/examples/weather_sdk/_sync/client.py +33 -0
- spitzeisen-0.1.0/examples/weather_sdk/_sync/config.py +58 -0
- spitzeisen-0.1.0/examples/weather_sdk/models.py +263 -0
- spitzeisen-0.1.0/examples/weather_sdk/params.py +16 -0
- spitzeisen-0.1.0/pyproject.toml +144 -0
- spitzeisen-0.1.0/src/spitzeisen/__init__.py +108 -0
- spitzeisen-0.1.0/src/spitzeisen/_async/__init__.py +1 -0
- spitzeisen-0.1.0/src/spitzeisen/_async/config.py +98 -0
- spitzeisen-0.1.0/src/spitzeisen/_async/core.py +519 -0
- spitzeisen-0.1.0/src/spitzeisen/_sync/__init__.py +3 -0
- spitzeisen-0.1.0/src/spitzeisen/_sync/config.py +100 -0
- spitzeisen-0.1.0/src/spitzeisen/_sync/core.py +517 -0
- spitzeisen-0.1.0/src/spitzeisen/auth.py +76 -0
- spitzeisen-0.1.0/src/spitzeisen/concurrency.py +48 -0
- spitzeisen-0.1.0/src/spitzeisen/exceptions.py +101 -0
- spitzeisen-0.1.0/src/spitzeisen/limits.py +105 -0
- spitzeisen-0.1.0/src/spitzeisen/models.py +23 -0
- spitzeisen-0.1.0/src/spitzeisen/operations.py +52 -0
- spitzeisen-0.1.0/src/spitzeisen/pagination.py +260 -0
- spitzeisen-0.1.0/src/spitzeisen/params.py +103 -0
- spitzeisen-0.1.0/src/spitzeisen/py.typed +0 -0
- spitzeisen-0.1.0/src/spitzeisen/testing/__init__.py +13 -0
- spitzeisen-0.1.0/src/spitzeisen/testing/harness.py +281 -0
- spitzeisen-0.1.0/src/spitzeisen/testing/plugin.py +63 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: spitzeisen
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A shared async/sync core for REST API SDKs
|
|
5
|
+
Keywords: api,client,rest,rate-limiting,sdk,async
|
|
6
|
+
Author: dodofarm
|
|
7
|
+
Author-email: dodofarm <ea01052002@gmail.com>
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Dist: httpx2>=2.7.0
|
|
15
|
+
Requires-Dist: pydantic>=2.11.7
|
|
16
|
+
Requires-Dist: steindamm>=0.11.1
|
|
17
|
+
Requires-Dist: structlog>=25.1.0
|
|
18
|
+
Requires-Dist: httpx2-pytest>=2.0.0 ; extra == 'testing'
|
|
19
|
+
Requires-Dist: pytest-asyncio>=1.0.0 ; extra == 'testing'
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Project-URL: Homepage, https://github.com/feuerstein-org/spitzeisen
|
|
22
|
+
Project-URL: Repository, https://github.com/feuerstein-org/spitzeisen
|
|
23
|
+
Provides-Extra: testing
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# Spitzeisen
|
|
27
|
+
|
|
28
|
+
Spitzeisen (Pickaxe in German) is a Python framework for building REST API clients, with async and sync support. Spitzeisen handles HTTP requests, retries, rate limiting, and pagination. You write the endpoint methods and Pydantic models for your API. It's intended for simple data APIs e.g. financial APIs, product APIs etc.
|
|
29
|
+
|
|
30
|
+
This project is in Alpha, I've built this to simplify/unite the code from the various financial APIs I'm working on (e.g. [eodhd-py](https://github.com/feuerstein-org/eodhd-py) and [massive-api](https://github.com/feuerstein-org/massive-api)). Feel free to use it as a building block for your own SDKs, better documentation is currently being worked on. In the future I want to add the possibility to generate the entire SDK off of OpenAPI and Smithy models but that's just an idea for now.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
Requires Python 3.12 or newer.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install spitzeisen
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The library is still in alpha, so the API may change.
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
> Note: It is **strongly** recommended to review the example weather SDK in `examples`, the below is just a very simple showcase, it is advised to create endpoint groups within one SDK Baseclass.
|
|
45
|
+
|
|
46
|
+
Here's a client for an API that returns a single item from `/item/{item_id}`:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import asyncio
|
|
50
|
+
|
|
51
|
+
from spitzeisen import (
|
|
52
|
+
BearerHeader,
|
|
53
|
+
SpitzeisenApi,
|
|
54
|
+
SpitzeisenConfig,
|
|
55
|
+
SpitzeisenModel,
|
|
56
|
+
SpitzeisenOperationSpec,
|
|
57
|
+
serialize_path_param,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Item(SpitzeisenModel):
|
|
62
|
+
id: str
|
|
63
|
+
name: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_ITEM = SpitzeisenOperationSpec(path="/item/{item_id}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ItemsApi(SpitzeisenApi):
|
|
70
|
+
async def get_item(self, item_id: str) -> Item:
|
|
71
|
+
item = await self.get_object(_ITEM, item_id=serialize_path_param(item_id))
|
|
72
|
+
return self.validate_record(item, Item, mode="raise")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def main():
|
|
76
|
+
config = SpitzeisenConfig(
|
|
77
|
+
base_url="https://api.example.com", # Replace with your API's URL
|
|
78
|
+
auth=BearerHeader("YOUR_API_KEY"),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
async with ItemsApi(config) as api:
|
|
82
|
+
item = await api.get_item("123")
|
|
83
|
+
print(item.id, item.name)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
asyncio.run(main())
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`serialize_path_param` URL-encodes the item ID before inserting it into the path. `get_object` fetches the JSON response. `validate_record` converts it into an `Item` model. With `mode="raise"`, a response that doesn't match the model raises a Pydantic validation error.
|
|
90
|
+
|
|
91
|
+
For a sync client, subclass `SyncSpitzeisenApi` and use `SyncSpitzeisenConfig`. Write regular methods with `with` blocks and no `await`. The [weather example](examples/weather_sdk) includes both versions. It's recommended to generate the sync counterparts as much as possible using unasync, the Weather example already does this.
|
|
92
|
+
|
|
93
|
+
## Config
|
|
94
|
+
|
|
95
|
+
Authentication, retries, timeouts, and rate limits are configured through `SpitzeisenConfig`.
|
|
96
|
+
For example:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from spitzeisen import BearerHeader, SpitzeisenConfig, single_bucket
|
|
100
|
+
|
|
101
|
+
config = SpitzeisenConfig(
|
|
102
|
+
base_url="https://api.example.com",
|
|
103
|
+
auth=BearerHeader("YOUR_API_KEY"),
|
|
104
|
+
limiter=single_bucket("example-account", 60, 60), # 60 tokens, refilled over 60 seconds
|
|
105
|
+
max_retries=3,
|
|
106
|
+
request_timeout=30,
|
|
107
|
+
on_validation_error="raise",
|
|
108
|
+
)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Rate limiting uses [steindamm](https://github.com/feuerstein-org/steindamm).
|
|
112
|
+
The bucket above starts full and refills gradually. Each request costs one token by default, including retries and page requests. Pass a Redis connection to `single_bucket` to share the limit across processes.
|
|
113
|
+
Without a limiter, requests are unlimited. Sync clients use `sync_single_bucket`.
|
|
114
|
+
|
|
115
|
+
For authentication, you can also use `HeaderKey` or `QueryParamAuth`. The default is `NoAuth`.
|
|
116
|
+
|
|
117
|
+
Those are by far not the only ways to authenticate which are supported. You can easily implement your own authentication strategy, same applies to for example pagination, to do that simply pass your custom strategy in the `SpitzeisenOperationSpec`, see [here](examples/inventory_sdk.py) for an example pagination strategy.
|
|
118
|
+
|
|
119
|
+
Retryable requests use exponential backoff for HTTP 429, server errors, timeouts, and connection errors. POST and PATCH requests are not retried unless you opt in with `retryable=True` on the operation.
|
|
120
|
+
|
|
121
|
+
Endpoint groups created with `self.api(GroupClass)` share the parent's config, HTTP connection, and rate limiter. Use context managers to close connections when you're done. A supplied HTTP client stays open unless you set `owns_http_client=True`.
|
|
122
|
+
|
|
123
|
+
## Testing
|
|
124
|
+
|
|
125
|
+
Install the testing helpers with `pip install "spitzeisen[testing]"`, then enable the pytest plugin in your root `conftest.py`:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
pytest_plugins = ["spitzeisen.testing.plugin"]
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`api_factory` creates and closes your client. `httpx2_mock` supplies responses without calling the real API.
|
|
132
|
+
Using the `ItemsApi` class above:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
import pytest
|
|
136
|
+
from spitzeisen import SpitzeisenConfig
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@pytest.mark.asyncio
|
|
140
|
+
async def test_get_item(api_factory, httpx2_mock):
|
|
141
|
+
httpx2_mock.add_response(
|
|
142
|
+
url="https://api.example.com/item/123",
|
|
143
|
+
json={"id": "123", "name": "Example item"},
|
|
144
|
+
)
|
|
145
|
+
api = await api_factory.create(
|
|
146
|
+
ItemsApi,
|
|
147
|
+
config=SpitzeisenConfig(base_url="https://api.example.com"),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
item = await api.get_item("123")
|
|
151
|
+
assert item.name == "Example item"
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
For sync clients, use `sync_api_factory`. See the [example tests](examples/tests) for more.
|
|
155
|
+
|
|
156
|
+
## Examples
|
|
157
|
+
|
|
158
|
+
- [Weather SDK](examples/weather_sdk): endpoint groups, response models, and async and sync clients.
|
|
159
|
+
- [Inventory SDK](examples/inventory_sdk.py): custom pagination and record iteration.
|
|
160
|
+
- [Offline demo](examples/demo.py): runs both weather clients with saved responses.
|
|
161
|
+
|
|
162
|
+
To run the demo from the repository root:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
mise run install
|
|
166
|
+
mise run demo-example
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The demo needs no API key or network connection.
|
|
170
|
+
|
|
171
|
+
## Development
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
mise run install
|
|
175
|
+
mise run build-sync
|
|
176
|
+
mise run lint
|
|
177
|
+
mise run test
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The sync code is generated from the async code with `unasync`.
|
|
181
|
+
After editing the async implementation, run `mise run build-sync` to update both the library and weather example.
|
|
182
|
+
`mise run check-sync` checks that the generated files are up to date.
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# Spitzeisen
|
|
2
|
+
|
|
3
|
+
Spitzeisen (Pickaxe in German) is a Python framework for building REST API clients, with async and sync support. Spitzeisen handles HTTP requests, retries, rate limiting, and pagination. You write the endpoint methods and Pydantic models for your API. It's intended for simple data APIs e.g. financial APIs, product APIs etc.
|
|
4
|
+
|
|
5
|
+
This project is in Alpha, I've built this to simplify/unite the code from the various financial APIs I'm working on (e.g. [eodhd-py](https://github.com/feuerstein-org/eodhd-py) and [massive-api](https://github.com/feuerstein-org/massive-api)). Feel free to use it as a building block for your own SDKs, better documentation is currently being worked on. In the future I want to add the possibility to generate the entire SDK off of OpenAPI and Smithy models but that's just an idea for now.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Requires Python 3.12 or newer.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install spitzeisen
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The library is still in alpha, so the API may change.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
> Note: It is **strongly** recommended to review the example weather SDK in `examples`, the below is just a very simple showcase, it is advised to create endpoint groups within one SDK Baseclass.
|
|
20
|
+
|
|
21
|
+
Here's a client for an API that returns a single item from `/item/{item_id}`:
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import asyncio
|
|
25
|
+
|
|
26
|
+
from spitzeisen import (
|
|
27
|
+
BearerHeader,
|
|
28
|
+
SpitzeisenApi,
|
|
29
|
+
SpitzeisenConfig,
|
|
30
|
+
SpitzeisenModel,
|
|
31
|
+
SpitzeisenOperationSpec,
|
|
32
|
+
serialize_path_param,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Item(SpitzeisenModel):
|
|
37
|
+
id: str
|
|
38
|
+
name: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_ITEM = SpitzeisenOperationSpec(path="/item/{item_id}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ItemsApi(SpitzeisenApi):
|
|
45
|
+
async def get_item(self, item_id: str) -> Item:
|
|
46
|
+
item = await self.get_object(_ITEM, item_id=serialize_path_param(item_id))
|
|
47
|
+
return self.validate_record(item, Item, mode="raise")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def main():
|
|
51
|
+
config = SpitzeisenConfig(
|
|
52
|
+
base_url="https://api.example.com", # Replace with your API's URL
|
|
53
|
+
auth=BearerHeader("YOUR_API_KEY"),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
async with ItemsApi(config) as api:
|
|
57
|
+
item = await api.get_item("123")
|
|
58
|
+
print(item.id, item.name)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
asyncio.run(main())
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`serialize_path_param` URL-encodes the item ID before inserting it into the path. `get_object` fetches the JSON response. `validate_record` converts it into an `Item` model. With `mode="raise"`, a response that doesn't match the model raises a Pydantic validation error.
|
|
65
|
+
|
|
66
|
+
For a sync client, subclass `SyncSpitzeisenApi` and use `SyncSpitzeisenConfig`. Write regular methods with `with` blocks and no `await`. The [weather example](examples/weather_sdk) includes both versions. It's recommended to generate the sync counterparts as much as possible using unasync, the Weather example already does this.
|
|
67
|
+
|
|
68
|
+
## Config
|
|
69
|
+
|
|
70
|
+
Authentication, retries, timeouts, and rate limits are configured through `SpitzeisenConfig`.
|
|
71
|
+
For example:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from spitzeisen import BearerHeader, SpitzeisenConfig, single_bucket
|
|
75
|
+
|
|
76
|
+
config = SpitzeisenConfig(
|
|
77
|
+
base_url="https://api.example.com",
|
|
78
|
+
auth=BearerHeader("YOUR_API_KEY"),
|
|
79
|
+
limiter=single_bucket("example-account", 60, 60), # 60 tokens, refilled over 60 seconds
|
|
80
|
+
max_retries=3,
|
|
81
|
+
request_timeout=30,
|
|
82
|
+
on_validation_error="raise",
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Rate limiting uses [steindamm](https://github.com/feuerstein-org/steindamm).
|
|
87
|
+
The bucket above starts full and refills gradually. Each request costs one token by default, including retries and page requests. Pass a Redis connection to `single_bucket` to share the limit across processes.
|
|
88
|
+
Without a limiter, requests are unlimited. Sync clients use `sync_single_bucket`.
|
|
89
|
+
|
|
90
|
+
For authentication, you can also use `HeaderKey` or `QueryParamAuth`. The default is `NoAuth`.
|
|
91
|
+
|
|
92
|
+
Those are by far not the only ways to authenticate which are supported. You can easily implement your own authentication strategy, same applies to for example pagination, to do that simply pass your custom strategy in the `SpitzeisenOperationSpec`, see [here](examples/inventory_sdk.py) for an example pagination strategy.
|
|
93
|
+
|
|
94
|
+
Retryable requests use exponential backoff for HTTP 429, server errors, timeouts, and connection errors. POST and PATCH requests are not retried unless you opt in with `retryable=True` on the operation.
|
|
95
|
+
|
|
96
|
+
Endpoint groups created with `self.api(GroupClass)` share the parent's config, HTTP connection, and rate limiter. Use context managers to close connections when you're done. A supplied HTTP client stays open unless you set `owns_http_client=True`.
|
|
97
|
+
|
|
98
|
+
## Testing
|
|
99
|
+
|
|
100
|
+
Install the testing helpers with `pip install "spitzeisen[testing]"`, then enable the pytest plugin in your root `conftest.py`:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
pytest_plugins = ["spitzeisen.testing.plugin"]
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`api_factory` creates and closes your client. `httpx2_mock` supplies responses without calling the real API.
|
|
107
|
+
Using the `ItemsApi` class above:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
import pytest
|
|
111
|
+
from spitzeisen import SpitzeisenConfig
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@pytest.mark.asyncio
|
|
115
|
+
async def test_get_item(api_factory, httpx2_mock):
|
|
116
|
+
httpx2_mock.add_response(
|
|
117
|
+
url="https://api.example.com/item/123",
|
|
118
|
+
json={"id": "123", "name": "Example item"},
|
|
119
|
+
)
|
|
120
|
+
api = await api_factory.create(
|
|
121
|
+
ItemsApi,
|
|
122
|
+
config=SpitzeisenConfig(base_url="https://api.example.com"),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
item = await api.get_item("123")
|
|
126
|
+
assert item.name == "Example item"
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
For sync clients, use `sync_api_factory`. See the [example tests](examples/tests) for more.
|
|
130
|
+
|
|
131
|
+
## Examples
|
|
132
|
+
|
|
133
|
+
- [Weather SDK](examples/weather_sdk): endpoint groups, response models, and async and sync clients.
|
|
134
|
+
- [Inventory SDK](examples/inventory_sdk.py): custom pagination and record iteration.
|
|
135
|
+
- [Offline demo](examples/demo.py): runs both weather clients with saved responses.
|
|
136
|
+
|
|
137
|
+
To run the demo from the repository root:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
mise run install
|
|
141
|
+
mise run demo-example
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The demo needs no API key or network connection.
|
|
145
|
+
|
|
146
|
+
## Development
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
mise run install
|
|
150
|
+
mise run build-sync
|
|
151
|
+
mise run lint
|
|
152
|
+
mise run test
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The sync code is generated from the async code with `unasync`.
|
|
156
|
+
After editing the async implementation, run `mise run build-sync` to update both the library and weather example.
|
|
157
|
+
`mise run check-sync` checks that the generated files are up to date.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generate the blocking weather SDK from its async implementation.
|
|
3
|
+
|
|
4
|
+
Run `python build_sync.py` to regenerate, or add `python build_sync.py --check` to CI.
|
|
5
|
+
Copy this script with the SDK and adjust ASYNC_DIR and SYNC_DIR for your package layout.
|
|
6
|
+
The development environment needs unasync and ruff, the script uses no core build helpers.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import unasync
|
|
17
|
+
|
|
18
|
+
ROOT = Path(__file__).resolve().parent
|
|
19
|
+
# Keep code shared by the async and sync surfaces outside these folders.
|
|
20
|
+
ASYNC_DIR = ROOT / "weather_sdk/_async"
|
|
21
|
+
SYNC_DIR = ROOT / "weather_sdk/_sync"
|
|
22
|
+
|
|
23
|
+
# unasync converts Async-prefixed class names to Sync, public exports drop the Async prefix.
|
|
24
|
+
# E.g. it's "Api" and "SyncApi" instead of "AsyncApi" and "SyncApi". Async is our main usecase.
|
|
25
|
+
REPLACEMENTS = {
|
|
26
|
+
# Route generated imports to the matching module tree.
|
|
27
|
+
"_async": "_sync",
|
|
28
|
+
# httpx2 names its sync client `Client`.
|
|
29
|
+
"AsyncClient": "Client",
|
|
30
|
+
"aclose": "close",
|
|
31
|
+
# Core types also use unprefixed async names.
|
|
32
|
+
"SpitzeisenApi": "SyncSpitzeisenApi",
|
|
33
|
+
"SpitzeisenConfig": "SyncSpitzeisenConfig",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
HEADER = f"""# Generated from {ASYNC_DIR.relative_to(ROOT)}/ by build_sync.py -- do not edit.
|
|
37
|
+
# Change the async module and run `python build_sync.py`.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def generate(destination: Path) -> None:
|
|
42
|
+
"""Convert async modules, check imports and names, then format using the project's Ruff configuration."""
|
|
43
|
+
sources = sorted(ASYNC_DIR.rglob("*.py"))
|
|
44
|
+
if not sources:
|
|
45
|
+
msg = f"No Python modules found in {ASYNC_DIR}"
|
|
46
|
+
raise RuntimeError(msg)
|
|
47
|
+
rule = unasync.Rule(
|
|
48
|
+
fromdir=f"{ASYNC_DIR}/",
|
|
49
|
+
todir=f"{destination}/",
|
|
50
|
+
additional_replacements=REPLACEMENTS,
|
|
51
|
+
)
|
|
52
|
+
unasync.unasync_files([str(path) for path in sources], [rule])
|
|
53
|
+
for path in sorted(destination.rglob("*.py")):
|
|
54
|
+
path.write_text(HEADER + path.read_text())
|
|
55
|
+
subprocess.run( # noqa: S603
|
|
56
|
+
[sys.executable, "-m", "ruff", "check", "--select", "I,F", "--fix", "--quiet", str(destination)],
|
|
57
|
+
cwd=ROOT,
|
|
58
|
+
check=True,
|
|
59
|
+
)
|
|
60
|
+
subprocess.run( # noqa: S603
|
|
61
|
+
[sys.executable, "-m", "ruff", "format", "--quiet", str(destination)],
|
|
62
|
+
cwd=ROOT,
|
|
63
|
+
check=True,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def stale_files(expected: Path) -> list[Path]:
|
|
68
|
+
"""Find missing, changed, or extra Python modules in the generated package."""
|
|
69
|
+
wanted = {path.relative_to(expected) for path in expected.rglob("*.py")}
|
|
70
|
+
actual = {path.relative_to(SYNC_DIR) for path in SYNC_DIR.rglob("*.py")}
|
|
71
|
+
changed = {path for path in wanted & actual if (expected / path).read_bytes() != (SYNC_DIR / path).read_bytes()}
|
|
72
|
+
return sorted((wanted ^ actual) | changed)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main() -> int:
|
|
76
|
+
"""Regenerate the sync package, or verify it without changing files."""
|
|
77
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
78
|
+
parser.add_argument("--check", action="store_true", help="fail if generated files are stale")
|
|
79
|
+
args = parser.parse_args()
|
|
80
|
+
with tempfile.TemporaryDirectory(dir=ROOT) as temporary:
|
|
81
|
+
# Preserve the package context so Ruff groups internal imports consistently.
|
|
82
|
+
expected = Path(temporary) / SYNC_DIR.relative_to(ROOT)
|
|
83
|
+
expected.parent.mkdir(parents=True)
|
|
84
|
+
(expected.parent / "__init__.py").touch()
|
|
85
|
+
generate(expected)
|
|
86
|
+
if args.check:
|
|
87
|
+
stale = stale_files(expected)
|
|
88
|
+
if stale:
|
|
89
|
+
print("Sync package is stale; run `python build_sync.py`:")
|
|
90
|
+
for path in stale:
|
|
91
|
+
print(f" {path}")
|
|
92
|
+
return 1
|
|
93
|
+
print("Sync package is up to date")
|
|
94
|
+
else:
|
|
95
|
+
shutil.rmtree(SYNC_DIR, ignore_errors=True)
|
|
96
|
+
shutil.copytree(expected, SYNC_DIR)
|
|
97
|
+
print(f"Generated {len(list(SYNC_DIR.rglob('*.py')))} modules in {SYNC_DIR.relative_to(ROOT)}")
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
sys.exit(main())
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Run both weather clients end to end without making a network request."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import httpx2
|
|
8
|
+
from weather_sdk import (
|
|
9
|
+
CurrentWeather,
|
|
10
|
+
Forecast,
|
|
11
|
+
SyncWeatherApi,
|
|
12
|
+
SyncWeatherApiConfig,
|
|
13
|
+
WeatherApi,
|
|
14
|
+
WeatherApiConfig,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# This is to return fake data and not call the actual API - can be ignored
|
|
19
|
+
def mock_response(request: httpx2.Request) -> httpx2.Response:
|
|
20
|
+
"""Return a saved response for either client's demo request."""
|
|
21
|
+
responses = Path(__file__).parent / "responses"
|
|
22
|
+
filename = {"/data/2.5/weather": "current_weather.json", "/data/2.5/forecast": "forecast.json"}[request.url.path]
|
|
23
|
+
return httpx2.Response(200, json=json.loads((responses / filename).read_text()))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def print_weather(weather: CurrentWeather | None, forecast: Forecast | None) -> None:
|
|
27
|
+
"""Display the shared response models returned by both clients."""
|
|
28
|
+
if weather is not None:
|
|
29
|
+
print(f"city: {weather.name}")
|
|
30
|
+
print(f"temperature: {weather.measurements.temperature} °C")
|
|
31
|
+
print(f"condition: {weather.conditions[0].description}")
|
|
32
|
+
if forecast is not None:
|
|
33
|
+
print(f"forecast city: {forecast.city.name}")
|
|
34
|
+
for reading in forecast.readings:
|
|
35
|
+
print(
|
|
36
|
+
f"forecast: {reading.time.isoformat()} - "
|
|
37
|
+
f"{reading.measurements.temperature} °C - {reading.conditions[0].description}"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Asynchronous example
|
|
42
|
+
async def main_async() -> None:
|
|
43
|
+
"""Fetch current conditions and forecasts with the async client."""
|
|
44
|
+
config = WeatherApiConfig(
|
|
45
|
+
api_key="example-key",
|
|
46
|
+
on_validation_error="skip", # If a single record from the API fails validation - skip it
|
|
47
|
+
validate_inputs=True, # Check inputs before sending request
|
|
48
|
+
http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(mock_response)),
|
|
49
|
+
owns_http_client=True, # Spitzeisen owns it and will close it when no longer needed
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
async with WeatherApi(config) as api:
|
|
53
|
+
weather = await api.current_weather_api.get_current_weather(
|
|
54
|
+
latitude=52.52,
|
|
55
|
+
longitude=13.405,
|
|
56
|
+
units="metric",
|
|
57
|
+
language="en",
|
|
58
|
+
)
|
|
59
|
+
forecast = await api.forecast_api.get_forecast("Berlin", units="metric")
|
|
60
|
+
|
|
61
|
+
print_weather(weather, forecast)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Synchronous example
|
|
65
|
+
def main_sync() -> None:
|
|
66
|
+
"""Fetch the same models with the blocking client."""
|
|
67
|
+
config = SyncWeatherApiConfig(
|
|
68
|
+
api_key="example-key",
|
|
69
|
+
on_validation_error="skip", # If a single record from the API fails validation - skip it
|
|
70
|
+
validate_inputs=True, # Check inputs before sending request
|
|
71
|
+
http_client=httpx2.Client(transport=httpx2.MockTransport(mock_response)),
|
|
72
|
+
owns_http_client=True, # Spitzeisen owns it and will close it when no longer needed
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
with SyncWeatherApi(config) as api:
|
|
76
|
+
weather = api.current_weather_api.get_current_weather(
|
|
77
|
+
latitude=52.52,
|
|
78
|
+
longitude=13.405,
|
|
79
|
+
units="metric",
|
|
80
|
+
language="en",
|
|
81
|
+
)
|
|
82
|
+
forecast = api.forecast_api.get_forecast("Berlin", units="metric")
|
|
83
|
+
|
|
84
|
+
print_weather(weather, forecast)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
print("Async client")
|
|
89
|
+
asyncio.run(main_async())
|
|
90
|
+
print("Sync client")
|
|
91
|
+
main_sync()
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""A dummy inventory SDK yielding records through custom bookmark pagination."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
|
|
5
|
+
from spitzeisen import (
|
|
6
|
+
JsonObject,
|
|
7
|
+
JsonValue,
|
|
8
|
+
QueryParams,
|
|
9
|
+
ResponseShapeError,
|
|
10
|
+
SpitzeisenApi,
|
|
11
|
+
SpitzeisenModel,
|
|
12
|
+
SpitzeisenOperationSpec,
|
|
13
|
+
extract_records,
|
|
14
|
+
serialize_query_map,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BookmarkPagination:
|
|
19
|
+
"""Read `items` and send each response's `bookmark` as the next request's `after`."""
|
|
20
|
+
|
|
21
|
+
def first_params(self, params: QueryParams) -> QueryParams:
|
|
22
|
+
"""Start with the SDK method's query parameters."""
|
|
23
|
+
return list(params)
|
|
24
|
+
|
|
25
|
+
def records(self, page: JsonValue) -> list[JsonObject]:
|
|
26
|
+
"""Extract raw records without validating inventory model fields."""
|
|
27
|
+
return extract_records(page, "items")
|
|
28
|
+
|
|
29
|
+
def next_params(
|
|
30
|
+
self,
|
|
31
|
+
page: JsonValue,
|
|
32
|
+
records: list[JsonObject], # noqa: ARG002 - required by the pagination protocol
|
|
33
|
+
params: QueryParams,
|
|
34
|
+
) -> QueryParams | None:
|
|
35
|
+
"""Stop without a bookmark, otherwise replace `after` and preserve other parameters."""
|
|
36
|
+
if not isinstance(page, dict):
|
|
37
|
+
msg = "expected an inventory response object"
|
|
38
|
+
raise ResponseShapeError(msg)
|
|
39
|
+
bookmark = page.get("bookmark")
|
|
40
|
+
if bookmark is None:
|
|
41
|
+
return None
|
|
42
|
+
if not isinstance(bookmark, str) or not bookmark:
|
|
43
|
+
msg = "expected a non-empty bookmark string"
|
|
44
|
+
raise ResponseShapeError(msg)
|
|
45
|
+
return [*(param for param in params if param.name != "after"), *serialize_query_map({"after": bookmark})]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class InventoryItem(SpitzeisenModel):
|
|
49
|
+
"""A model returned by the inventory endpoint's typed method."""
|
|
50
|
+
|
|
51
|
+
id: str
|
|
52
|
+
stock: int
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_LIST_ITEMS = SpitzeisenOperationSpec(path="/items", pagination=BookmarkPagination())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class InventoryApi(SpitzeisenApi):
|
|
59
|
+
"""Iterate or collect inventory using a standard SpitzeisenConfig and bookmark pagination."""
|
|
60
|
+
|
|
61
|
+
async def iter_items_raw(self) -> AsyncIterator[JsonObject]:
|
|
62
|
+
"""Yield raw items, fetching the next page as iteration advances."""
|
|
63
|
+
async for record in self.iter_records(_LIST_ITEMS):
|
|
64
|
+
yield record
|
|
65
|
+
|
|
66
|
+
async def iter_items(self) -> AsyncIterator[InventoryItem]:
|
|
67
|
+
"""Validate and yield each item, following the config's validation policy."""
|
|
68
|
+
async for record in self.iter_items_raw():
|
|
69
|
+
item = self.validate_record(record, InventoryItem)
|
|
70
|
+
if item is not None:
|
|
71
|
+
yield item
|
|
72
|
+
|
|
73
|
+
async def list_items(self) -> list[InventoryItem]:
|
|
74
|
+
"""Return all items in the inventory."""
|
|
75
|
+
return [item async for item in self.iter_items()]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"coord": {
|
|
3
|
+
"lon": 13.405,
|
|
4
|
+
"lat": 52.52
|
|
5
|
+
},
|
|
6
|
+
"weather": [
|
|
7
|
+
{
|
|
8
|
+
"id": 800,
|
|
9
|
+
"main": "Clear",
|
|
10
|
+
"description": "clear sky",
|
|
11
|
+
"icon": "01d"
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"base": "stations",
|
|
15
|
+
"main": {
|
|
16
|
+
"temp": 22.4,
|
|
17
|
+
"feels_like": 22.6,
|
|
18
|
+
"temp_min": 21.0,
|
|
19
|
+
"temp_max": 23.5,
|
|
20
|
+
"pressure": 1015,
|
|
21
|
+
"humidity": 64,
|
|
22
|
+
"sea_level": 1015,
|
|
23
|
+
"grnd_level": 1008
|
|
24
|
+
},
|
|
25
|
+
"visibility": 10000,
|
|
26
|
+
"wind": {
|
|
27
|
+
"speed": 2.3,
|
|
28
|
+
"deg": 180,
|
|
29
|
+
"gust": 3.5
|
|
30
|
+
},
|
|
31
|
+
"clouds": {
|
|
32
|
+
"all": 0
|
|
33
|
+
},
|
|
34
|
+
"dt": 1787227200,
|
|
35
|
+
"sys": {
|
|
36
|
+
"type": 2,
|
|
37
|
+
"id": 2011538,
|
|
38
|
+
"message": 0.0,
|
|
39
|
+
"country": "DE",
|
|
40
|
+
"sunrise": 1787198400,
|
|
41
|
+
"sunset": 1787248800
|
|
42
|
+
},
|
|
43
|
+
"timezone": 7200,
|
|
44
|
+
"id": 2950159,
|
|
45
|
+
"name": "Berlin",
|
|
46
|
+
"cod": 200
|
|
47
|
+
}
|