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.
- allowances_api-0.1.0/.gitignore +49 -0
- allowances_api-0.1.0/LICENSE +22 -0
- allowances_api-0.1.0/PKG-INFO +223 -0
- allowances_api-0.1.0/README.md +191 -0
- allowances_api-0.1.0/pyproject.toml +46 -0
- allowances_api-0.1.0/src/allowances_api/__init__.py +26 -0
- allowances_api-0.1.0/src/allowances_api/_utils.py +24 -0
- allowances_api-0.1.0/src/allowances_api/client.py +100 -0
- allowances_api-0.1.0/src/allowances_api/exceptions.py +60 -0
- allowances_api-0.1.0/src/allowances_api/py.typed +1 -0
- allowances_api-0.1.0/src/allowances_api/resources.py +153 -0
- allowances_api-0.1.0/tests/test_client.py +234 -0
- allowances_api-0.1.0/tests/test_endpoints.py +146 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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
|
+
|