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.
Files changed (47) hide show
  1. spitzeisen-0.1.0/PKG-INFO +182 -0
  2. spitzeisen-0.1.0/README.md +157 -0
  3. spitzeisen-0.1.0/examples/build_sync.py +102 -0
  4. spitzeisen-0.1.0/examples/demo.py +91 -0
  5. spitzeisen-0.1.0/examples/inventory_sdk.py +75 -0
  6. spitzeisen-0.1.0/examples/responses/current_weather.json +47 -0
  7. spitzeisen-0.1.0/examples/responses/forecast.json +92 -0
  8. spitzeisen-0.1.0/examples/tests/conftest.py +42 -0
  9. spitzeisen-0.1.0/examples/tests/test_client.py +82 -0
  10. spitzeisen-0.1.0/examples/tests/test_custom_strategies.py +34 -0
  11. spitzeisen-0.1.0/examples/weather_sdk/__init__.py +51 -0
  12. spitzeisen-0.1.0/examples/weather_sdk/_async/__init__.py +1 -0
  13. spitzeisen-0.1.0/examples/weather_sdk/_async/api/__init__.py +1 -0
  14. spitzeisen-0.1.0/examples/weather_sdk/_async/api/current_weather.py +104 -0
  15. spitzeisen-0.1.0/examples/weather_sdk/_async/api/forecast.py +111 -0
  16. spitzeisen-0.1.0/examples/weather_sdk/_async/base.py +45 -0
  17. spitzeisen-0.1.0/examples/weather_sdk/_async/client.py +31 -0
  18. spitzeisen-0.1.0/examples/weather_sdk/_async/config.py +56 -0
  19. spitzeisen-0.1.0/examples/weather_sdk/_sync/__init__.py +3 -0
  20. spitzeisen-0.1.0/examples/weather_sdk/_sync/api/__init__.py +3 -0
  21. spitzeisen-0.1.0/examples/weather_sdk/_sync/api/current_weather.py +106 -0
  22. spitzeisen-0.1.0/examples/weather_sdk/_sync/api/forecast.py +113 -0
  23. spitzeisen-0.1.0/examples/weather_sdk/_sync/base.py +47 -0
  24. spitzeisen-0.1.0/examples/weather_sdk/_sync/client.py +33 -0
  25. spitzeisen-0.1.0/examples/weather_sdk/_sync/config.py +58 -0
  26. spitzeisen-0.1.0/examples/weather_sdk/models.py +263 -0
  27. spitzeisen-0.1.0/examples/weather_sdk/params.py +16 -0
  28. spitzeisen-0.1.0/pyproject.toml +144 -0
  29. spitzeisen-0.1.0/src/spitzeisen/__init__.py +108 -0
  30. spitzeisen-0.1.0/src/spitzeisen/_async/__init__.py +1 -0
  31. spitzeisen-0.1.0/src/spitzeisen/_async/config.py +98 -0
  32. spitzeisen-0.1.0/src/spitzeisen/_async/core.py +519 -0
  33. spitzeisen-0.1.0/src/spitzeisen/_sync/__init__.py +3 -0
  34. spitzeisen-0.1.0/src/spitzeisen/_sync/config.py +100 -0
  35. spitzeisen-0.1.0/src/spitzeisen/_sync/core.py +517 -0
  36. spitzeisen-0.1.0/src/spitzeisen/auth.py +76 -0
  37. spitzeisen-0.1.0/src/spitzeisen/concurrency.py +48 -0
  38. spitzeisen-0.1.0/src/spitzeisen/exceptions.py +101 -0
  39. spitzeisen-0.1.0/src/spitzeisen/limits.py +105 -0
  40. spitzeisen-0.1.0/src/spitzeisen/models.py +23 -0
  41. spitzeisen-0.1.0/src/spitzeisen/operations.py +52 -0
  42. spitzeisen-0.1.0/src/spitzeisen/pagination.py +260 -0
  43. spitzeisen-0.1.0/src/spitzeisen/params.py +103 -0
  44. spitzeisen-0.1.0/src/spitzeisen/py.typed +0 -0
  45. spitzeisen-0.1.0/src/spitzeisen/testing/__init__.py +13 -0
  46. spitzeisen-0.1.0/src/spitzeisen/testing/harness.py +281 -0
  47. 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
+ }