pharos-sdk 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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()
pharos_sdk/base.py ADDED
@@ -0,0 +1,13 @@
1
+ """Base client contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+
7
+
8
+ class BaseClient(ABC):
9
+ """Common contract shared by sync and async clients."""
10
+
11
+ @abstractmethod
12
+ def is_closed(self) -> bool:
13
+ """Return whether the underlying SDK-owned transport is closed."""
pharos_sdk/client.py ADDED
@@ -0,0 +1,52 @@
1
+ """Synchronous 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 DeclarationsResource, ImportersResource
12
+ from pharos_sdk.transport import SyncTransport
13
+
14
+
15
+ class PharosClient(BaseClient):
16
+ """Synchronous 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.Client] = None,
27
+ ) -> None:
28
+ """Create a synchronous 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 = SyncTransport(config, http_client=http_client)
37
+ self.importers = ImportersResource(self.transport)
38
+ self.declarations = DeclarationsResource(self.transport)
39
+
40
+ def close(self) -> None:
41
+ """Close the underlying HTTP client if this SDK instance owns it."""
42
+ 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
+ def __enter__(self) -> PharosClient:
49
+ return self
50
+
51
+ def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
52
+ self.close()
pharos_sdk/config.py ADDED
@@ -0,0 +1,75 @@
1
+ """Client configuration for Pharos Connect."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ from typing import Dict, Optional, Union
8
+
9
+ from pharos_sdk.exceptions import ConfigurationError
10
+
11
+
12
+ class AuthScheme(str, Enum):
13
+ """Supported authentication header schemes."""
14
+
15
+ BEARER = "bearer"
16
+ API_KEY = "api_key"
17
+
18
+
19
+ AuthSchemeInput = Union[AuthScheme, str]
20
+ BASE_URL = "https://app.pharos.pe/api/v1"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ClientConfig:
25
+ """Configuration shared by sync and async clients."""
26
+
27
+ api_key: str
28
+ base_url: str = BASE_URL
29
+ timeout: float = 10.0
30
+ headers: Dict[str, str] = field(default_factory=dict)
31
+ auth_scheme: AuthScheme = AuthScheme.BEARER
32
+
33
+ def auth_headers(self) -> Dict[str, str]:
34
+ """Return authentication headers for the configured scheme."""
35
+ if self.auth_scheme == AuthScheme.BEARER:
36
+ return {"Authorization": f"Bearer {self.api_key}"}
37
+ elif self.auth_scheme == AuthScheme.API_KEY:
38
+ return {"X-Pharos-Key": self.api_key}
39
+ else:
40
+ raise ValueError(f"Unsupported auth scheme: {self.auth_scheme}")
41
+
42
+
43
+ def normalize_auth_scheme(auth_scheme: AuthSchemeInput) -> AuthScheme:
44
+ """Normalize a public auth scheme value into AuthScheme."""
45
+ if isinstance(auth_scheme, AuthScheme):
46
+ return auth_scheme
47
+ try:
48
+ return AuthScheme(auth_scheme)
49
+ except ValueError as e:
50
+ raise ValueError(f"Unsupported auth scheme: {auth_scheme}") from e
51
+
52
+
53
+ def make_config(
54
+ *,
55
+ api_key: str,
56
+ base_url: str,
57
+ timeout: float,
58
+ headers: Optional[Dict[str, str]],
59
+ auth_scheme: AuthSchemeInput,
60
+ ) -> ClientConfig:
61
+ """Validate client options and return a ClientConfig."""
62
+ if not api_key:
63
+ raise ConfigurationError("api_key is required.")
64
+ try:
65
+ normalized_auth_scheme = normalize_auth_scheme(auth_scheme)
66
+ except ValueError as e:
67
+ raise ConfigurationError("auth_scheme must be 'bearer' or 'api_key'.") from e
68
+ else:
69
+ return ClientConfig(
70
+ api_key=api_key,
71
+ base_url=base_url,
72
+ timeout=timeout,
73
+ headers=headers or {},
74
+ auth_scheme=normalized_auth_scheme,
75
+ )
@@ -0,0 +1,82 @@
1
+ """Exception hierarchy for Pharos Connect."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Dict, Optional
7
+
8
+
9
+ class PharosError(Exception):
10
+ """Base class for all SDK errors."""
11
+
12
+
13
+ class ConfigurationError(PharosError):
14
+ """Raised when client configuration is invalid."""
15
+
16
+
17
+ class TransportError(PharosError):
18
+ """Raised when the HTTP transport cannot complete a request."""
19
+
20
+
21
+ class ResponseDecodeError(PharosError):
22
+ """Raised when Pharos returns a response that cannot be decoded as JSON."""
23
+
24
+
25
+ @dataclass
26
+ class APIError(PharosError):
27
+ """Raised for documented Pharos API errors."""
28
+
29
+ message: str
30
+ status_code: int
31
+ code: Optional[str] = None
32
+ retry_after: Optional[int] = None
33
+ headers: Dict[str, str] = field(default_factory=dict)
34
+
35
+ def __str__(self) -> str:
36
+ code = f" ({self.code})" if self.code else ""
37
+ return f"Pharos API error {self.status_code}{code}: {self.message}"
38
+
39
+
40
+ class AuthenticationError(APIError):
41
+ """Raised for missing or invalid API keys."""
42
+
43
+
44
+ class AuthorizationError(APIError):
45
+ """Raised when the credential is valid but not allowed to access the API."""
46
+
47
+
48
+ class NotFoundError(APIError):
49
+ """Raised when a resource does not exist or is outside the credential scope."""
50
+
51
+
52
+ class RateLimitError(APIError):
53
+ """Raised when the API rate limit is exceeded."""
54
+
55
+
56
+ class InvalidRequestError(APIError):
57
+ """Raised when request parameters are invalid."""
58
+
59
+
60
+ class ServerError(APIError):
61
+ """Raised for unexpected Pharos server errors."""
62
+
63
+
64
+ class WebhookVerificationError(PharosError):
65
+ """Raised when a webhook signature or payload cannot be verified."""
66
+
67
+
68
+ @dataclass
69
+ class UnsupportedWebhookEvent(PharosError):
70
+ """Raised when a webhook carries an event type this version does not know.
71
+
72
+ The signature and the timestamp were already checked, so the delivery is
73
+ authentic — only its type is unrecognised. **Catch this and answer 2xx**: a
74
+ rejected delivery counts as a failure, and enough consecutive failures
75
+ suspend the endpoint, so treating a new event type as an error would take
76
+ the whole integration down instead of only the part that is new.
77
+ """
78
+
79
+ event: str
80
+
81
+ def __str__(self) -> str:
82
+ return f"Unsupported Pharos webhook event: {self.event}"
pharos_sdk/models.py ADDED
@@ -0,0 +1,318 @@
1
+ """Dataclass models for the Pharos Connect OpenAPI contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import date, datetime
7
+ from decimal import Decimal
8
+ from typing import Dict, Generic, Optional, Tuple, TypeVar, Union
9
+ from uuid import UUID
10
+
11
+ T = TypeVar("T")
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class ListEnvelope(Generic[T]):
16
+ """Common list response envelope returned by Pharos Connect."""
17
+
18
+ object: str
19
+ count: int
20
+ retrieved_at: datetime
21
+ data: Tuple[T, ...]
22
+ limit: Optional[int] = None
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Importer:
27
+ """Company included in the integration scope."""
28
+
29
+ object: str
30
+ ruc: str
31
+ name: str
32
+ address: str
33
+ department: Optional[str]
34
+ province: Optional[str]
35
+ district: Optional[str]
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class ImporterRef:
40
+ """Minimal importer identity used in summaries and webhooks."""
41
+
42
+ ruc: str
43
+ name: str
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class CustomsOffice:
48
+ """Customs office identity."""
49
+
50
+ code: str
51
+ name: str
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class CustomsBroker:
56
+ """Customs broker identity."""
57
+
58
+ code: str
59
+ name: str
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class Vehicle:
64
+ """Vehicle detail block for a vehicle item."""
65
+
66
+ category: str
67
+ vehicle_class: str
68
+ brand: str
69
+ model: str
70
+ version: str
71
+ model_year: Optional[int]
72
+ vin_number: str
73
+ chassis_number: str
74
+ engine_number: str
75
+ fuel_type: str
76
+ engine_cylinders_count: Optional[int]
77
+ engine_displacement_cc: Optional[Decimal]
78
+ engine_power: str
79
+ drivetrain: str
80
+ transmission_type: str
81
+ axles_count: int
82
+ wheels_count: Optional[Decimal]
83
+ seats_count: Optional[int]
84
+ passengers_count: int
85
+ color_1: str
86
+ color_2: Optional[str]
87
+ gross_weight: Optional[Decimal]
88
+ net_weight: Optional[Decimal]
89
+ load_capacity: Optional[Decimal]
90
+ length_mm: Optional[Decimal]
91
+ width_mm: Optional[Decimal]
92
+ height_mm: Optional[Decimal]
93
+ fob_usd: Optional[Decimal]
94
+ freight_usd: Optional[Decimal]
95
+ insurance_usd: Optional[Decimal]
96
+ additional_notes: str
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class Product:
101
+ """Product detail block for a product item."""
102
+
103
+ commercial_description: str
104
+ product_category: str
105
+ brand: str
106
+ model: str
107
+ quantity: Decimal
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class ItemBase:
112
+ """Fields shared by VehicleItem and ProductItem.
113
+
114
+ `retrieved_at` is set only when the item was fetched on its own, through
115
+ `declarations.item(...)`. Pharos puts it at the top level of every response,
116
+ which for a list is the envelope: read `ListEnvelope.retrieved_at` there, or
117
+ `Declaration.retrieved_at` for the items inside a declaration. It says when
118
+ Pharos built the response, not when the declaration was read from customs —
119
+ that is `created_at` and `updated_at`.
120
+ """
121
+
122
+ object: str
123
+ item_number: int
124
+ item_type: str
125
+ nandina_code: str
126
+ tariff_description: str
127
+ commercial_description: str
128
+ commercial_units: Decimal
129
+ commercial_unit_type: str
130
+ physical_units: Decimal
131
+ package_quantity: Decimal
132
+ package_class: str
133
+ net_weight_kg: Decimal
134
+ gross_weight_kg: Decimal
135
+ fob_usd: Decimal
136
+ freight_usd: Decimal
137
+ insurance_usd: Decimal
138
+ advalorem_usd: Decimal
139
+ igv_usd: Decimal
140
+ ipm_usd: Decimal
141
+ isc_usd: Decimal
142
+ origin_country: str
143
+ acquisition_country: str
144
+ port_of_loading: str
145
+ bill_of_lading: str
146
+ shipment_date: Optional[date]
147
+ invoice_numbers: Tuple[str, ...]
148
+ status: str
149
+ retrieved_at: Optional[datetime]
150
+
151
+
152
+ @dataclass(frozen=True)
153
+ class VehicleItem(ItemBase):
154
+ """Declaration item with a vehicle block."""
155
+
156
+ vehicle: Vehicle
157
+
158
+
159
+ @dataclass(frozen=True)
160
+ class ProductItem(ItemBase):
161
+ """Declaration item with a product block."""
162
+
163
+ product: Product
164
+
165
+
166
+ Item = Union[VehicleItem, ProductItem]
167
+
168
+
169
+ @dataclass(frozen=True)
170
+ class DeclarationNumber:
171
+ """Structured DUA number accepted by declaration resource methods."""
172
+
173
+ customs_office: str
174
+ year: int
175
+ regime: str
176
+ number: int
177
+ control_number: Optional[str] = None
178
+
179
+ def format(self) -> str:
180
+ """Return the DUA number in the API path format."""
181
+ base = f"{self.customs_office}-{self.year}-{self.regime}-{self.number:06d}"
182
+ if self.control_number is None:
183
+ return base
184
+ else:
185
+ return f"{base}-{self.control_number}"
186
+
187
+ def __str__(self) -> str:
188
+ return self.format()
189
+
190
+
191
+ @dataclass(frozen=True)
192
+ class DeclarationHeader:
193
+ """Fields shared by Declaration and DeclarationSummary."""
194
+
195
+ full_number: str
196
+ customs_office: CustomsOffice
197
+ year: int
198
+ number: int
199
+ revision: int
200
+ numbering_date: date
201
+ channel: str
202
+ customs_broker: CustomsBroker
203
+ created_at: datetime
204
+ updated_at: datetime
205
+
206
+
207
+ @dataclass(frozen=True)
208
+ class TotalsBase:
209
+ """Value totals present on every declaration representation."""
210
+
211
+ items: int
212
+ fob_usd: Decimal
213
+ freight_usd: Decimal
214
+ insurance_usd: Decimal
215
+ cif_usd: Decimal
216
+
217
+
218
+ @dataclass(frozen=True)
219
+ class DeclarationSummary(DeclarationHeader):
220
+ """Header-only declaration returned by the latest endpoint."""
221
+
222
+ object: str
223
+ importer: ImporterRef
224
+ totals: TotalsBase
225
+
226
+
227
+ @dataclass(frozen=True)
228
+ class Transport:
229
+ """Transport block of a full declaration."""
230
+
231
+ mode: str
232
+ company: Optional[str]
233
+ terminal: str
234
+ manifest_number: str
235
+
236
+
237
+ @dataclass(frozen=True)
238
+ class DeclarationTotals(TotalsBase):
239
+ """Full declaration totals."""
240
+
241
+ net_weight_kg: Decimal
242
+ gross_weight_kg: Decimal
243
+ packages: Decimal
244
+ commercial_units: Decimal
245
+ physical_units: Decimal
246
+ liquidation: Decimal
247
+
248
+
249
+ @dataclass(frozen=True)
250
+ class Transaction:
251
+ """Transaction block of a full declaration."""
252
+
253
+ currency: str
254
+ payment_method: str
255
+ payment_term_days: Optional[int]
256
+
257
+
258
+ @dataclass(frozen=True)
259
+ class Declaration(DeclarationHeader):
260
+ """Full declaration with items, vehicles and products."""
261
+
262
+ object: str
263
+ arrival_date: Optional[date]
264
+ last_payment_date: date
265
+ importer: Importer
266
+ transport: Transport
267
+ totals: DeclarationTotals
268
+ transaction: Transaction
269
+ items: Tuple[Item, ...]
270
+ retrieved_at: datetime
271
+
272
+
273
+ @dataclass(frozen=True)
274
+ class WebhookDeclarationRef:
275
+ """Declaration reference carried by webhook events."""
276
+
277
+ full_number: str
278
+ customs_office_code: str
279
+ year: int
280
+ number: int
281
+ numbering_date: date
282
+ items_count: int
283
+
284
+
285
+ @dataclass(frozen=True)
286
+ class WebhookEvent:
287
+ """Base webhook event payload.
288
+
289
+ `extra` holds whatever the endpoint was configured to send on top of the
290
+ documented keys. The contract allows them — `additionalProperties: true` —
291
+ and a typed event has to give them one named home or drop them, so anything
292
+ that is not a field above arrives here. Values keep their JSON type, except
293
+ on a form-encoded endpoint, where a form carried no types and they are text.
294
+ """
295
+
296
+ event_id: UUID
297
+ event: str
298
+ occurred_at: datetime
299
+ revision: int
300
+ declaration: WebhookDeclarationRef
301
+ importer: ImporterRef
302
+ resource_url: str
303
+ extra: Dict[str, object] = field(default_factory=dict)
304
+
305
+
306
+ @dataclass(frozen=True)
307
+ class DeclarationIngestedEvent(WebhookEvent):
308
+ """Webhook event emitted when a declaration first enters Pharos."""
309
+
310
+
311
+ @dataclass(frozen=True)
312
+ class DeclarationCorrectedEvent(WebhookEvent):
313
+ """Webhook event emitted when a declaration is reprocessed."""
314
+
315
+
316
+ @dataclass(frozen=True)
317
+ class DeclarationDeletedEvent(WebhookEvent):
318
+ """Webhook event emitted before a declaration is removed."""
@@ -0,0 +1,23 @@
1
+ """Explicit parsers for Pharos Connect response models."""
2
+
3
+ from pharos_sdk.parsers.declarations import (
4
+ parse_declaration,
5
+ parse_declaration_summary,
6
+ parse_declaration_summary_value,
7
+ )
8
+ from pharos_sdk.parsers.envelopes import parse_list_envelope
9
+ from pharos_sdk.parsers.importers import parse_importer, parse_importer_value
10
+ from pharos_sdk.parsers.items import parse_item, parse_item_value
11
+ from pharos_sdk.parsers.webhook_events import parse_webhook_event
12
+
13
+ __all__ = [
14
+ "parse_declaration",
15
+ "parse_declaration_summary",
16
+ "parse_declaration_summary_value",
17
+ "parse_importer",
18
+ "parse_importer_value",
19
+ "parse_item",
20
+ "parse_item_value",
21
+ "parse_list_envelope",
22
+ "parse_webhook_event",
23
+ ]
@@ -0,0 +1,23 @@
1
+ """Parsers for customs identity schemas."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pharos_sdk.models import CustomsBroker, CustomsOffice
6
+ from pharos_sdk.parsers.primitives import field_string
7
+ from pharos_sdk.types import JSONObject
8
+
9
+
10
+ def parse_customs_office(payload: JSONObject, *, path: str) -> CustomsOffice:
11
+ """Parse a CustomsOffice object."""
12
+ return CustomsOffice(
13
+ code=field_string(payload, key="code", path=path),
14
+ name=field_string(payload, key="name", path=path),
15
+ )
16
+
17
+
18
+ def parse_customs_broker(payload: JSONObject, *, path: str) -> CustomsBroker:
19
+ """Parse a CustomsBroker object."""
20
+ return CustomsBroker(
21
+ code=field_string(payload, key="code", path=path),
22
+ name=field_string(payload, key="name", path=path),
23
+ )