allowances-api 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.
@@ -0,0 +1,49 @@
1
+ # Byte-compiled Python and interpreter caches
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # Distribution and packaging artifacts
7
+ build/
8
+ dist/
9
+ *.egg-info/
10
+ *.egg
11
+ .eggs/
12
+ wheels/
13
+ pip-wheel-metadata/
14
+
15
+ # Virtual environments
16
+ .venv/
17
+ venv/
18
+ env/
19
+ ENV/
20
+
21
+ # Test, coverage, type-checker, and linter caches
22
+ .pytest_cache/
23
+ .coverage
24
+ .coverage.*
25
+ coverage.xml
26
+ htmlcov/
27
+ .tox/
28
+ .nox/
29
+ .mypy_cache/
30
+ .pyright/
31
+ .ruff_cache/
32
+ .hypothesis/
33
+
34
+ # IDE and editor settings
35
+ .idea/
36
+ .vscode/
37
+ *.swp
38
+ *.swo
39
+ *~
40
+
41
+ # Environment files and local secrets
42
+ .env
43
+ .env.*
44
+ !.env.example
45
+ !.env.dist
46
+
47
+ # Operating-system metadata
48
+ .DS_Store
49
+ Thumbs.db
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Allowances API
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.
22
+
@@ -0,0 +1,223 @@
1
+ Metadata-Version: 2.4
2
+ Name: allowances-api
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Allowances API
5
+ Project-URL: Homepage, https://www.allowancesapi.com
6
+ Project-URL: Documentation, https://www.allowancesapi.com/docs
7
+ Project-URL: Repository, https://github.com/allowances-api/allowances-api-python
8
+ Project-URL: Issues, https://github.com/allowances-api/allowances-api-python/issues
9
+ Author-email: Allowances API <contact@allowancesapi.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: allowances,dssr,dtmo,gsa,per-diem,travel
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.9
24
+ Requires-Dist: httpx<1,>=0.25
25
+ Provides-Extra: dev
26
+ Requires-Dist: build>=1.2; extra == 'dev'
27
+ Requires-Dist: pytest-cov>=5; extra == 'dev'
28
+ Requires-Dist: pytest>=8; extra == 'dev'
29
+ Requires-Dist: ruff>=0.9; extra == 'dev'
30
+ Requires-Dist: twine>=6; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # Allowances API Python SDK
34
+
35
+ The official Python client for [Allowances API](https://www.allowancesapi.com), a normalized API for U.S. government travel and overseas compensation data.
36
+
37
+ The API brings together rates published by three different government sources:
38
+
39
+ - **DSSR** — overseas post allowance (COLA), hardship differential, danger pay, education allowance, living quarters allowance, and foreign per diem.
40
+ - **DTMO** — military per diem for CONUS and OCONUS locations.
41
+ - **GSA** — civilian CONUS per diem, mileage reimbursement, and Fleet vehicle leasing rates.
42
+
43
+ This SDK handles API-key authentication, URL construction, parameter serialization, response decoding, and useful Python exceptions. It is useful for travel platforms, expense systems, payroll and mobility tools, government contractors, and internal applications that need current or historical allowance data without maintaining scrapers for several unrelated sources.
44
+
45
+ ## Installation
46
+
47
+ ```bash
48
+ pip install allowances-api
49
+ ```
50
+
51
+ Python 3.9 or newer is required.
52
+
53
+ ## Authentication
54
+
55
+ Create an API key at [allowancesapi.com](https://www.allowancesapi.com), then provide it directly:
56
+
57
+ ```python
58
+ from allowances_api import AllowancesClient
59
+
60
+ client = AllowancesClient("ak_live_your_key")
61
+ ```
62
+
63
+ For applications, keeping the key in an environment variable is preferable. The client will find your api key if you set it explicitely as ALLOWANCES_API_KEY.
64
+
65
+ ```bash
66
+ export ALLOWANCES_API_KEY="ak_live_your_key"
67
+ ```
68
+
69
+ ```python
70
+ from allowances_api import AllowancesClient
71
+
72
+ client = AllowancesClient() # reads ALLOWANCES_API_KEY
73
+ ```
74
+
75
+ Every request sends the key in the API's `X-API-Key` header. Do not embed a secret API key in browser or mobile client code.
76
+
77
+ ## Quick start
78
+
79
+ Use the client as a context manager so its connection pool is closed automatically:
80
+
81
+ ```python
82
+ from datetime import date
83
+ from allowances_api import AllowancesClient
84
+
85
+ with AllowancesClient() as client:
86
+ # Overseas allowances for Nairobi
87
+ kenya = client.dssr.allowances_by_location(
88
+ "KE",
89
+ "Nairobi",
90
+ date=date.today(),
91
+ type=["cola", "hardship", "danger_pay"],
92
+ )
93
+
94
+ # Military OCONUS per diem
95
+ australia = client.dtmo.perdiem_oconus("AU", query="Adelaide")
96
+
97
+ # Civilian CONUS per diem
98
+ albany = client.gsa.perdiem_by_state("NY", location="Albany")
99
+ ```
100
+
101
+ Responses are returned as decoded dictionaries or lists, matching the documented JSON response exactly.
102
+
103
+ ## Common use cases
104
+
105
+ ### Look up an overseas post allowance and calculate COLA
106
+
107
+ ```python
108
+ rates = client.dssr.allowances_by_postcode("BE", "10112", types=["cola"])
109
+
110
+ estimate = client.dssr.calculate_cola(
111
+ "10112",
112
+ family_size=4,
113
+ base_salary=100_000,
114
+ )
115
+ print(estimate["annual_cola"], estimate["biweekly_cola"])
116
+ ```
117
+
118
+ ### Retrieve per diem by source
119
+
120
+ ```python
121
+ # Department of State foreign per diem
122
+ dssr = client.dssr.perdiem_by_location("KE", "Nairobi")
123
+
124
+ # Defense Travel Management Office military rates
125
+ dtmo = client.dtmo.perdiem_conus("US-CO", query="Aspen")
126
+
127
+ # General Services Administration civilian rates
128
+ gsa = client.gsa.perdiem_by_zipcode("12207")
129
+ ```
130
+
131
+ ### Mileage and GSA Fleet rates
132
+
133
+ ```python
134
+ mileage = client.gsa.mileage(mileage_type="automobile")
135
+ fleet = client.gsa.vehicle_rates("standard", "10B", year=2026)
136
+ ```
137
+
138
+ ### Browse locations
139
+
140
+ ```python
141
+ countries = client.dssr.countries(query="United")
142
+ dssr_locations = client.dssr.country_locations("DE")
143
+ dtmo_locations = client.dtmo.locations_oconus("AU", location="Sydney")
144
+ states = client.gsa.states()
145
+ destinations = client.gsa.destinations(limit=50)
146
+ ```
147
+
148
+ ## Errors
149
+
150
+ The SDK maps common status codes to specific exceptions:
151
+
152
+ ```python
153
+ from allowances_api import ForbiddenError, NotFoundError, RateLimitError
154
+
155
+ try:
156
+ result = client.dssr.perdiem("XX")
157
+ except NotFoundError as exc:
158
+ print(exc.status_code, exc.message, exc.request_id)
159
+ except ForbiddenError:
160
+ print("This request is not included in the current plan")
161
+ except RateLimitError:
162
+ print("Request limit reached")
163
+ ```
164
+
165
+ Available exception classes are `AuthenticationError`, `ForbiddenError`, `NotFoundError`, `ValidationError`, `RateLimitError`, `TransportError`, and the base `AllowancesAPIError`.
166
+
167
+ ## Configuration
168
+
169
+ Use a different endpoint or timeout for local development and testing:
170
+
171
+ ```python
172
+ client = AllowancesClient(
173
+ "test-key",
174
+ base_url="http://localhost:8000/v1",
175
+ timeout=10,
176
+ )
177
+ ```
178
+
179
+ An existing `httpx.Client` can be injected for custom transports, proxies, telemetry, or testing:
180
+
181
+ ```python
182
+ import httpx
183
+ from allowances_api import AllowancesClient
184
+
185
+ transport = httpx.HTTPTransport(retries=2)
186
+ http = httpx.Client(base_url="https://api.allowancesapi.com/v1", transport=transport)
187
+ client = AllowancesClient("ak_live_your_key", http_client=http)
188
+ ```
189
+
190
+ When injecting a client, its lifecycle remains owned by the caller.
191
+
192
+ ## Endpoint mapping
193
+
194
+ SDK methods follow the API's three source groups:
195
+
196
+ - `client.dssr`: allowances, per diem, COLA calculator, countries, and locations.
197
+ - `client.dtmo`: CONUS/OCONUS per diem, search, and locations.
198
+ - `client.gsa`: destination/state/ZIP per diem, ZIP and county metadata, mileage, and vehicle rates.
199
+
200
+ See the complete [API documentation](https://www.allowancesapi.com/docs) for response fields, plan limits, and data-source details.
201
+
202
+ ## Development and publishing
203
+
204
+ ```bash
205
+ python -m venv .venv
206
+ source .venv/bin/activate
207
+ pip install -e '.[dev]'
208
+ pytest
209
+ ruff check .
210
+ python -m build
211
+ twine check dist/*
212
+ ```
213
+
214
+ Publish a release after updating the version in `pyproject.toml` and `src/allowances_api/__init__.py`:
215
+
216
+ ```bash
217
+ twine upload dist/*
218
+ ```
219
+
220
+ ## License
221
+
222
+ MIT
223
+
@@ -0,0 +1,191 @@
1
+ # Allowances API Python SDK
2
+
3
+ The official Python client for [Allowances API](https://www.allowancesapi.com), a normalized API for U.S. government travel and overseas compensation data.
4
+
5
+ The API brings together rates published by three different government sources:
6
+
7
+ - **DSSR** — overseas post allowance (COLA), hardship differential, danger pay, education allowance, living quarters allowance, and foreign per diem.
8
+ - **DTMO** — military per diem for CONUS and OCONUS locations.
9
+ - **GSA** — civilian CONUS per diem, mileage reimbursement, and Fleet vehicle leasing rates.
10
+
11
+ This SDK handles API-key authentication, URL construction, parameter serialization, response decoding, and useful Python exceptions. It is useful for travel platforms, expense systems, payroll and mobility tools, government contractors, and internal applications that need current or historical allowance data without maintaining scrapers for several unrelated sources.
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pip install allowances-api
17
+ ```
18
+
19
+ Python 3.9 or newer is required.
20
+
21
+ ## Authentication
22
+
23
+ Create an API key at [allowancesapi.com](https://www.allowancesapi.com), then provide it directly:
24
+
25
+ ```python
26
+ from allowances_api import AllowancesClient
27
+
28
+ client = AllowancesClient("ak_live_your_key")
29
+ ```
30
+
31
+ For applications, keeping the key in an environment variable is preferable. The client will find your api key if you set it explicitely as ALLOWANCES_API_KEY.
32
+
33
+ ```bash
34
+ export ALLOWANCES_API_KEY="ak_live_your_key"
35
+ ```
36
+
37
+ ```python
38
+ from allowances_api import AllowancesClient
39
+
40
+ client = AllowancesClient() # reads ALLOWANCES_API_KEY
41
+ ```
42
+
43
+ Every request sends the key in the API's `X-API-Key` header. Do not embed a secret API key in browser or mobile client code.
44
+
45
+ ## Quick start
46
+
47
+ Use the client as a context manager so its connection pool is closed automatically:
48
+
49
+ ```python
50
+ from datetime import date
51
+ from allowances_api import AllowancesClient
52
+
53
+ with AllowancesClient() as client:
54
+ # Overseas allowances for Nairobi
55
+ kenya = client.dssr.allowances_by_location(
56
+ "KE",
57
+ "Nairobi",
58
+ date=date.today(),
59
+ type=["cola", "hardship", "danger_pay"],
60
+ )
61
+
62
+ # Military OCONUS per diem
63
+ australia = client.dtmo.perdiem_oconus("AU", query="Adelaide")
64
+
65
+ # Civilian CONUS per diem
66
+ albany = client.gsa.perdiem_by_state("NY", location="Albany")
67
+ ```
68
+
69
+ Responses are returned as decoded dictionaries or lists, matching the documented JSON response exactly.
70
+
71
+ ## Common use cases
72
+
73
+ ### Look up an overseas post allowance and calculate COLA
74
+
75
+ ```python
76
+ rates = client.dssr.allowances_by_postcode("BE", "10112", types=["cola"])
77
+
78
+ estimate = client.dssr.calculate_cola(
79
+ "10112",
80
+ family_size=4,
81
+ base_salary=100_000,
82
+ )
83
+ print(estimate["annual_cola"], estimate["biweekly_cola"])
84
+ ```
85
+
86
+ ### Retrieve per diem by source
87
+
88
+ ```python
89
+ # Department of State foreign per diem
90
+ dssr = client.dssr.perdiem_by_location("KE", "Nairobi")
91
+
92
+ # Defense Travel Management Office military rates
93
+ dtmo = client.dtmo.perdiem_conus("US-CO", query="Aspen")
94
+
95
+ # General Services Administration civilian rates
96
+ gsa = client.gsa.perdiem_by_zipcode("12207")
97
+ ```
98
+
99
+ ### Mileage and GSA Fleet rates
100
+
101
+ ```python
102
+ mileage = client.gsa.mileage(mileage_type="automobile")
103
+ fleet = client.gsa.vehicle_rates("standard", "10B", year=2026)
104
+ ```
105
+
106
+ ### Browse locations
107
+
108
+ ```python
109
+ countries = client.dssr.countries(query="United")
110
+ dssr_locations = client.dssr.country_locations("DE")
111
+ dtmo_locations = client.dtmo.locations_oconus("AU", location="Sydney")
112
+ states = client.gsa.states()
113
+ destinations = client.gsa.destinations(limit=50)
114
+ ```
115
+
116
+ ## Errors
117
+
118
+ The SDK maps common status codes to specific exceptions:
119
+
120
+ ```python
121
+ from allowances_api import ForbiddenError, NotFoundError, RateLimitError
122
+
123
+ try:
124
+ result = client.dssr.perdiem("XX")
125
+ except NotFoundError as exc:
126
+ print(exc.status_code, exc.message, exc.request_id)
127
+ except ForbiddenError:
128
+ print("This request is not included in the current plan")
129
+ except RateLimitError:
130
+ print("Request limit reached")
131
+ ```
132
+
133
+ Available exception classes are `AuthenticationError`, `ForbiddenError`, `NotFoundError`, `ValidationError`, `RateLimitError`, `TransportError`, and the base `AllowancesAPIError`.
134
+
135
+ ## Configuration
136
+
137
+ Use a different endpoint or timeout for local development and testing:
138
+
139
+ ```python
140
+ client = AllowancesClient(
141
+ "test-key",
142
+ base_url="http://localhost:8000/v1",
143
+ timeout=10,
144
+ )
145
+ ```
146
+
147
+ An existing `httpx.Client` can be injected for custom transports, proxies, telemetry, or testing:
148
+
149
+ ```python
150
+ import httpx
151
+ from allowances_api import AllowancesClient
152
+
153
+ transport = httpx.HTTPTransport(retries=2)
154
+ http = httpx.Client(base_url="https://api.allowancesapi.com/v1", transport=transport)
155
+ client = AllowancesClient("ak_live_your_key", http_client=http)
156
+ ```
157
+
158
+ When injecting a client, its lifecycle remains owned by the caller.
159
+
160
+ ## Endpoint mapping
161
+
162
+ SDK methods follow the API's three source groups:
163
+
164
+ - `client.dssr`: allowances, per diem, COLA calculator, countries, and locations.
165
+ - `client.dtmo`: CONUS/OCONUS per diem, search, and locations.
166
+ - `client.gsa`: destination/state/ZIP per diem, ZIP and county metadata, mileage, and vehicle rates.
167
+
168
+ See the complete [API documentation](https://www.allowancesapi.com/docs) for response fields, plan limits, and data-source details.
169
+
170
+ ## Development and publishing
171
+
172
+ ```bash
173
+ python -m venv .venv
174
+ source .venv/bin/activate
175
+ pip install -e '.[dev]'
176
+ pytest
177
+ ruff check .
178
+ python -m build
179
+ twine check dist/*
180
+ ```
181
+
182
+ Publish a release after updating the version in `pyproject.toml` and `src/allowances_api/__init__.py`:
183
+
184
+ ```bash
185
+ twine upload dist/*
186
+ ```
187
+
188
+ ## License
189
+
190
+ MIT
191
+
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.26"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "allowances-api"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for the Allowances API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [{ name = "Allowances API", email = "contact@allowancesapi.com" }]
13
+ keywords = ["allowances", "per-diem", "dssr", "dtmo", "gsa", "travel"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Typing :: Typed",
25
+ ]
26
+ dependencies = ["httpx>=0.25,<1"]
27
+
28
+ [project.urls]
29
+ Homepage = "https://www.allowancesapi.com"
30
+ Documentation = "https://www.allowancesapi.com/docs"
31
+ Repository = "https://github.com/allowances-api/allowances-api-python"
32
+ Issues = "https://github.com/allowances-api/allowances-api-python/issues"
33
+
34
+ [project.optional-dependencies]
35
+ dev = ["build>=1.2", "pytest>=8", "pytest-cov>=5", "ruff>=0.9", "twine>=6"]
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/allowances_api"]
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
42
+ addopts = "--cov=allowances_api --cov-branch --cov-report=term-missing --cov-fail-under=95"
43
+
44
+ [tool.ruff]
45
+ line-length = 100
46
+ target-version = "py39"
@@ -0,0 +1,26 @@
1
+ """Python SDK for the Allowances API."""
2
+
3
+ from .client import AllowancesClient
4
+ from .exceptions import (
5
+ AllowancesAPIError,
6
+ AuthenticationError,
7
+ ForbiddenError,
8
+ NotFoundError,
9
+ RateLimitError,
10
+ TransportError,
11
+ ValidationError,
12
+ )
13
+
14
+ __all__ = [
15
+ "AllowancesClient",
16
+ "AllowancesAPIError",
17
+ "AuthenticationError",
18
+ "ForbiddenError",
19
+ "NotFoundError",
20
+ "RateLimitError",
21
+ "TransportError",
22
+ "ValidationError",
23
+ ]
24
+
25
+ __version__ = "0.1.0"
26
+
@@ -0,0 +1,24 @@
1
+ from datetime import date, datetime
2
+ from enum import Enum
3
+ from typing import Any, Dict, Mapping, Optional
4
+
5
+
6
+ def serialize(value: Any) -> Any:
7
+ if isinstance(value, (date, datetime)):
8
+ return value.isoformat()
9
+ if isinstance(value, Enum):
10
+ return value.value
11
+ if isinstance(value, (list, tuple, set)):
12
+ return [serialize(item) for item in value]
13
+ return value
14
+
15
+
16
+ def query_params(**values: Any) -> Dict[str, Any]:
17
+ return {key: serialize(value) for key, value in values.items() if value is not None}
18
+
19
+
20
+ def merge_params(params: Optional[Mapping[str, Any]], **values: Any) -> Dict[str, Any]:
21
+ merged = dict(params or {})
22
+ merged.update(query_params(**values))
23
+ return merged
24
+
@@ -0,0 +1,100 @@
1
+ """HTTP client and authentication for the Allowances API."""
2
+
3
+ import os
4
+ from typing import Any, Mapping, Optional
5
+
6
+ import httpx
7
+
8
+ from .exceptions import (
9
+ AllowancesAPIError,
10
+ AuthenticationError,
11
+ ForbiddenError,
12
+ NotFoundError,
13
+ RateLimitError,
14
+ TransportError,
15
+ ValidationError,
16
+ error_details,
17
+ )
18
+ from .resources import DSSRResource, DTMOResource, GSAResource
19
+
20
+ DEFAULT_BASE_URL = "https://api.allowancesapi.com/v1"
21
+
22
+
23
+ class AllowancesClient:
24
+ """Authenticated synchronous client for all public Allowances API endpoints."""
25
+
26
+ def __init__(
27
+ self,
28
+ api_key: Optional[str] = None,
29
+ *,
30
+ base_url: str = DEFAULT_BASE_URL,
31
+ timeout: float = 30.0,
32
+ http_client: Optional[httpx.Client] = None,
33
+ ) -> None:
34
+ key = api_key or os.getenv("ALLOWANCES_API_KEY")
35
+ if not key:
36
+ raise ValueError("Pass api_key or set the ALLOWANCES_API_KEY environment variable")
37
+
38
+ self._owns_client = http_client is None
39
+ self._http = http_client or httpx.Client(
40
+ base_url=base_url.rstrip("/"),
41
+ timeout=timeout,
42
+ headers={"X-API-Key": key, "User-Agent": "allowances-api-python/0.1.0"},
43
+ )
44
+ if http_client is not None:
45
+ self._http.headers["X-API-Key"] = key
46
+ self._http.headers.setdefault("User-Agent", "allowances-api-python/0.1.0")
47
+
48
+ self.dssr = DSSRResource(self)
49
+ self.dtmo = DTMOResource(self)
50
+ self.gsa = GSAResource(self)
51
+
52
+ def request(
53
+ self,
54
+ method: str,
55
+ path: str,
56
+ *,
57
+ params: Optional[Mapping[str, Any]] = None,
58
+ ) -> Any:
59
+ try:
60
+ response = self._http.request(method, path, params=params)
61
+ except httpx.HTTPError as exc:
62
+ raise TransportError(str(exc)) from exc
63
+
64
+ if response.is_success:
65
+ if response.status_code == 204 or not response.content:
66
+ return None
67
+ return response.json()
68
+
69
+ try:
70
+ payload = response.json()
71
+ except ValueError:
72
+ payload = None
73
+ message, request_id = error_details(payload)
74
+ error_type = {
75
+ 401: AuthenticationError,
76
+ 403: ForbiddenError,
77
+ 404: NotFoundError,
78
+ 422: ValidationError,
79
+ 429: RateLimitError,
80
+ }.get(response.status_code, AllowancesAPIError)
81
+ raise error_type(
82
+ message,
83
+ status_code=response.status_code,
84
+ request_id=request_id,
85
+ response=response,
86
+ )
87
+
88
+ def get(self, path: str, *, params: Optional[Mapping[str, Any]] = None) -> Any:
89
+ return self.request("GET", path, params=params)
90
+
91
+ def close(self) -> None:
92
+ if self._owns_client:
93
+ self._http.close()
94
+
95
+ def __enter__(self) -> "AllowancesClient":
96
+ return self
97
+
98
+ def __exit__(self, *args: Any) -> None:
99
+ self.close()
100
+
@@ -0,0 +1,60 @@
1
+ """Exceptions raised by the Allowances API client."""
2
+
3
+ from typing import Any, Mapping, Optional
4
+
5
+
6
+ class AllowancesAPIError(Exception):
7
+ """An error response returned by the Allowances API."""
8
+
9
+ def __init__(
10
+ self,
11
+ message: str,
12
+ *,
13
+ status_code: Optional[int] = None,
14
+ request_id: Optional[str] = None,
15
+ response: Any = None,
16
+ ) -> None:
17
+ super().__init__(message)
18
+ self.message = message
19
+ self.status_code = status_code
20
+ self.request_id = request_id
21
+ self.response = response
22
+
23
+
24
+ class AuthenticationError(AllowancesAPIError):
25
+ """The API key is missing or invalid."""
26
+
27
+
28
+ class ForbiddenError(AllowancesAPIError):
29
+ """The current plan cannot access the requested resource."""
30
+
31
+
32
+ class NotFoundError(AllowancesAPIError):
33
+ """The requested data was not found."""
34
+
35
+
36
+ class ValidationError(AllowancesAPIError):
37
+ """One or more request parameters are invalid."""
38
+
39
+
40
+ class RateLimitError(AllowancesAPIError):
41
+ """The account's request limit was exceeded."""
42
+
43
+
44
+ class TransportError(AllowancesAPIError):
45
+ """The request could not reach the API."""
46
+
47
+
48
+ def error_details(payload: Any) -> tuple[str, Optional[str]]:
49
+ """Extract the API's message and request ID from supported error envelopes."""
50
+ if not isinstance(payload, Mapping):
51
+ return "Allowances API request failed", None
52
+
53
+ detail = payload.get("detail", payload)
54
+ if isinstance(detail, Mapping):
55
+ return str(detail.get("message") or detail.get("detail") or detail), detail.get("request_id")
56
+ if isinstance(detail, list):
57
+ messages = [str(item.get("msg", item)) if isinstance(item, Mapping) else str(item) for item in detail]
58
+ return "; ".join(messages), None
59
+ return str(detail), None
60
+
@@ -0,0 +1,153 @@
1
+ """Resource clients mirroring the public v1 API routes."""
2
+
3
+ from datetime import date
4
+ from typing import TYPE_CHECKING, Any, Iterable, Optional, Union
5
+ from urllib.parse import quote
6
+
7
+ from ._utils import query_params
8
+
9
+ if TYPE_CHECKING:
10
+ from .client import AllowancesClient
11
+
12
+ DateLike = Union[str, date]
13
+
14
+
15
+ def segment(value: Any) -> str:
16
+ return quote(str(value), safe="")
17
+
18
+
19
+ class Resource:
20
+ def __init__(self, client: "AllowancesClient") -> None:
21
+ self._client = client
22
+
23
+ def _get(self, path: str, **params: Any) -> Any:
24
+ # Friendly Python names for API parameters whose wire names are terse or singular.
25
+ if "types" in params:
26
+ params["type"] = params.pop("types")
27
+ if "query" in params:
28
+ params["q"] = params.pop("query")
29
+ return self._client.get(path, params=query_params(**params))
30
+
31
+
32
+ class DSSRResource(Resource):
33
+ def allowances(
34
+ self, country: str, *, date: Optional[DateLike] = None,
35
+ types: Optional[Iterable[str]] = None, verification_url: bool = False,
36
+ ) -> Any:
37
+ return self._get(f"/dssr/allowances/{segment(country)}", date=date, type=types,
38
+ verification_url=verification_url)
39
+
40
+ def allowances_by_location(self, country: str, location: str, **params: Any) -> Any:
41
+ return self._get(f"/dssr/allowances/{segment(country)}/location/{segment(location)}", **params)
42
+
43
+ def allowances_by_postcode(self, country: str, postcode: Union[str, int], **params: Any) -> Any:
44
+ return self._get(f"/dssr/allowances/{segment(country)}/postcode/{segment(postcode)}", **params)
45
+
46
+ def perdiem(self, country: str, **params: Any) -> Any:
47
+ return self._get(f"/dssr/perdiem/{segment(country)}", **params)
48
+
49
+ def perdiem_by_location(self, country: str, location: str, **params: Any) -> Any:
50
+ return self._get(f"/dssr/perdiem/{segment(country)}/location/{segment(location)}", **params)
51
+
52
+ def perdiem_by_postcode(self, country: str, postcode: Union[str, int], **params: Any) -> Any:
53
+ return self._get(f"/dssr/perdiem/{segment(country)}/postcode/{segment(postcode)}", **params)
54
+
55
+ def search_perdiem(self, query: str, *, date: Optional[DateLike] = None) -> Any:
56
+ return self._get("/dssr/perdiem/search", q=query, date=date)
57
+
58
+ def locations(self, *, query: Optional[str] = None, offset: int = 0, limit: int = 10) -> Any:
59
+ return self._get("/dssr/locations", q=query, offset=offset, limit=limit)
60
+
61
+ def countries(self, *, query: Optional[str] = None, offset: int = 0, limit: int = 20) -> Any:
62
+ return self._get("/dssr/countries", q=query, offset=offset, limit=limit)
63
+
64
+ def country(self, country: str) -> Any:
65
+ return self._get(f"/dssr/country/{segment(country)}")
66
+
67
+ def country_locations(self, country: str) -> Any:
68
+ return self._get(f"/dssr/country/{segment(country)}/locations")
69
+
70
+ def calculate_cola(
71
+ self, postcode: Union[str, int], *, family_size: int, base_salary: int,
72
+ date: Optional[DateLike] = None,
73
+ ) -> Any:
74
+ return self._get(f"/dssr/cola-calculator/postcode/{segment(postcode)}",
75
+ family_size=family_size, base_salary=base_salary, date=date)
76
+
77
+
78
+ class DTMOResource(Resource):
79
+ def perdiem_conus(
80
+ self, state: str, *, date: Optional[DateLike] = None, year: Optional[int] = None,
81
+ query: Optional[str] = None, verification_url: bool = False,
82
+ offset: int = 0, limit: int = 50,
83
+ ) -> Any:
84
+ return self._get(f"/dtmo/perdiem/conus/{segment(state)}", date=date, year=year, q=query,
85
+ verification_url=verification_url, offset=offset, limit=limit)
86
+
87
+ def perdiem_oconus(self, country: str, **params: Any) -> Any:
88
+ return self._get(f"/dtmo/perdiem/oconus/{segment(country)}", **params)
89
+
90
+ def search_perdiem(
91
+ self, query: str, *, date: Optional[DateLike] = None, year: Optional[int] = None,
92
+ verification_url: bool = False,
93
+ ) -> Any:
94
+ return self._get("/dtmo/perdiem/search", q=query, date=date, year=year,
95
+ verification_url=verification_url)
96
+
97
+ def locations_conus(self, state: str) -> Any:
98
+ return self._get(f"/dtmo/locations/conus/{segment(state)}")
99
+
100
+ def locations_oconus(self, country: str, *, location: Optional[str] = None) -> Any:
101
+ return self._get(f"/dtmo/locations/oconus/{segment(country)}", location=location)
102
+
103
+
104
+ class GSAResource(Resource):
105
+ def perdiem_by_destination(self, destination_id: int, **params: Any) -> Any:
106
+ return self._get(f"/gsa/perdiem/destination/{destination_id}", **params)
107
+
108
+ def perdiem_by_state(self, state: str, **params: Any) -> Any:
109
+ return self._get(f"/gsa/perdiem/state/{segment(state)}", **params)
110
+
111
+ def perdiem_by_state_year(self, state: str, year: int, **params: Any) -> Any:
112
+ return self._get(f"/gsa/perdiem/state/{segment(state)}/year/{year}", **params)
113
+
114
+ def perdiem_by_zipcode(self, zipcode: Union[str, int], **params: Any) -> Any:
115
+ return self._get(f"/gsa/perdiem/zipcode/{segment(zipcode)}", **params)
116
+
117
+ def perdiem_by_zipcode_year(self, zipcode: Union[str, int], year: int, **params: Any) -> Any:
118
+ return self._get(f"/gsa/perdiem/zipcode/{segment(zipcode)}/year/{year}", **params)
119
+
120
+ def zipcodes(self, state: str, *, offset: int = 0, limit: int = 20) -> Any:
121
+ return self._get(f"/gsa/zipcodes/{segment(state)}", offset=offset, limit=limit)
122
+
123
+ def zipcode(self, zipcode: Union[str, int]) -> Any:
124
+ return self._get(f"/gsa/zipcode/{segment(zipcode)}")
125
+
126
+ def counties(
127
+ self, *, query: Optional[str] = None, state: Optional[str] = None,
128
+ offset: int = 0, limit: int = 20,
129
+ ) -> Any:
130
+ return self._get("/gsa/counties", q=query, state=state, offset=offset, limit=limit)
131
+
132
+ def destination(self, destination_id: int) -> Any:
133
+ return self._get(f"/gsa/destination/{destination_id}")
134
+
135
+ def destinations(self, *, offset: int = 0, limit: int = 100) -> Any:
136
+ return self._get("/gsa/destinations", offset=offset, limit=limit)
137
+
138
+ def states(self) -> Any:
139
+ return self._get("/gsa/states")
140
+
141
+ def mileage(
142
+ self, *, date: Optional[DateLike] = None, mileage_type: Optional[str] = None,
143
+ verification_url: bool = False,
144
+ ) -> Any:
145
+ return self._get("/gsa/mileage", date=date, type=mileage_type,
146
+ verification_url=verification_url)
147
+
148
+ def vehicle_rates(
149
+ self, rate_type: str, sin: str, *, year: Optional[int] = None,
150
+ verification_url: bool = False,
151
+ ) -> Any:
152
+ return self._get(f"/gsa/vehicle-rates/{segment(rate_type)}/{segment(sin)}", year=year,
153
+ verification_url=verification_url)
@@ -0,0 +1,234 @@
1
+ from datetime import date
2
+
3
+ import httpx
4
+ import pytest
5
+
6
+ from allowances_api import (
7
+ AllowancesAPIError,
8
+ AllowancesClient,
9
+ AuthenticationError,
10
+ ForbiddenError,
11
+ NotFoundError,
12
+ RateLimitError,
13
+ TransportError,
14
+ ValidationError,
15
+ )
16
+
17
+
18
+ def make_client(handler):
19
+ http = httpx.Client(
20
+ base_url="https://example.test/v1",
21
+ transport=httpx.MockTransport(handler),
22
+ )
23
+ return AllowancesClient("secret-key", http_client=http), http
24
+
25
+
26
+ def test_authentication_and_dssr_parameter_serialization():
27
+ def handler(request):
28
+ assert request.headers["X-API-Key"] == "secret-key"
29
+ assert request.url.path == "/v1/dssr/allowances/KE/location/Nairobi"
30
+ assert request.url.params.get("date") == "2026-01-02"
31
+ assert request.url.params.get_list("type") == ["cola", "hardship"]
32
+ return httpx.Response(200, json={"effective_date": "2026-01-01", "results": []})
33
+
34
+ client, http = make_client(handler)
35
+ try:
36
+ payload = client.dssr.allowances_by_location(
37
+ "KE", "Nairobi", date=date(2026, 1, 2), type=["cola", "hardship"]
38
+ )
39
+ assert payload["results"] == []
40
+ finally:
41
+ http.close()
42
+
43
+
44
+ def test_all_resource_groups_are_available():
45
+ paths = []
46
+
47
+ def handler(request):
48
+ paths.append(request.url.path)
49
+ return httpx.Response(200, json={})
50
+
51
+ client, http = make_client(handler)
52
+ try:
53
+ client.dssr.country("BE")
54
+ client.dtmo.perdiem_oconus("AU")
55
+ client.gsa.vehicle_rates("standard", "10B", year=2026)
56
+ finally:
57
+ http.close()
58
+
59
+ assert paths == [
60
+ "/v1/dssr/country/BE",
61
+ "/v1/dtmo/perdiem/oconus/AU",
62
+ "/v1/gsa/vehicle-rates/standard/10B",
63
+ ]
64
+
65
+
66
+ @pytest.mark.parametrize(
67
+ ("status_code", "exception"),
68
+ [
69
+ (401, AuthenticationError),
70
+ (403, ForbiddenError),
71
+ (404, NotFoundError),
72
+ (422, ValidationError),
73
+ (429, RateLimitError),
74
+ (500, AllowancesAPIError),
75
+ ],
76
+ )
77
+ def test_structured_api_errors(status_code, exception):
78
+ def handler(request):
79
+ return httpx.Response(
80
+ status_code,
81
+ json={"detail": {"message": "No access", "request_id": "req_123"}},
82
+ )
83
+
84
+ client, http = make_client(handler)
85
+ try:
86
+ with pytest.raises(exception) as raised:
87
+ client.gsa.mileage(date="2020-01-01")
88
+ assert raised.value.status_code == status_code
89
+ assert raised.value.request_id == "req_123"
90
+ assert str(raised.value) == "No access"
91
+ finally:
92
+ http.close()
93
+
94
+
95
+ def test_api_key_can_come_from_environment(monkeypatch):
96
+ monkeypatch.setenv("ALLOWANCES_API_KEY", "environment-key")
97
+
98
+ def handler(request):
99
+ assert request.headers["X-API-Key"] == "environment-key"
100
+ return httpx.Response(200, json=[])
101
+
102
+ http = httpx.Client(base_url="https://example.test/v1", transport=httpx.MockTransport(handler))
103
+ client = AllowancesClient(http_client=http)
104
+ try:
105
+ assert client.gsa.states() == []
106
+ finally:
107
+ http.close()
108
+
109
+
110
+ def test_missing_api_key_is_rejected(monkeypatch):
111
+ monkeypatch.delenv("ALLOWANCES_API_KEY", raising=False)
112
+ with pytest.raises(ValueError, match="ALLOWANCES_API_KEY"):
113
+ AllowancesClient()
114
+
115
+
116
+ @pytest.mark.parametrize(
117
+ ("payload", "message", "request_id"),
118
+ [
119
+ ({"detail": "Plain error"}, "Plain error", None),
120
+ ({"detail": {"detail": "Nested error"}}, "Nested error", None),
121
+ ({"detail": {"code": "unknown"}}, "{'code': 'unknown'}", None),
122
+ ({"detail": [{"msg": "Bad country"}, {"msg": "Bad date"}]},
123
+ "Bad country; Bad date", None),
124
+ ({"detail": ["First", "Second"]}, "First; Second", None),
125
+ ({"message": "Top level", "request_id": "req_top"}, "Top level", "req_top"),
126
+ ],
127
+ )
128
+ def test_supported_error_envelopes(payload, message, request_id):
129
+ def handler(request):
130
+ return httpx.Response(400, json=payload)
131
+
132
+ client, http = make_client(handler)
133
+ try:
134
+ with pytest.raises(AllowancesAPIError) as raised:
135
+ client.gsa.states()
136
+ assert raised.value.message == message
137
+ assert raised.value.request_id == request_id
138
+ assert raised.value.response.status_code == 400
139
+ finally:
140
+ http.close()
141
+
142
+
143
+ def test_non_json_error_uses_a_safe_fallback_message():
144
+ def handler(request):
145
+ return httpx.Response(502, text="upstream exploded")
146
+
147
+ client, http = make_client(handler)
148
+ try:
149
+ with pytest.raises(AllowancesAPIError, match="Allowances API request failed"):
150
+ client.gsa.states()
151
+ finally:
152
+ http.close()
153
+
154
+
155
+ @pytest.mark.parametrize("status_code", [204, 205])
156
+ def test_empty_success_response_returns_none(status_code):
157
+ def handler(request):
158
+ return httpx.Response(status_code)
159
+
160
+ client, http = make_client(handler)
161
+ try:
162
+ assert client.get("/empty") is None
163
+ finally:
164
+ http.close()
165
+
166
+
167
+ def test_malformed_json_success_is_exposed_to_the_caller():
168
+ def handler(request):
169
+ return httpx.Response(200, content=b"not-json", headers={"content-type": "application/json"})
170
+
171
+ client, http = make_client(handler)
172
+ try:
173
+ with pytest.raises(ValueError):
174
+ client.gsa.states()
175
+ finally:
176
+ http.close()
177
+
178
+
179
+ def test_transport_errors_are_wrapped():
180
+ def handler(request):
181
+ raise httpx.ConnectError("connection refused", request=request)
182
+
183
+ client, http = make_client(handler)
184
+ try:
185
+ with pytest.raises(TransportError, match="connection refused") as raised:
186
+ client.gsa.states()
187
+ assert isinstance(raised.value.__cause__, httpx.ConnectError)
188
+ finally:
189
+ http.close()
190
+
191
+
192
+ def test_explicit_key_takes_precedence_over_environment(monkeypatch):
193
+ monkeypatch.setenv("ALLOWANCES_API_KEY", "environment-key")
194
+
195
+ def handler(request):
196
+ assert request.headers["X-API-Key"] == "explicit-key"
197
+ return httpx.Response(200, json={})
198
+
199
+ http = httpx.Client(base_url="https://example.test/v1", transport=httpx.MockTransport(handler))
200
+ client = AllowancesClient("explicit-key", http_client=http)
201
+ try:
202
+ client.gsa.states()
203
+ finally:
204
+ http.close()
205
+
206
+
207
+ def test_injected_http_client_is_not_closed_by_sdk():
208
+ http = httpx.Client(
209
+ base_url="https://example.test/v1",
210
+ transport=httpx.MockTransport(lambda request: httpx.Response(200, json={})),
211
+ )
212
+ client = AllowancesClient("key", http_client=http)
213
+ client.close()
214
+ assert not http.is_closed
215
+ http.close()
216
+
217
+
218
+ def test_owned_http_client_is_closed_by_context_manager(monkeypatch):
219
+ created = []
220
+
221
+ class FakeHTTPClient:
222
+ def __init__(self, **kwargs):
223
+ self.headers = dict(kwargs["headers"])
224
+ self.closed = False
225
+ created.append(self)
226
+
227
+ def close(self):
228
+ self.closed = True
229
+
230
+ monkeypatch.setattr(httpx, "Client", FakeHTTPClient)
231
+ with AllowancesClient("key", base_url="https://example.test/v1/") as client:
232
+ assert client._http.headers["X-API-Key"] == "key"
233
+ assert not created[0].closed
234
+ assert created[0].closed
@@ -0,0 +1,146 @@
1
+ from datetime import date
2
+
3
+ import httpx
4
+ import pytest
5
+
6
+ from allowances_api import AllowancesClient
7
+
8
+
9
+ ENDPOINT_CASES = [
10
+ # DSSR
11
+ ("dssr_allowances", lambda c: c.dssr.allowances("KE", date=date(2026, 1, 2),
12
+ types=["cola", "hardship"], verification_url=True), "/v1/dssr/allowances/KE",
13
+ {"date": ["2026-01-02"], "type": ["cola", "hardship"], "verification_url": ["true"]}),
14
+ ("dssr_allowances_location", lambda c: c.dssr.allowances_by_location(
15
+ "CI", "Abidjan Plateau", types=["cola"]),
16
+ "/v1/dssr/allowances/CI/location/Abidjan%20Plateau", {"type": ["cola"]}),
17
+ ("dssr_allowances_postcode", lambda c: c.dssr.allowances_by_postcode(
18
+ "BE", 10112, date="2026-01-01"),
19
+ "/v1/dssr/allowances/BE/postcode/10112", {"date": ["2026-01-01"]}),
20
+ ("dssr_perdiem", lambda c: c.dssr.perdiem("KE", verification_url=True),
21
+ "/v1/dssr/perdiem/KE", {"verification_url": ["true"]}),
22
+ ("dssr_perdiem_location", lambda c: c.dssr.perdiem_by_location("KE", "Nairobi"),
23
+ "/v1/dssr/perdiem/KE/location/Nairobi", {}),
24
+ ("dssr_perdiem_postcode", lambda c: c.dssr.perdiem_by_postcode("BE", "10112"),
25
+ "/v1/dssr/perdiem/BE/postcode/10112", {}),
26
+ ("dssr_search", lambda c: c.dssr.search_perdiem("Nairobi", date="2026-01-01"),
27
+ "/v1/dssr/perdiem/search", {"q": ["Nairobi"], "date": ["2026-01-01"]}),
28
+ ("dssr_locations", lambda c: c.dssr.locations(query="Nai", offset=10, limit=20),
29
+ "/v1/dssr/locations", {"q": ["Nai"], "offset": ["10"], "limit": ["20"]}),
30
+ ("dssr_countries", lambda c: c.dssr.countries(query="United", offset=2, limit=50),
31
+ "/v1/dssr/countries", {"q": ["United"], "offset": ["2"], "limit": ["50"]}),
32
+ ("dssr_country", lambda c: c.dssr.country("GB"), "/v1/dssr/country/GB", {}),
33
+ ("dssr_country_locations", lambda c: c.dssr.country_locations("DE"),
34
+ "/v1/dssr/country/DE/locations", {}),
35
+ ("dssr_cola", lambda c: c.dssr.calculate_cola(
36
+ "10016", family_size=4, base_salary=100_000, date=date(2026, 1, 1)),
37
+ "/v1/dssr/cola-calculator/postcode/10016",
38
+ {"family_size": ["4"], "base_salary": ["100000"], "date": ["2026-01-01"]}),
39
+ # DTMO
40
+ ("dtmo_conus", lambda c: c.dtmo.perdiem_conus(
41
+ "US-CO", date="2026-01-01", year=2026, query="Aspen", verification_url=True,
42
+ offset=5, limit=10), "/v1/dtmo/perdiem/conus/US-CO",
43
+ {"date": ["2026-01-01"], "year": ["2026"], "q": ["Aspen"],
44
+ "verification_url": ["true"], "offset": ["5"], "limit": ["10"]}),
45
+ ("dtmo_oconus", lambda c: c.dtmo.perdiem_oconus("AU", query="Adelaide", year=2026),
46
+ "/v1/dtmo/perdiem/oconus/AU", {"q": ["Adelaide"], "year": ["2026"]}),
47
+ ("dtmo_search", lambda c: c.dtmo.search_perdiem(
48
+ "Aspen", date="2026-01-01", year=2026, verification_url=True),
49
+ "/v1/dtmo/perdiem/search",
50
+ {"q": ["Aspen"], "date": ["2026-01-01"], "year": ["2026"],
51
+ "verification_url": ["true"]}),
52
+ ("dtmo_locations_conus", lambda c: c.dtmo.locations_conus("US-CA"),
53
+ "/v1/dtmo/locations/conus/US-CA", {}),
54
+ ("dtmo_locations_oconus", lambda c: c.dtmo.locations_oconus("AU", location="Sydney"),
55
+ "/v1/dtmo/locations/oconus/AU", {"location": ["Sydney"]}),
56
+ # GSA
57
+ ("gsa_perdiem_destination", lambda c: c.gsa.perdiem_by_destination(
58
+ 22, date="2026-01-01", verification_url=True),
59
+ "/v1/gsa/perdiem/destination/22", {"date": ["2026-01-01"], "verification_url": ["true"]}),
60
+ ("gsa_perdiem_state", lambda c: c.gsa.perdiem_by_state("NY", location="Albany"),
61
+ "/v1/gsa/perdiem/state/NY", {"location": ["Albany"]}),
62
+ ("gsa_perdiem_state_year", lambda c: c.gsa.perdiem_by_state_year(
63
+ "CA", 2026, location="Los Angeles", verification_url=True),
64
+ "/v1/gsa/perdiem/state/CA/year/2026",
65
+ {"location": ["Los Angeles"], "verification_url": ["true"]}),
66
+ ("gsa_perdiem_zipcode", lambda c: c.gsa.perdiem_by_zipcode(12207, date="2026-01-01"),
67
+ "/v1/gsa/perdiem/zipcode/12207", {"date": ["2026-01-01"]}),
68
+ ("gsa_perdiem_zipcode_year", lambda c: c.gsa.perdiem_by_zipcode_year(
69
+ "12207", 2026, verification_url=True),
70
+ "/v1/gsa/perdiem/zipcode/12207/year/2026", {"verification_url": ["true"]}),
71
+ ("gsa_zipcodes", lambda c: c.gsa.zipcodes("NY", offset=20, limit=40),
72
+ "/v1/gsa/zipcodes/NY", {"offset": ["20"], "limit": ["40"]}),
73
+ ("gsa_zipcode", lambda c: c.gsa.zipcode("12207"), "/v1/gsa/zipcode/12207", {}),
74
+ ("gsa_counties", lambda c: c.gsa.counties(query="Albany", state="NY", offset=1, limit=30),
75
+ "/v1/gsa/counties", {"q": ["Albany"], "state": ["NY"], "offset": ["1"], "limit": ["30"]}),
76
+ ("gsa_destination", lambda c: c.gsa.destination(22), "/v1/gsa/destination/22", {}),
77
+ ("gsa_destinations", lambda c: c.gsa.destinations(offset=5, limit=25),
78
+ "/v1/gsa/destinations", {"offset": ["5"], "limit": ["25"]}),
79
+ ("gsa_states", lambda c: c.gsa.states(), "/v1/gsa/states", {}),
80
+ ("gsa_mileage", lambda c: c.gsa.mileage(
81
+ date=date(2026, 1, 1), mileage_type="automobile", verification_url=True),
82
+ "/v1/gsa/mileage", {"date": ["2026-01-01"], "type": ["automobile"],
83
+ "verification_url": ["true"]}),
84
+ ("gsa_vehicle_rates", lambda c: c.gsa.vehicle_rates(
85
+ "standard", "10B", year=2026, verification_url=True),
86
+ "/v1/gsa/vehicle-rates/standard/10B", {"year": ["2026"], "verification_url": ["true"]}),
87
+ ]
88
+
89
+
90
+ @pytest.mark.parametrize(("name", "invoke", "expected_path", "expected_params"), ENDPOINT_CASES,
91
+ ids=[case[0] for case in ENDPOINT_CASES])
92
+ def test_endpoint_contract(name, invoke, expected_path, expected_params):
93
+ seen = []
94
+
95
+ def handler(request):
96
+ seen.append(request)
97
+ return httpx.Response(200, json={"endpoint": name})
98
+
99
+ http = httpx.Client(base_url="https://example.test/v1", transport=httpx.MockTransport(handler))
100
+ client = AllowancesClient("test-key", http_client=http)
101
+ try:
102
+ assert invoke(client) == {"endpoint": name}
103
+ finally:
104
+ http.close()
105
+
106
+ assert len(seen) == 1
107
+ request = seen[0]
108
+ assert request.method == "GET"
109
+ assert request.url.raw_path.decode().split("?", 1)[0] == expected_path
110
+ assert {key: request.url.params.get_list(key) for key in request.url.params} == expected_params
111
+ assert request.headers["X-API-Key"] == "test-key"
112
+
113
+
114
+ def test_path_segments_are_percent_encoded():
115
+ paths = []
116
+
117
+ def handler(request):
118
+ paths.append(request.url.raw_path.decode())
119
+ return httpx.Response(200, json={})
120
+
121
+ http = httpx.Client(base_url="https://example.test/v1", transport=httpx.MockTransport(handler))
122
+ client = AllowancesClient("key", http_client=http)
123
+ try:
124
+ client.dssr.perdiem_by_location("CI", "A/B #1")
125
+ finally:
126
+ http.close()
127
+
128
+ assert paths == ["/v1/dssr/perdiem/CI/location/A%2FB%20%231"]
129
+
130
+
131
+ def test_none_query_values_are_omitted_but_false_and_zero_are_preserved():
132
+ queries = []
133
+
134
+ def handler(request):
135
+ queries.append(request.url.params)
136
+ return httpx.Response(200, json={})
137
+
138
+ http = httpx.Client(base_url="https://example.test/v1", transport=httpx.MockTransport(handler))
139
+ client = AllowancesClient("key", http_client=http)
140
+ try:
141
+ client.dssr.locations(query=None, offset=0, limit=10)
142
+ finally:
143
+ http.close()
144
+
145
+ assert dict(queries[0]) == {"offset": "0", "limit": "10"}
146
+