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.
Files changed (47) hide show
  1. pharos_sdk-0.1.0/.github/workflows/ci.yml +73 -0
  2. pharos_sdk-0.1.0/.gitignore +10 -0
  3. pharos_sdk-0.1.0/CHANGELOG.md +23 -0
  4. pharos_sdk-0.1.0/LICENSE +21 -0
  5. pharos_sdk-0.1.0/PKG-INFO +240 -0
  6. pharos_sdk-0.1.0/README.md +209 -0
  7. pharos_sdk-0.1.0/docs/release.md +8 -0
  8. pharos_sdk-0.1.0/examples/async_usage.py +26 -0
  9. pharos_sdk-0.1.0/examples/sync_usage.py +20 -0
  10. pharos_sdk-0.1.0/pharos_sdk/__init__.py +8 -0
  11. pharos_sdk-0.1.0/pharos_sdk/async_client.py +52 -0
  12. pharos_sdk-0.1.0/pharos_sdk/base.py +13 -0
  13. pharos_sdk-0.1.0/pharos_sdk/client.py +52 -0
  14. pharos_sdk-0.1.0/pharos_sdk/config.py +75 -0
  15. pharos_sdk-0.1.0/pharos_sdk/exceptions.py +82 -0
  16. pharos_sdk-0.1.0/pharos_sdk/models.py +318 -0
  17. pharos_sdk-0.1.0/pharos_sdk/parsers/__init__.py +23 -0
  18. pharos_sdk-0.1.0/pharos_sdk/parsers/customs.py +23 -0
  19. pharos_sdk-0.1.0/pharos_sdk/parsers/declarations.py +174 -0
  20. pharos_sdk-0.1.0/pharos_sdk/parsers/envelopes.py +44 -0
  21. pharos_sdk-0.1.0/pharos_sdk/parsers/importers.py +34 -0
  22. pharos_sdk-0.1.0/pharos_sdk/parsers/items.py +219 -0
  23. pharos_sdk-0.1.0/pharos_sdk/parsers/primitives.py +220 -0
  24. pharos_sdk-0.1.0/pharos_sdk/parsers/webhook_events.py +119 -0
  25. pharos_sdk-0.1.0/pharos_sdk/py.typed +0 -0
  26. pharos_sdk-0.1.0/pharos_sdk/resources/__init__.py +11 -0
  27. pharos_sdk-0.1.0/pharos_sdk/resources/declarations.py +120 -0
  28. pharos_sdk-0.1.0/pharos_sdk/resources/importers.py +31 -0
  29. pharos_sdk-0.1.0/pharos_sdk/resources/paths.py +51 -0
  30. pharos_sdk-0.1.0/pharos_sdk/transport/__init__.py +8 -0
  31. pharos_sdk-0.1.0/pharos_sdk/transport/async_transport.py +51 -0
  32. pharos_sdk-0.1.0/pharos_sdk/transport/base.py +30 -0
  33. pharos_sdk-0.1.0/pharos_sdk/transport/errors.py +75 -0
  34. pharos_sdk-0.1.0/pharos_sdk/transport/response.py +66 -0
  35. pharos_sdk-0.1.0/pharos_sdk/transport/sync_transport.py +49 -0
  36. pharos_sdk-0.1.0/pharos_sdk/types.py +9 -0
  37. pharos_sdk-0.1.0/pharos_sdk/version.py +3 -0
  38. pharos_sdk-0.1.0/pharos_sdk/webhooks/__init__.py +5 -0
  39. pharos_sdk-0.1.0/pharos_sdk/webhooks/verifier.py +200 -0
  40. pharos_sdk-0.1.0/pyproject.toml +62 -0
  41. pharos_sdk-0.1.0/tests/__init__.py +0 -0
  42. pharos_sdk-0.1.0/tests/fixtures.py +191 -0
  43. pharos_sdk-0.1.0/tests/test_clients.py +441 -0
  44. pharos_sdk-0.1.0/tests/test_declaration_number.py +45 -0
  45. pharos_sdk-0.1.0/tests/test_parsers.py +283 -0
  46. pharos_sdk-0.1.0/tests/test_transport.py +269 -0
  47. 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,10 @@
1
+ /.mypy_cache/
2
+ /.pytest_cache/
3
+ /.ruff_cache/
4
+ /dist/
5
+ /.venv/
6
+ /uv.lock
7
+ *.egg-info/
8
+ __pycache__/
9
+ .coverage
10
+ *.swp
@@ -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>`.
@@ -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()