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 +8 -0
- pharos_sdk/async_client.py +52 -0
- pharos_sdk/base.py +13 -0
- pharos_sdk/client.py +52 -0
- pharos_sdk/config.py +75 -0
- pharos_sdk/exceptions.py +82 -0
- pharos_sdk/models.py +318 -0
- pharos_sdk/parsers/__init__.py +23 -0
- pharos_sdk/parsers/customs.py +23 -0
- pharos_sdk/parsers/declarations.py +174 -0
- pharos_sdk/parsers/envelopes.py +44 -0
- pharos_sdk/parsers/importers.py +34 -0
- pharos_sdk/parsers/items.py +219 -0
- pharos_sdk/parsers/primitives.py +220 -0
- pharos_sdk/parsers/webhook_events.py +119 -0
- pharos_sdk/py.typed +0 -0
- pharos_sdk/resources/__init__.py +11 -0
- pharos_sdk/resources/declarations.py +120 -0
- pharos_sdk/resources/importers.py +31 -0
- pharos_sdk/resources/paths.py +51 -0
- pharos_sdk/transport/__init__.py +8 -0
- pharos_sdk/transport/async_transport.py +51 -0
- pharos_sdk/transport/base.py +30 -0
- pharos_sdk/transport/errors.py +75 -0
- pharos_sdk/transport/response.py +66 -0
- pharos_sdk/transport/sync_transport.py +49 -0
- pharos_sdk/types.py +9 -0
- pharos_sdk/version.py +3 -0
- pharos_sdk/webhooks/__init__.py +5 -0
- pharos_sdk/webhooks/verifier.py +200 -0
- pharos_sdk-0.1.0.dist-info/METADATA +240 -0
- pharos_sdk-0.1.0.dist-info/RECORD +34 -0
- pharos_sdk-0.1.0.dist-info/WHEEL +4 -0
- pharos_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
)
|
pharos_sdk/exceptions.py
ADDED
|
@@ -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
|
+
)
|