pharos-sdk 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.
- pharos_sdk-0.1.0/.github/workflows/ci.yml +73 -0
- pharos_sdk-0.1.0/.gitignore +10 -0
- pharos_sdk-0.1.0/CHANGELOG.md +23 -0
- pharos_sdk-0.1.0/LICENSE +21 -0
- pharos_sdk-0.1.0/PKG-INFO +240 -0
- pharos_sdk-0.1.0/README.md +209 -0
- pharos_sdk-0.1.0/docs/release.md +8 -0
- pharos_sdk-0.1.0/examples/async_usage.py +26 -0
- pharos_sdk-0.1.0/examples/sync_usage.py +20 -0
- pharos_sdk-0.1.0/pharos_sdk/__init__.py +8 -0
- pharos_sdk-0.1.0/pharos_sdk/async_client.py +52 -0
- pharos_sdk-0.1.0/pharos_sdk/base.py +13 -0
- pharos_sdk-0.1.0/pharos_sdk/client.py +52 -0
- pharos_sdk-0.1.0/pharos_sdk/config.py +75 -0
- pharos_sdk-0.1.0/pharos_sdk/exceptions.py +82 -0
- pharos_sdk-0.1.0/pharos_sdk/models.py +318 -0
- pharos_sdk-0.1.0/pharos_sdk/parsers/__init__.py +23 -0
- pharos_sdk-0.1.0/pharos_sdk/parsers/customs.py +23 -0
- pharos_sdk-0.1.0/pharos_sdk/parsers/declarations.py +174 -0
- pharos_sdk-0.1.0/pharos_sdk/parsers/envelopes.py +44 -0
- pharos_sdk-0.1.0/pharos_sdk/parsers/importers.py +34 -0
- pharos_sdk-0.1.0/pharos_sdk/parsers/items.py +219 -0
- pharos_sdk-0.1.0/pharos_sdk/parsers/primitives.py +220 -0
- pharos_sdk-0.1.0/pharos_sdk/parsers/webhook_events.py +119 -0
- pharos_sdk-0.1.0/pharos_sdk/py.typed +0 -0
- pharos_sdk-0.1.0/pharos_sdk/resources/__init__.py +11 -0
- pharos_sdk-0.1.0/pharos_sdk/resources/declarations.py +120 -0
- pharos_sdk-0.1.0/pharos_sdk/resources/importers.py +31 -0
- pharos_sdk-0.1.0/pharos_sdk/resources/paths.py +51 -0
- pharos_sdk-0.1.0/pharos_sdk/transport/__init__.py +8 -0
- pharos_sdk-0.1.0/pharos_sdk/transport/async_transport.py +51 -0
- pharos_sdk-0.1.0/pharos_sdk/transport/base.py +30 -0
- pharos_sdk-0.1.0/pharos_sdk/transport/errors.py +75 -0
- pharos_sdk-0.1.0/pharos_sdk/transport/response.py +66 -0
- pharos_sdk-0.1.0/pharos_sdk/transport/sync_transport.py +49 -0
- pharos_sdk-0.1.0/pharos_sdk/types.py +9 -0
- pharos_sdk-0.1.0/pharos_sdk/version.py +3 -0
- pharos_sdk-0.1.0/pharos_sdk/webhooks/__init__.py +5 -0
- pharos_sdk-0.1.0/pharos_sdk/webhooks/verifier.py +200 -0
- pharos_sdk-0.1.0/pyproject.toml +62 -0
- pharos_sdk-0.1.0/tests/__init__.py +0 -0
- pharos_sdk-0.1.0/tests/fixtures.py +191 -0
- pharos_sdk-0.1.0/tests/test_clients.py +441 -0
- pharos_sdk-0.1.0/tests/test_declaration_number.py +45 -0
- pharos_sdk-0.1.0/tests/test_parsers.py +283 -0
- pharos_sdk-0.1.0/tests/test_transport.py +269 -0
- pharos_sdk-0.1.0/tests/test_webhooks.py +243 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
tags: ['v*']
|
|
7
|
+
pull_request:
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ${{ matrix.os }}
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14']
|
|
15
|
+
os: [ubuntu-latest]
|
|
16
|
+
include:
|
|
17
|
+
# ubuntu-latest is 24.04, and setup-python no longer publishes a 3.8
|
|
18
|
+
# build for it. We still support 3.8 because the ERPs this SDK talks
|
|
19
|
+
# to are not always on a current runtime, so it is tested on 22.04.
|
|
20
|
+
- python-version: '3.8'
|
|
21
|
+
os: ubuntu-22.04
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
- uses: actions/setup-python@v5
|
|
25
|
+
with:
|
|
26
|
+
python-version: ${{ matrix.python-version }}
|
|
27
|
+
- name: Install
|
|
28
|
+
run: |
|
|
29
|
+
python -m pip install --upgrade pip
|
|
30
|
+
python -m pip install -e '.[dev]'
|
|
31
|
+
- name: Ruff
|
|
32
|
+
run: ruff check .
|
|
33
|
+
- name: Mypy
|
|
34
|
+
run: mypy --no-site-packages pharos_sdk
|
|
35
|
+
- name: Pytest
|
|
36
|
+
run: pytest
|
|
37
|
+
|
|
38
|
+
publish:
|
|
39
|
+
needs: test
|
|
40
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
41
|
+
runs-on: ubuntu-latest
|
|
42
|
+
permissions:
|
|
43
|
+
id-token: write
|
|
44
|
+
contents: read
|
|
45
|
+
steps:
|
|
46
|
+
- uses: actions/checkout@v4
|
|
47
|
+
- uses: actions/setup-python@v5
|
|
48
|
+
with:
|
|
49
|
+
python-version: '3.12'
|
|
50
|
+
- name: Check the tag matches the package version
|
|
51
|
+
run: |
|
|
52
|
+
# Anchored to the assignment: an unanchored quoted-string match reads
|
|
53
|
+
# the module docstring instead, which is what it did the first time
|
|
54
|
+
# this guard ever ran.
|
|
55
|
+
version="$(python - <<'PY'
|
|
56
|
+
import pathlib
|
|
57
|
+
import re
|
|
58
|
+
|
|
59
|
+
text = pathlib.Path("pharos_sdk/version.py").read_text()
|
|
60
|
+
print(re.search(r'^__version__\s*=\s*"([^"]+)"', text, re.M).group(1))
|
|
61
|
+
PY
|
|
62
|
+
)"
|
|
63
|
+
tag="${GITHUB_REF_NAME#v}"
|
|
64
|
+
if [ "$version" != "$tag" ]; then
|
|
65
|
+
echo "Tag $GITHUB_REF_NAME does not match version.py ($version)." >&2
|
|
66
|
+
exit 1
|
|
67
|
+
fi
|
|
68
|
+
- name: Build
|
|
69
|
+
run: |
|
|
70
|
+
python -m pip install --upgrade build
|
|
71
|
+
python -m build
|
|
72
|
+
- name: Publish
|
|
73
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 - 2026-08-02
|
|
4
|
+
|
|
5
|
+
- Initial Pharos Connect SDK with sync and async clients.
|
|
6
|
+
- Add dataclass response models and explicit parsers for the OpenAPI contract.
|
|
7
|
+
- Add webhook signature verification and event parsing. An unrecognised event
|
|
8
|
+
type raises `UnsupportedWebhookEvent`, separate from
|
|
9
|
+
`WebhookVerificationError`, so a handler can tell a new event apart from a
|
|
10
|
+
bad delivery and acknowledge it: a rejected delivery counts as a failure, and
|
|
11
|
+
enough of those suspend the endpoint.
|
|
12
|
+
- Accept a webhook body in either shape the platform delivers: pass the
|
|
13
|
+
request's `Content-Type` to `verify` and a form-encoded endpoint is read
|
|
14
|
+
as readily as a JSON one, producing the same event object.
|
|
15
|
+
- Send the API key as `Authorization: Bearer` or, with
|
|
16
|
+
`auth_scheme="api_key"`, as `X-Pharos-Key`, for callers behind a proxy
|
|
17
|
+
that claims the `Authorization` header.
|
|
18
|
+
- Ship inline type information (PEP 561).
|
|
19
|
+
- Map every error response to `APIError`, including gateway pages and empty
|
|
20
|
+
bodies that never reached the API.
|
|
21
|
+
- Address requests absolutely, so a caller-supplied `httpx` client works without
|
|
22
|
+
repeating the base URL.
|
|
23
|
+
- Identify the SDK with a `User-Agent` of `pharos-sdk/<version>`.
|
pharos_sdk-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pharos
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pharos-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for Pharos Connect.
|
|
5
|
+
Project-URL: Homepage, https://pharos.pe/desarrolladores/
|
|
6
|
+
Project-URL: Repository, https://github.com/pharos-pe/pharos-python-sdk
|
|
7
|
+
Project-URL: Changelog, https://github.com/pharos-pe/pharos-python-sdk/blob/main/CHANGELOG.md
|
|
8
|
+
Author-email: Pharos <contacto@pharos.pe>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: aduanas,customs,dua,peru,pharos,sunat,webhooks
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.8
|
|
24
|
+
Requires-Dist: httpx>=0.27
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: mypy<1.15,>=1.11; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-cov<6,>=5.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest<9,>=8.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# pharos-sdk
|
|
33
|
+
|
|
34
|
+
Official Python SDK for Pharos Connect.
|
|
35
|
+
|
|
36
|
+
- Documentation: https://pharos.pe/desarrolladores/
|
|
37
|
+
- Repository: https://github.com/pharos-pe/pharos-python-sdk
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install pharos-sdk
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Supports Python 3.8+.
|
|
46
|
+
|
|
47
|
+
## API Client
|
|
48
|
+
|
|
49
|
+
Use the client directly when your application owns a long-lived process.
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
import os
|
|
53
|
+
|
|
54
|
+
from pharos_sdk import PharosClient
|
|
55
|
+
|
|
56
|
+
client = PharosClient(os.environ["PHAROS_API_KEY"])
|
|
57
|
+
latest = client.declarations.latest(limit=10)
|
|
58
|
+
|
|
59
|
+
for summary in latest.data:
|
|
60
|
+
declaration = client.declarations.get(summary.full_number)
|
|
61
|
+
process_declaration(declaration)
|
|
62
|
+
|
|
63
|
+
client.close()
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Use a context manager when the client is short-lived.
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
import os
|
|
70
|
+
|
|
71
|
+
from pharos_sdk import PharosClient
|
|
72
|
+
|
|
73
|
+
with PharosClient(os.environ["PHAROS_API_KEY"]) as client:
|
|
74
|
+
importers = client.importers.list()
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Sending the key in `X-Pharos-Key`
|
|
78
|
+
|
|
79
|
+
The key travels in `Authorization: Bearer …` by default. Pharos also accepts it
|
|
80
|
+
in `X-Pharos-Key`, which is what to reach for when something between you and the
|
|
81
|
+
API takes the `Authorization` header for itself — a corporate proxy or an API
|
|
82
|
+
gateway that authenticates you to itself and rewrites it on the way out.
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
client = PharosClient(os.environ["PHAROS_API_KEY"], auth_scheme="api_key")
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Both clients take it, and both accept the `AuthScheme` enum if you would rather
|
|
89
|
+
not pass a string:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from pharos_sdk import AuthScheme
|
|
93
|
+
|
|
94
|
+
client = PharosClient(os.environ["PHAROS_API_KEY"], auth_scheme=AuthScheme.API_KEY)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Anything other than `"bearer"` or `"api_key"` raises `ConfigurationError` when
|
|
98
|
+
the client is built, rather than on the first request.
|
|
99
|
+
|
|
100
|
+
## Async Client
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
import os
|
|
104
|
+
|
|
105
|
+
from pharos_sdk import AsyncPharosClient
|
|
106
|
+
|
|
107
|
+
client = AsyncPharosClient(os.environ["PHAROS_API_KEY"])
|
|
108
|
+
latest = await client.declarations.latest()
|
|
109
|
+
await client.close()
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
import os
|
|
114
|
+
|
|
115
|
+
from pharos_sdk import AsyncPharosClient
|
|
116
|
+
|
|
117
|
+
async with AsyncPharosClient(os.environ["PHAROS_API_KEY"]) as client:
|
|
118
|
+
importers = await client.importers.list()
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Errors
|
|
122
|
+
|
|
123
|
+
Every SDK exception derives from `PharosError`. Failures that reached the API and
|
|
124
|
+
came back with a status derive from `APIError`, which carries `status_code`, the
|
|
125
|
+
documented `code`, and the response `headers`.
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
declaration = client.declarations.get("118-2026-10-001234-00")
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Catch `NotFoundError` when a missing declaration is part of your normal workflow.
|
|
132
|
+
Catch `RateLimitError` or `ServerError` at your job boundary if you want to back
|
|
133
|
+
off and retry.
|
|
134
|
+
|
|
135
|
+
| Exception | Raised when |
|
|
136
|
+
|---|---|
|
|
137
|
+
| `AuthenticationError` | The key is missing or invalid (401) |
|
|
138
|
+
| `AuthorizationError` | Pharos Connect is not enabled for the account, or the source IP is not on the key's allowlist (403) |
|
|
139
|
+
| `NotFoundError` | The resource does not exist **or is outside your scope** (404) |
|
|
140
|
+
| `RateLimitError` | The hourly request limit was exceeded (429). `retry_after` holds the seconds to wait |
|
|
141
|
+
| `InvalidRequestError` | A parameter is not valid (400) |
|
|
142
|
+
| `ServerError` | An unexpected failure on the Pharos side, including gateway errors (5xx) |
|
|
143
|
+
| `TransportError` | The request never got a response — DNS, TLS, connection or timeout |
|
|
144
|
+
| `ResponseDecodeError` | A successful response did not match the published contract |
|
|
145
|
+
|
|
146
|
+
**The SDK does not retry.** Reads are idempotent, so retrying is safe, but the
|
|
147
|
+
policy is yours: catch `RateLimitError` and `ServerError`, and back off using
|
|
148
|
+
`retry_after` when it is set.
|
|
149
|
+
|
|
150
|
+
## Declaration Numbers
|
|
151
|
+
|
|
152
|
+
Declaration methods accept either the full DUA number as a string or a structured
|
|
153
|
+
`DeclarationNumber`.
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
from pharos_sdk.models import DeclarationNumber
|
|
157
|
+
|
|
158
|
+
number = DeclarationNumber(
|
|
159
|
+
customs_office="118",
|
|
160
|
+
year=2026,
|
|
161
|
+
regime="10",
|
|
162
|
+
number=1234,
|
|
163
|
+
control_number="00",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
declaration = client.declarations.get(number)
|
|
167
|
+
items = client.declarations.items(number)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Webhooks
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
import os
|
|
174
|
+
|
|
175
|
+
from pharos_sdk.webhooks import WebhookVerifier
|
|
176
|
+
|
|
177
|
+
verifier = WebhookVerifier(os.environ["PHAROS_WEBHOOK_SECRET"])
|
|
178
|
+
event = verifier.verify(request_body, signature_header)
|
|
179
|
+
handle_event(event)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Pass the body **exactly as it arrived**, before any parsing: the signature covers
|
|
183
|
+
the raw bytes. `verify` checks the signature and the timestamp — five minutes of
|
|
184
|
+
tolerance by default — and raises `WebhookVerificationError` if either fails.
|
|
185
|
+
|
|
186
|
+
### Form-encoded endpoints
|
|
187
|
+
|
|
188
|
+
An endpoint can be configured to receive `application/x-www-form-urlencoded`
|
|
189
|
+
instead of JSON. Pass the request's `Content-Type` and the verifier reads either:
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
event = verifier.verify(
|
|
193
|
+
request_body,
|
|
194
|
+
signature_header,
|
|
195
|
+
content_type=request.headers["Content-Type"],
|
|
196
|
+
)
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Nothing is inferred from the body — Pharos always sends the header, so it is the
|
|
200
|
+
sender's own statement of which shape it used. Omit the argument and the body is
|
|
201
|
+
read as JSON, which is what an endpoint receives unless you asked for the other.
|
|
202
|
+
A `Content-Type` that is neither raises `WebhookVerificationError` rather than
|
|
203
|
+
being read as a guess.
|
|
204
|
+
|
|
205
|
+
You get the same event object either way. A form carries no types, so Pharos
|
|
206
|
+
sends every value as text and the two objects as text holding JSON; the verifier
|
|
207
|
+
undoes exactly that. The one difference is `event.extra`: any additional field
|
|
208
|
+
configured for your endpoint stays a string, because the form did not carry its
|
|
209
|
+
type either.
|
|
210
|
+
|
|
211
|
+
An event type newer than your installed SDK raises `UnsupportedWebhookEvent`,
|
|
212
|
+
which is deliberately **not** a `WebhookVerificationError`: the delivery is
|
|
213
|
+
authentic, only its type is unknown. Catch it and answer 2xx, or repeated
|
|
214
|
+
failures will suspend your endpoint.
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
event = verifier.verify(request_body, signature_header)
|
|
218
|
+
handle_event(event)
|
|
219
|
+
return http_200()
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
At your webhook boundary, answer `2xx` for `UnsupportedWebhookEvent` only when
|
|
223
|
+
your system deliberately ignores event types unknown to this SDK version.
|
|
224
|
+
|
|
225
|
+
Deduplicate on `event.event_id`. It is the same across retries **and** across
|
|
226
|
+
every endpoint subscribed to the same fact, which the delivery id is not.
|
|
227
|
+
|
|
228
|
+
Anything Pharos was asked to add to your notifications — a tenant identifier, a
|
|
229
|
+
routing key — arrives in `event.extra`, keyed as you configured it. The
|
|
230
|
+
documented fields are attributes; `extra` is everything else.
|
|
231
|
+
|
|
232
|
+
## Development
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
uv run pytest
|
|
236
|
+
uv run ruff check .
|
|
237
|
+
uv run mypy --no-site-packages pharos_sdk
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
`pytest` prints coverage by default.
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# pharos-sdk
|
|
2
|
+
|
|
3
|
+
Official Python SDK for Pharos Connect.
|
|
4
|
+
|
|
5
|
+
- Documentation: https://pharos.pe/desarrolladores/
|
|
6
|
+
- Repository: https://github.com/pharos-pe/pharos-python-sdk
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install pharos-sdk
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Supports Python 3.8+.
|
|
15
|
+
|
|
16
|
+
## API Client
|
|
17
|
+
|
|
18
|
+
Use the client directly when your application owns a long-lived process.
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import os
|
|
22
|
+
|
|
23
|
+
from pharos_sdk import PharosClient
|
|
24
|
+
|
|
25
|
+
client = PharosClient(os.environ["PHAROS_API_KEY"])
|
|
26
|
+
latest = client.declarations.latest(limit=10)
|
|
27
|
+
|
|
28
|
+
for summary in latest.data:
|
|
29
|
+
declaration = client.declarations.get(summary.full_number)
|
|
30
|
+
process_declaration(declaration)
|
|
31
|
+
|
|
32
|
+
client.close()
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Use a context manager when the client is short-lived.
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import os
|
|
39
|
+
|
|
40
|
+
from pharos_sdk import PharosClient
|
|
41
|
+
|
|
42
|
+
with PharosClient(os.environ["PHAROS_API_KEY"]) as client:
|
|
43
|
+
importers = client.importers.list()
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Sending the key in `X-Pharos-Key`
|
|
47
|
+
|
|
48
|
+
The key travels in `Authorization: Bearer …` by default. Pharos also accepts it
|
|
49
|
+
in `X-Pharos-Key`, which is what to reach for when something between you and the
|
|
50
|
+
API takes the `Authorization` header for itself — a corporate proxy or an API
|
|
51
|
+
gateway that authenticates you to itself and rewrites it on the way out.
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
client = PharosClient(os.environ["PHAROS_API_KEY"], auth_scheme="api_key")
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Both clients take it, and both accept the `AuthScheme` enum if you would rather
|
|
58
|
+
not pass a string:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from pharos_sdk import AuthScheme
|
|
62
|
+
|
|
63
|
+
client = PharosClient(os.environ["PHAROS_API_KEY"], auth_scheme=AuthScheme.API_KEY)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Anything other than `"bearer"` or `"api_key"` raises `ConfigurationError` when
|
|
67
|
+
the client is built, rather than on the first request.
|
|
68
|
+
|
|
69
|
+
## Async Client
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
import os
|
|
73
|
+
|
|
74
|
+
from pharos_sdk import AsyncPharosClient
|
|
75
|
+
|
|
76
|
+
client = AsyncPharosClient(os.environ["PHAROS_API_KEY"])
|
|
77
|
+
latest = await client.declarations.latest()
|
|
78
|
+
await client.close()
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
import os
|
|
83
|
+
|
|
84
|
+
from pharos_sdk import AsyncPharosClient
|
|
85
|
+
|
|
86
|
+
async with AsyncPharosClient(os.environ["PHAROS_API_KEY"]) as client:
|
|
87
|
+
importers = await client.importers.list()
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Errors
|
|
91
|
+
|
|
92
|
+
Every SDK exception derives from `PharosError`. Failures that reached the API and
|
|
93
|
+
came back with a status derive from `APIError`, which carries `status_code`, the
|
|
94
|
+
documented `code`, and the response `headers`.
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
declaration = client.declarations.get("118-2026-10-001234-00")
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Catch `NotFoundError` when a missing declaration is part of your normal workflow.
|
|
101
|
+
Catch `RateLimitError` or `ServerError` at your job boundary if you want to back
|
|
102
|
+
off and retry.
|
|
103
|
+
|
|
104
|
+
| Exception | Raised when |
|
|
105
|
+
|---|---|
|
|
106
|
+
| `AuthenticationError` | The key is missing or invalid (401) |
|
|
107
|
+
| `AuthorizationError` | Pharos Connect is not enabled for the account, or the source IP is not on the key's allowlist (403) |
|
|
108
|
+
| `NotFoundError` | The resource does not exist **or is outside your scope** (404) |
|
|
109
|
+
| `RateLimitError` | The hourly request limit was exceeded (429). `retry_after` holds the seconds to wait |
|
|
110
|
+
| `InvalidRequestError` | A parameter is not valid (400) |
|
|
111
|
+
| `ServerError` | An unexpected failure on the Pharos side, including gateway errors (5xx) |
|
|
112
|
+
| `TransportError` | The request never got a response — DNS, TLS, connection or timeout |
|
|
113
|
+
| `ResponseDecodeError` | A successful response did not match the published contract |
|
|
114
|
+
|
|
115
|
+
**The SDK does not retry.** Reads are idempotent, so retrying is safe, but the
|
|
116
|
+
policy is yours: catch `RateLimitError` and `ServerError`, and back off using
|
|
117
|
+
`retry_after` when it is set.
|
|
118
|
+
|
|
119
|
+
## Declaration Numbers
|
|
120
|
+
|
|
121
|
+
Declaration methods accept either the full DUA number as a string or a structured
|
|
122
|
+
`DeclarationNumber`.
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from pharos_sdk.models import DeclarationNumber
|
|
126
|
+
|
|
127
|
+
number = DeclarationNumber(
|
|
128
|
+
customs_office="118",
|
|
129
|
+
year=2026,
|
|
130
|
+
regime="10",
|
|
131
|
+
number=1234,
|
|
132
|
+
control_number="00",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
declaration = client.declarations.get(number)
|
|
136
|
+
items = client.declarations.items(number)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Webhooks
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
import os
|
|
143
|
+
|
|
144
|
+
from pharos_sdk.webhooks import WebhookVerifier
|
|
145
|
+
|
|
146
|
+
verifier = WebhookVerifier(os.environ["PHAROS_WEBHOOK_SECRET"])
|
|
147
|
+
event = verifier.verify(request_body, signature_header)
|
|
148
|
+
handle_event(event)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Pass the body **exactly as it arrived**, before any parsing: the signature covers
|
|
152
|
+
the raw bytes. `verify` checks the signature and the timestamp — five minutes of
|
|
153
|
+
tolerance by default — and raises `WebhookVerificationError` if either fails.
|
|
154
|
+
|
|
155
|
+
### Form-encoded endpoints
|
|
156
|
+
|
|
157
|
+
An endpoint can be configured to receive `application/x-www-form-urlencoded`
|
|
158
|
+
instead of JSON. Pass the request's `Content-Type` and the verifier reads either:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
event = verifier.verify(
|
|
162
|
+
request_body,
|
|
163
|
+
signature_header,
|
|
164
|
+
content_type=request.headers["Content-Type"],
|
|
165
|
+
)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Nothing is inferred from the body — Pharos always sends the header, so it is the
|
|
169
|
+
sender's own statement of which shape it used. Omit the argument and the body is
|
|
170
|
+
read as JSON, which is what an endpoint receives unless you asked for the other.
|
|
171
|
+
A `Content-Type` that is neither raises `WebhookVerificationError` rather than
|
|
172
|
+
being read as a guess.
|
|
173
|
+
|
|
174
|
+
You get the same event object either way. A form carries no types, so Pharos
|
|
175
|
+
sends every value as text and the two objects as text holding JSON; the verifier
|
|
176
|
+
undoes exactly that. The one difference is `event.extra`: any additional field
|
|
177
|
+
configured for your endpoint stays a string, because the form did not carry its
|
|
178
|
+
type either.
|
|
179
|
+
|
|
180
|
+
An event type newer than your installed SDK raises `UnsupportedWebhookEvent`,
|
|
181
|
+
which is deliberately **not** a `WebhookVerificationError`: the delivery is
|
|
182
|
+
authentic, only its type is unknown. Catch it and answer 2xx, or repeated
|
|
183
|
+
failures will suspend your endpoint.
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
event = verifier.verify(request_body, signature_header)
|
|
187
|
+
handle_event(event)
|
|
188
|
+
return http_200()
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
At your webhook boundary, answer `2xx` for `UnsupportedWebhookEvent` only when
|
|
192
|
+
your system deliberately ignores event types unknown to this SDK version.
|
|
193
|
+
|
|
194
|
+
Deduplicate on `event.event_id`. It is the same across retries **and** across
|
|
195
|
+
every endpoint subscribed to the same fact, which the delivery id is not.
|
|
196
|
+
|
|
197
|
+
Anything Pharos was asked to add to your notifications — a tenant identifier, a
|
|
198
|
+
routing key — arrives in `event.extra`, keyed as you configured it. The
|
|
199
|
+
documented fields are attributes; `extra` is everything else.
|
|
200
|
+
|
|
201
|
+
## Development
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
uv run pytest
|
|
205
|
+
uv run ruff check .
|
|
206
|
+
uv run mypy --no-site-packages pharos_sdk
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
`pytest` prints coverage by default.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Release Process
|
|
2
|
+
|
|
3
|
+
The SDK follows Semantic Versioning independently from the `/api/v1` API version.
|
|
4
|
+
|
|
5
|
+
1. Update `CHANGELOG.md`.
|
|
6
|
+
2. Run `ruff check .`, `mypy --no-site-packages pharos_sdk`, and `pytest`.
|
|
7
|
+
3. Create an immutable signed tag such as `v0.1.0`.
|
|
8
|
+
4. Push the tag. GitHub Actions publishes to PyPI through Trusted Publishing.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from pharos_sdk import AsyncPharosClient
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
async def with_context_manager() -> None:
|
|
7
|
+
async with AsyncPharosClient("phk_live_...") as client:
|
|
8
|
+
importers = await client.importers.list()
|
|
9
|
+
latest = await client.declarations.latest(limit=10)
|
|
10
|
+
print(importers.count, latest.count)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def without_context_manager() -> None:
|
|
14
|
+
client = AsyncPharosClient("phk_live_...")
|
|
15
|
+
importers = await client.importers.list()
|
|
16
|
+
latest = await client.declarations.latest(limit=10)
|
|
17
|
+
print(importers.count, latest.count)
|
|
18
|
+
await client.close()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def main() -> None:
|
|
22
|
+
await with_context_manager()
|
|
23
|
+
await without_context_manager()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from pharos_sdk import PharosClient
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def with_context_manager() -> None:
|
|
5
|
+
with PharosClient("phk_live_...") as client:
|
|
6
|
+
importers = client.importers.list()
|
|
7
|
+
latest = client.declarations.latest(limit=10)
|
|
8
|
+
print(importers.count, latest.count)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def without_context_manager() -> None:
|
|
12
|
+
client = PharosClient("phk_live_...")
|
|
13
|
+
importers = client.importers.list()
|
|
14
|
+
latest = client.declarations.latest(limit=10)
|
|
15
|
+
print(importers.count, latest.count)
|
|
16
|
+
client.close()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
with_context_manager()
|
|
20
|
+
without_context_manager()
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Official Python SDK for Pharos Connect."""
|
|
2
|
+
|
|
3
|
+
from pharos_sdk.async_client import AsyncPharosClient
|
|
4
|
+
from pharos_sdk.client import PharosClient
|
|
5
|
+
from pharos_sdk.config import AuthScheme
|
|
6
|
+
from pharos_sdk.version import __version__
|
|
7
|
+
|
|
8
|
+
__all__ = ["AsyncPharosClient", "AuthScheme", "PharosClient", "__version__"]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Asynchronous Pharos Connect client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Dict, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from pharos_sdk.base import BaseClient
|
|
10
|
+
from pharos_sdk.config import BASE_URL, AuthScheme, AuthSchemeInput, make_config
|
|
11
|
+
from pharos_sdk.resources import AsyncDeclarationsResource, AsyncImportersResource
|
|
12
|
+
from pharos_sdk.transport import AsyncTransport
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AsyncPharosClient(BaseClient):
|
|
16
|
+
"""Asynchronous client for the Pharos Connect API."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
api_key: str,
|
|
21
|
+
*,
|
|
22
|
+
base_url: str = BASE_URL,
|
|
23
|
+
timeout: float = 10.0,
|
|
24
|
+
headers: Optional[Dict[str, str]] = None,
|
|
25
|
+
auth_scheme: AuthSchemeInput = AuthScheme.BEARER,
|
|
26
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Create an asynchronous Pharos Connect client."""
|
|
29
|
+
config = make_config(
|
|
30
|
+
api_key=api_key,
|
|
31
|
+
base_url=base_url,
|
|
32
|
+
timeout=timeout,
|
|
33
|
+
headers=headers,
|
|
34
|
+
auth_scheme=auth_scheme,
|
|
35
|
+
)
|
|
36
|
+
self.transport = AsyncTransport(config, http_client=http_client)
|
|
37
|
+
self.importers = AsyncImportersResource(self.transport)
|
|
38
|
+
self.declarations = AsyncDeclarationsResource(self.transport)
|
|
39
|
+
|
|
40
|
+
async def close(self) -> None:
|
|
41
|
+
"""Close the underlying HTTP client if this SDK instance owns it."""
|
|
42
|
+
await self.transport.close()
|
|
43
|
+
|
|
44
|
+
def is_closed(self) -> bool:
|
|
45
|
+
"""Return whether the underlying HTTP client is closed."""
|
|
46
|
+
return bool(self.transport.client.is_closed)
|
|
47
|
+
|
|
48
|
+
async def __aenter__(self) -> AsyncPharosClient:
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
|
52
|
+
await self.close()
|