bls-api-client 1.0.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.
@@ -0,0 +1,24 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ build:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - name: Install uv
15
+ uses: astral-sh/setup-uv@v6
16
+ - name: Build sdist and wheel
17
+ run: uv build
18
+ - name: Check distribution metadata
19
+ run: uvx twine check dist/*
20
+ - name: Upload distributions
21
+ uses: actions/upload-artifact@v4
22
+ with:
23
+ name: dist
24
+ path: dist/*
@@ -0,0 +1,20 @@
1
+ name: Release
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ environment: pypi
11
+ permissions:
12
+ id-token: write
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - name: Install uv
16
+ uses: astral-sh/setup-uv@v6
17
+ - name: Build sdist and wheel
18
+ run: uv build
19
+ - name: Publish to PyPI
20
+ run: uv publish
@@ -0,0 +1 @@
1
+ 3.14
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 covertcast
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,236 @@
1
+ Metadata-Version: 2.4
2
+ Name: bls-api-client
3
+ Version: 1.0.0
4
+ Summary: A sync + async client for the BLS Public Data API
5
+ Project-URL: Homepage, https://github.com/covertcast/blsapi
6
+ Project-URL: Repository, https://github.com/covertcast/blsapi
7
+ Project-URL: Issues, https://github.com/covertcast/blsapi/issues
8
+ Author-email: covertcast <covertcast@proton.me>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: api-client,bls,bureau-of-labor-statistics,economics,labor-statistics,polars
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Financial and Insurance Industry
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.11
26
+ Requires-Dist: anyio>=4.0
27
+ Requires-Dist: httpx2>=2.4.0
28
+ Requires-Dist: polars>=1.42.0
29
+ Requires-Dist: pydantic>=2.13.4
30
+ Description-Content-Type: text/markdown
31
+
32
+ # blsapi
33
+
34
+ [![PyPI version](https://img.shields.io/pypi/v/blsapi.svg)](https://pypi.org/project/blsapi/)
35
+ [![Python versions](https://img.shields.io/pypi/pyversions/blsapi.svg)](https://pypi.org/project/blsapi/)
36
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
37
+
38
+ A **sync + async** client inspired by `blsR` for the [U.S. Bureau of Labor Statistics
39
+ (BLS) Public Data API](https://www.bls.gov/developers/).
40
+
41
+ - **Sync and async** clients (`BLSClient`, `AsyncBLSClient`).
42
+ - **Polars output** returns easy to use and convert Polars Dataframes.
43
+ - **Automatic batching** over the BLS per-request limits (series count and year span).
44
+
45
+ ## Installation
46
+
47
+ Requires Python 3.11+:
48
+ ```bash
49
+ pip install bls-api-client
50
+ # or
51
+ uv add bls-api-client
52
+ ```
53
+
54
+ ```python
55
+ import blsapi
56
+ ```
57
+
58
+ ## Quick start
59
+
60
+ ```python
61
+ from blsapi import BLSClient
62
+
63
+ with BLSClient() as client:
64
+ df = client.get_series("LNS14000000", start_year=2023, end_year=2024)
65
+
66
+ print(df)
67
+ ```
68
+
69
+ The result is a tidy/long DataFrame. Exporting elsewhere is
70
+ simple:
71
+
72
+ ```python
73
+ df.write_parquet("data.parquet")
74
+ df.write_csv("data.csv")
75
+ df.to_pandas()
76
+ df.to_arrow()
77
+ ```
78
+
79
+ ### Async
80
+
81
+ ```python
82
+ import anyio
83
+ from blsapi import AsyncBLSClient
84
+
85
+ async def main():
86
+ async with AsyncBLSClient() as client:
87
+ df = await client.get_series("LNS14000000", start_year=2023, end_year=2024)
88
+ print(df)
89
+
90
+ anyio.run(main)
91
+ ```
92
+
93
+ ## Configuration
94
+
95
+ Everything has a sensible default, so `blsapi` works out of the box.
96
+
97
+ ### API key
98
+
99
+ A registration key is optional but raises your rate limits substantially. The key is resolved with this precedence:
100
+
101
+ 1. The explicit `api_key=` argument, if given.
102
+ 2. Otherwise the `BLS_API_KEY` environment variable.
103
+ 3. Otherwise no key (unauthenticated tier).
104
+
105
+ ### Example:
106
+ ```python
107
+ # Explicit (highest precedence)
108
+ client = BLSClient(api_key="your_32_char_key")
109
+
110
+ # Or rely on the environment
111
+ # export BLS_API_KEY=your_32_char_key (macOS/Linux)
112
+ # $env:BLS_API_KEY = "your_32_char_key" (PowerShell)
113
+ client = BLSClient()
114
+
115
+ client.has_key # returns True if a key is active
116
+ ```
117
+
118
+ ### Other options
119
+
120
+ All are keyword-only on both clients:
121
+
122
+ | Argument | Default | Purpose |
123
+ | ------------- | -------------------------------- | -------------------------------------------------------------- |
124
+ | `api_key` | `None` -> `BLS_API_KEY` | Registration key. |
125
+ | `base_url` | `https://api.bls.gov/publicAPI/v2/` | API base URL. |
126
+ | `timeout` | `5/30/10/5s` (connect/read/write/pool) | `httpx2.Timeout`. |
127
+ | `max_retries` | `3` | Application-level retries for transient failures. |
128
+ | `auto_batch` | `True` | Transparently split over the tier limits. |
129
+ | `user_agent` | `blsapi/<version>` | Sent as `User-Agent`. |
130
+ | `client` | `None` | Inject your own `httpx2.Client`/`AsyncClient`.|
131
+
132
+ ```python
133
+ import httpx2
134
+ from blsapi import BLSClient
135
+
136
+ client = BLSClient(
137
+ api_key="…",
138
+ max_retries=5,
139
+ timeout=httpx2.Timeout(connect=5.0, read=60.0, write=10.0, pool=5.0),
140
+ user_agent="my-app/1.0 (you@example.com)",
141
+ )
142
+ ```
143
+
144
+ ## Usage
145
+
146
+ ### Multiple series and named aliases
147
+
148
+ Pass a list of ids, or a `{label: id}` mapping to relabel the output's `series_id` column:
149
+
150
+ ```python
151
+ df = client.get_series(
152
+ {"unemployment": "LNS14000000", "cpi": "CUUR0000SA0"},
153
+ start_year=2020,
154
+ end_year=2024,
155
+ )
156
+ df["series_id"].unique().to_list() # -> ["cpi", "unemployment"]
157
+ ```
158
+
159
+ ### Wide format
160
+
161
+ Reshape the long frame to one value column per series, indexed by date:
162
+
163
+ ```python
164
+ from blsapi import pivot_wide
165
+
166
+ wide = pivot_wide(df) # columns: date, unemployment, cpi
167
+ ```
168
+
169
+ (Annual-average rows have no real date and are dropped by `pivot_wide`.)
170
+
171
+ ### Calculations and catalog
172
+
173
+ ```python
174
+ df = client.get_series(
175
+ "LNS14000000", start_year=2023, end_year=2024, calculations=True
176
+ )
177
+ # adds net_change_{1,3,6,12}m and pct_change_{1,3,6,12}m columns
178
+
179
+ resp = client.get_series_raw("LNS14000000", catalog=True) # -> BLSResponse with catalog metadata
180
+ ```
181
+
182
+ ### Surveys and popular series
183
+
184
+ ```python
185
+ client.list_surveys() # -> DataFrame of all surveys
186
+ client.get_survey("CU") # -> dict of metadata for one survey
187
+ client.get_popular() # -> list of popular series ids
188
+ client.get_popular("LN") # -> popular ids within a survey
189
+ ```
190
+
191
+ ### Quota planning
192
+
193
+ ```python
194
+ client.query_cost(["LNS14000000", "CUUR0000SA0"], 2000, 2024) # -> number of API calls used
195
+ ```
196
+
197
+ ## Error handling
198
+
199
+ All errors derive from `BLSError`, so you can catch broadly or narrowly:
200
+
201
+ ```python
202
+ from blsapi import BLSError, BLSValidationError, BLSAPIError, BLSHTTPError
203
+
204
+ try:
205
+ df = client.get_series("BAD_ID", start_year=2024, end_year=2023)
206
+ except BLSValidationError:
207
+ ... # bad input caught locally, before any network call
208
+ except BLSAPIError as e:
209
+ ... # BLS returned a non-success status; inspect e.status and e.messages
210
+ except BLSHTTPError:
211
+ ... # transport/HTTP failure that survived the retry loop
212
+ except BLSError:
213
+ ... # anything else from this library
214
+ ```
215
+
216
+ ## Tiers & limits
217
+
218
+ | | Series per request | Years per request | Queries per day |
219
+ | --- | --- | --- | --- |
220
+ | **No key** | 25 | 10 | 25 |
221
+ | **With key** | 50 | 20 | 500 |
222
+
223
+ With `auto_batch=True` (the default), requests that exceed the per-request limits are split
224
+ into multiple calls automatically and stitched back together. Register for a free key at
225
+ <https://data.bls.gov/registrationEngine/>.
226
+
227
+ ## Development
228
+
229
+ ```bash
230
+ uv sync # install dependencies
231
+ uv build # build the sdist + wheel
232
+ ```
233
+
234
+ ## License
235
+
236
+ [MIT](LICENSE)
@@ -0,0 +1,205 @@
1
+ # blsapi
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/blsapi.svg)](https://pypi.org/project/blsapi/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/blsapi.svg)](https://pypi.org/project/blsapi/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
6
+
7
+ A **sync + async** client inspired by `blsR` for the [U.S. Bureau of Labor Statistics
8
+ (BLS) Public Data API](https://www.bls.gov/developers/).
9
+
10
+ - **Sync and async** clients (`BLSClient`, `AsyncBLSClient`).
11
+ - **Polars output** returns easy to use and convert Polars Dataframes.
12
+ - **Automatic batching** over the BLS per-request limits (series count and year span).
13
+
14
+ ## Installation
15
+
16
+ Requires Python 3.11+:
17
+ ```bash
18
+ pip install bls-api-client
19
+ # or
20
+ uv add bls-api-client
21
+ ```
22
+
23
+ ```python
24
+ import blsapi
25
+ ```
26
+
27
+ ## Quick start
28
+
29
+ ```python
30
+ from blsapi import BLSClient
31
+
32
+ with BLSClient() as client:
33
+ df = client.get_series("LNS14000000", start_year=2023, end_year=2024)
34
+
35
+ print(df)
36
+ ```
37
+
38
+ The result is a tidy/long DataFrame. Exporting elsewhere is
39
+ simple:
40
+
41
+ ```python
42
+ df.write_parquet("data.parquet")
43
+ df.write_csv("data.csv")
44
+ df.to_pandas()
45
+ df.to_arrow()
46
+ ```
47
+
48
+ ### Async
49
+
50
+ ```python
51
+ import anyio
52
+ from blsapi import AsyncBLSClient
53
+
54
+ async def main():
55
+ async with AsyncBLSClient() as client:
56
+ df = await client.get_series("LNS14000000", start_year=2023, end_year=2024)
57
+ print(df)
58
+
59
+ anyio.run(main)
60
+ ```
61
+
62
+ ## Configuration
63
+
64
+ Everything has a sensible default, so `blsapi` works out of the box.
65
+
66
+ ### API key
67
+
68
+ A registration key is optional but raises your rate limits substantially. The key is resolved with this precedence:
69
+
70
+ 1. The explicit `api_key=` argument, if given.
71
+ 2. Otherwise the `BLS_API_KEY` environment variable.
72
+ 3. Otherwise no key (unauthenticated tier).
73
+
74
+ ### Example:
75
+ ```python
76
+ # Explicit (highest precedence)
77
+ client = BLSClient(api_key="your_32_char_key")
78
+
79
+ # Or rely on the environment
80
+ # export BLS_API_KEY=your_32_char_key (macOS/Linux)
81
+ # $env:BLS_API_KEY = "your_32_char_key" (PowerShell)
82
+ client = BLSClient()
83
+
84
+ client.has_key # returns True if a key is active
85
+ ```
86
+
87
+ ### Other options
88
+
89
+ All are keyword-only on both clients:
90
+
91
+ | Argument | Default | Purpose |
92
+ | ------------- | -------------------------------- | -------------------------------------------------------------- |
93
+ | `api_key` | `None` -> `BLS_API_KEY` | Registration key. |
94
+ | `base_url` | `https://api.bls.gov/publicAPI/v2/` | API base URL. |
95
+ | `timeout` | `5/30/10/5s` (connect/read/write/pool) | `httpx2.Timeout`. |
96
+ | `max_retries` | `3` | Application-level retries for transient failures. |
97
+ | `auto_batch` | `True` | Transparently split over the tier limits. |
98
+ | `user_agent` | `blsapi/<version>` | Sent as `User-Agent`. |
99
+ | `client` | `None` | Inject your own `httpx2.Client`/`AsyncClient`.|
100
+
101
+ ```python
102
+ import httpx2
103
+ from blsapi import BLSClient
104
+
105
+ client = BLSClient(
106
+ api_key="…",
107
+ max_retries=5,
108
+ timeout=httpx2.Timeout(connect=5.0, read=60.0, write=10.0, pool=5.0),
109
+ user_agent="my-app/1.0 (you@example.com)",
110
+ )
111
+ ```
112
+
113
+ ## Usage
114
+
115
+ ### Multiple series and named aliases
116
+
117
+ Pass a list of ids, or a `{label: id}` mapping to relabel the output's `series_id` column:
118
+
119
+ ```python
120
+ df = client.get_series(
121
+ {"unemployment": "LNS14000000", "cpi": "CUUR0000SA0"},
122
+ start_year=2020,
123
+ end_year=2024,
124
+ )
125
+ df["series_id"].unique().to_list() # -> ["cpi", "unemployment"]
126
+ ```
127
+
128
+ ### Wide format
129
+
130
+ Reshape the long frame to one value column per series, indexed by date:
131
+
132
+ ```python
133
+ from blsapi import pivot_wide
134
+
135
+ wide = pivot_wide(df) # columns: date, unemployment, cpi
136
+ ```
137
+
138
+ (Annual-average rows have no real date and are dropped by `pivot_wide`.)
139
+
140
+ ### Calculations and catalog
141
+
142
+ ```python
143
+ df = client.get_series(
144
+ "LNS14000000", start_year=2023, end_year=2024, calculations=True
145
+ )
146
+ # adds net_change_{1,3,6,12}m and pct_change_{1,3,6,12}m columns
147
+
148
+ resp = client.get_series_raw("LNS14000000", catalog=True) # -> BLSResponse with catalog metadata
149
+ ```
150
+
151
+ ### Surveys and popular series
152
+
153
+ ```python
154
+ client.list_surveys() # -> DataFrame of all surveys
155
+ client.get_survey("CU") # -> dict of metadata for one survey
156
+ client.get_popular() # -> list of popular series ids
157
+ client.get_popular("LN") # -> popular ids within a survey
158
+ ```
159
+
160
+ ### Quota planning
161
+
162
+ ```python
163
+ client.query_cost(["LNS14000000", "CUUR0000SA0"], 2000, 2024) # -> number of API calls used
164
+ ```
165
+
166
+ ## Error handling
167
+
168
+ All errors derive from `BLSError`, so you can catch broadly or narrowly:
169
+
170
+ ```python
171
+ from blsapi import BLSError, BLSValidationError, BLSAPIError, BLSHTTPError
172
+
173
+ try:
174
+ df = client.get_series("BAD_ID", start_year=2024, end_year=2023)
175
+ except BLSValidationError:
176
+ ... # bad input caught locally, before any network call
177
+ except BLSAPIError as e:
178
+ ... # BLS returned a non-success status; inspect e.status and e.messages
179
+ except BLSHTTPError:
180
+ ... # transport/HTTP failure that survived the retry loop
181
+ except BLSError:
182
+ ... # anything else from this library
183
+ ```
184
+
185
+ ## Tiers & limits
186
+
187
+ | | Series per request | Years per request | Queries per day |
188
+ | --- | --- | --- | --- |
189
+ | **No key** | 25 | 10 | 25 |
190
+ | **With key** | 50 | 20 | 500 |
191
+
192
+ With `auto_batch=True` (the default), requests that exceed the per-request limits are split
193
+ into multiple calls automatically and stitched back together. Register for a free key at
194
+ <https://data.bls.gov/registrationEngine/>.
195
+
196
+ ## Development
197
+
198
+ ```bash
199
+ uv sync # install dependencies
200
+ uv build # build the sdist + wheel
201
+ ```
202
+
203
+ ## License
204
+
205
+ [MIT](LICENSE)
@@ -0,0 +1,53 @@
1
+ [project]
2
+ name = "bls-api-client"
3
+ description = "A sync + async client for the BLS Public Data API"
4
+ readme = "README.md"
5
+ requires-python = ">=3.11"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "covertcast", email = "covertcast@proton.me" }]
9
+ keywords = [
10
+ "bls",
11
+ "bureau-of-labor-statistics",
12
+ "economics",
13
+ "labor-statistics",
14
+ "polars",
15
+ "api-client",
16
+ ]
17
+ classifiers = [
18
+ "Development Status :: 5 - Production/Stable",
19
+ "Intended Audience :: Science/Research",
20
+ "Intended Audience :: Developers",
21
+ "Intended Audience :: Financial and Insurance Industry",
22
+ "License :: OSI Approved :: MIT License",
23
+ "Operating System :: OS Independent",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Programming Language :: Python :: 3.14",
29
+ "Topic :: Scientific/Engineering :: Information Analysis",
30
+ "Typing :: Typed",
31
+ ]
32
+ dynamic = ["version"]
33
+ dependencies = [
34
+ "httpx2>=2.4.0",
35
+ "polars>=1.42.0",
36
+ "pydantic>=2.13.4",
37
+ "anyio>=4.0",
38
+ ]
39
+
40
+ [project.urls]
41
+ Homepage = "https://github.com/covertcast/blsapi"
42
+ Repository = "https://github.com/covertcast/blsapi"
43
+ Issues = "https://github.com/covertcast/blsapi/issues"
44
+
45
+ [build-system]
46
+ requires = ["hatchling"]
47
+ build-backend = "hatchling.build"
48
+
49
+ [tool.hatch.version]
50
+ path = "src/blsapi/__about__.py"
51
+
52
+ [tool.hatch.build.targets.wheel]
53
+ packages = ["src/blsapi"]
@@ -0,0 +1,3 @@
1
+ """Package version for the client's useragent."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,44 @@
1
+ """blsapi - a sync + async client for the BLS Public Data API.
2
+
3
+ Quick start:
4
+
5
+ from blsapi import BLSClient
6
+ with BLSClient() as client: # reads BLS_API_KEY from env if set
7
+ df = client.get_series("LNS14000000", start_year=2023, end_year=2024)
8
+
9
+ # async
10
+ from blsapi import AsyncBLSClient
11
+ async with AsyncBLSClient() as client:
12
+ df = await client.get_series("LNS14000000", start_year=2023, end_year=2024)
13
+
14
+ The result is a Polars DataFrame. Exporting it to other formats is simple:
15
+ df.write_parquet(...), df.write_csv(...), df.to_pandas(), df.to_arrow()
16
+ """
17
+
18
+ from .__about__ import __version__
19
+ from .aclient import AsyncBLSClient
20
+ from .client import BLSClient
21
+ from .enums import PeriodType
22
+ from .exceptions import (
23
+ BLSAPIError,
24
+ BLSError,
25
+ BLSHTTPError,
26
+ BLSValidationError,
27
+ )
28
+ from .frames import period_to_date, pivot_wide, series_to_frame
29
+ from .models import BLSResponse
30
+
31
+ __all__ = [
32
+ "AsyncBLSClient",
33
+ "BLSClient",
34
+ "BLSError",
35
+ "BLSValidationError",
36
+ "BLSAPIError",
37
+ "BLSHTTPError",
38
+ "BLSResponse",
39
+ "PeriodType",
40
+ "series_to_frame",
41
+ "pivot_wide",
42
+ "period_to_date",
43
+ "__version__",
44
+ ]
@@ -0,0 +1,58 @@
1
+ """Utilities for both the sync and async clients."""
2
+
3
+ from .models import BLSResponse, SeriesRequest, SeriesResult
4
+ from ._endpoints import RequestSpec, data_url
5
+ from collections.abc import Iterable
6
+ from . import config
7
+ from .batching import chunk_series, year_windows
8
+
9
+
10
+ def build_data_request(req: SeriesRequest, api_key: str | None, base_url: str) -> RequestSpec:
11
+ """Turn a validated SeriesRequest into a RequestSpec."""
12
+ return RequestSpec(
13
+ method="POST",
14
+ url=data_url(base_url),
15
+ json=req.to_payload(api_key),
16
+ )
17
+
18
+
19
+ def parse_data_response(payload: dict) -> BLSResponse:
20
+ """Validate the payload. Raises typed errors on failure."""
21
+ return BLSResponse.model_validate(payload)
22
+
23
+
24
+ def plan_data_requests(
25
+ req: SeriesRequest, api_key: str | None, base_url: str, auto_batch: bool
26
+ ) -> list[RequestSpec]:
27
+ """One RequestSpec per API call, splitting over the tier limits when auto_batch is enabled."""
28
+ if not auto_batch:
29
+ return [build_data_request(req, api_key, base_url)]
30
+
31
+ series_limit, year_limit, _ = config.limits_for(api_key)
32
+ if req.start_year is not None and req.end_year is not None:
33
+ windows = list(year_windows(req.start_year, req.end_year, year_limit))
34
+ else:
35
+ windows = [(req.start_year, req.end_year)] # no year filter produces a single window
36
+
37
+ # every combination of series-chunk and year-window is one request.
38
+ return [
39
+ build_data_request(
40
+ req.model_copy(update={"series_ids": ids, "start_year": lo, "end_year": hi}),
41
+ api_key,
42
+ base_url,
43
+ )
44
+ for ids in chunk_series(req.series_ids, series_limit)
45
+ for lo, hi in windows
46
+ ]
47
+
48
+
49
+ def merge_series(series_lists: Iterable[list[SeriesResult]]) -> list[SeriesResult]:
50
+ """Combine SeriesResults from several responses, concatenating .data by series_id."""
51
+ merged: dict[str, SeriesResult] = {}
52
+ for series in series_lists:
53
+ for s in series:
54
+ if s.series_id in merged:
55
+ merged[s.series_id].data.extend(s.data) # same series, another year-window
56
+ else:
57
+ merged[s.series_id] = s # first sighting
58
+ return list(merged.values())