ordercloud-python 2026.4.1__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.
- ordercloud/__init__.py +37 -0
- ordercloud/auth.py +136 -0
- ordercloud/client.py +211 -0
- ordercloud/config.py +42 -0
- ordercloud/errors.py +47 -0
- ordercloud/http.py +218 -0
- ordercloud/middleware.py +66 -0
- ordercloud/models/__init__.py +271 -0
- ordercloud/models/address.py +47 -0
- ordercloud/models/api_client.py +116 -0
- ordercloud/models/approval.py +73 -0
- ordercloud/models/assignments.py +402 -0
- ordercloud/models/auth_models.py +114 -0
- ordercloud/models/bundle.py +31 -0
- ordercloud/models/buyer.py +271 -0
- ordercloud/models/catalog.py +33 -0
- ordercloud/models/category.py +35 -0
- ordercloud/models/cost_center.py +27 -0
- ordercloud/models/credit_card.py +35 -0
- ordercloud/models/delivery.py +277 -0
- ordercloud/models/discount.py +63 -0
- ordercloud/models/integration.py +76 -0
- ordercloud/models/inventory_record.py +53 -0
- ordercloud/models/line_item.py +95 -0
- ordercloud/models/line_item_types.py +89 -0
- ordercloud/models/message_sender.py +80 -0
- ordercloud/models/misc.py +280 -0
- ordercloud/models/open_id_connect.py +47 -0
- ordercloud/models/order.py +477 -0
- ordercloud/models/order_return.py +92 -0
- ordercloud/models/payment.py +77 -0
- ordercloud/models/price_schedule.py +76 -0
- ordercloud/models/product.py +227 -0
- ordercloud/models/product_collection.py +186 -0
- ordercloud/models/promotion.py +297 -0
- ordercloud/models/security.py +89 -0
- ordercloud/models/shared.py +131 -0
- ordercloud/models/shipment.py +150 -0
- ordercloud/models/spec.py +67 -0
- ordercloud/models/spending_account.py +33 -0
- ordercloud/models/subscription.py +125 -0
- ordercloud/models/supplier.py +43 -0
- ordercloud/models/sync.py +172 -0
- ordercloud/models/user.py +207 -0
- ordercloud/models/user_group.py +27 -0
- ordercloud/models/webhook.py +58 -0
- ordercloud/py.typed +0 -0
- ordercloud/resources/__init__.py +65 -0
- ordercloud/resources/addresses.py +228 -0
- ordercloud/resources/admin_addresses.py +128 -0
- ordercloud/resources/admin_user_groups.py +185 -0
- ordercloud/resources/admin_users.py +150 -0
- ordercloud/resources/api_clients.py +308 -0
- ordercloud/resources/approval_rules.py +144 -0
- ordercloud/resources/base.py +145 -0
- ordercloud/resources/bundle_line_items.py +59 -0
- ordercloud/resources/bundle_subscription_items.py +54 -0
- ordercloud/resources/bundles.py +278 -0
- ordercloud/resources/buyer_groups.py +128 -0
- ordercloud/resources/buyers.py +164 -0
- ordercloud/resources/cart.py +613 -0
- ordercloud/resources/catalogs.py +311 -0
- ordercloud/resources/categories.py +392 -0
- ordercloud/resources/cost_centers.py +222 -0
- ordercloud/resources/credit_cards.py +227 -0
- ordercloud/resources/delivery_configurations.py +132 -0
- ordercloud/resources/discounts.py +201 -0
- ordercloud/resources/entity_syncs.py +534 -0
- ordercloud/resources/error_configs.py +71 -0
- ordercloud/resources/forgotten_credentials.py +74 -0
- ordercloud/resources/group_orders.py +28 -0
- ordercloud/resources/impersonation_configs.py +132 -0
- ordercloud/resources/incrementors.py +128 -0
- ordercloud/resources/integration_events.py +203 -0
- ordercloud/resources/inventory_integrations.py +65 -0
- ordercloud/resources/inventory_records.py +484 -0
- ordercloud/resources/line_items.py +262 -0
- ordercloud/resources/locales.py +203 -0
- ordercloud/resources/me.py +1882 -0
- ordercloud/resources/message_senders.py +261 -0
- ordercloud/resources/open_id_connects.py +128 -0
- ordercloud/resources/order_returns.py +306 -0
- ordercloud/resources/order_syncs.py +65 -0
- ordercloud/resources/orders.py +689 -0
- ordercloud/resources/payments.py +176 -0
- ordercloud/resources/price_schedules.py +164 -0
- ordercloud/resources/product_collections.py +116 -0
- ordercloud/resources/product_facets.py +128 -0
- ordercloud/resources/product_syncs.py +76 -0
- ordercloud/resources/products.py +454 -0
- ordercloud/resources/promotion_integrations.py +65 -0
- ordercloud/resources/promotions.py +203 -0
- ordercloud/resources/security_profiles.py +222 -0
- ordercloud/resources/seller_approval_rules.py +128 -0
- ordercloud/resources/shipments.py +256 -0
- ordercloud/resources/specs.py +313 -0
- ordercloud/resources/spending_accounts.py +227 -0
- ordercloud/resources/subscription_integrations.py +65 -0
- ordercloud/resources/subscription_items.py +146 -0
- ordercloud/resources/subscriptions.py +128 -0
- ordercloud/resources/supplier_addresses.py +144 -0
- ordercloud/resources/supplier_user_groups.py +210 -0
- ordercloud/resources/supplier_users.py +170 -0
- ordercloud/resources/suppliers.py +190 -0
- ordercloud/resources/tracking_events.py +130 -0
- ordercloud/resources/user_groups.py +210 -0
- ordercloud/resources/users.py +254 -0
- ordercloud/resources/webhooks.py +128 -0
- ordercloud/resources/xp_indices.py +77 -0
- ordercloud/sync_client.py +170 -0
- ordercloud_python-2026.4.1.dist-info/METADATA +552 -0
- ordercloud_python-2026.4.1.dist-info/RECORD +114 -0
- ordercloud_python-2026.4.1.dist-info/WHEEL +4 -0
- ordercloud_python-2026.4.1.dist-info/licenses/LICENSE +21 -0
ordercloud/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Idiomatic Python SDK for Sitecore OrderCloud.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from ordercloud import OrderCloudClient
|
|
6
|
+
|
|
7
|
+
async with OrderCloudClient.create(
|
|
8
|
+
client_id="YOUR_CLIENT_ID",
|
|
9
|
+
client_secret="YOUR_CLIENT_SECRET",
|
|
10
|
+
) as client:
|
|
11
|
+
products = await client.products.list()
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from importlib.metadata import version as _version
|
|
15
|
+
|
|
16
|
+
from .client import OrderCloudClient
|
|
17
|
+
from .config import OrderCloudConfig
|
|
18
|
+
from .errors import AuthenticationError, OrderCloudError
|
|
19
|
+
from .middleware import AfterResponse, BeforeRequest, RequestContext, ResponseContext
|
|
20
|
+
from .resources.base import paginate
|
|
21
|
+
from .sync_client import SyncOrderCloudClient, paginate_sync
|
|
22
|
+
|
|
23
|
+
__version__ = _version("ordercloud-python")
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"OrderCloudClient",
|
|
27
|
+
"SyncOrderCloudClient",
|
|
28
|
+
"OrderCloudConfig",
|
|
29
|
+
"OrderCloudError",
|
|
30
|
+
"AuthenticationError",
|
|
31
|
+
"BeforeRequest",
|
|
32
|
+
"AfterResponse",
|
|
33
|
+
"RequestContext",
|
|
34
|
+
"ResponseContext",
|
|
35
|
+
"paginate",
|
|
36
|
+
"paginate_sync",
|
|
37
|
+
]
|
ordercloud/auth.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""OAuth2 token management for OrderCloud API authentication."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .config import OrderCloudConfig
|
|
10
|
+
|
|
11
|
+
__all__ = ["AccessToken", "TokenManager"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AccessToken:
|
|
15
|
+
"""An OAuth2 access token with expiry tracking.
|
|
16
|
+
|
|
17
|
+
Stores the token string, optional refresh token, and calculates an
|
|
18
|
+
absolute expiry timestamp with a 30-second safety buffer.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
access_token: The bearer token string.
|
|
22
|
+
expires_in: Token lifetime in seconds (from the OAuth2 response).
|
|
23
|
+
refresh_token: Optional refresh token for token renewal.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, access_token: str, expires_in: int, refresh_token: str = "") -> None:
|
|
27
|
+
self.access_token = access_token
|
|
28
|
+
self.refresh_token = refresh_token
|
|
29
|
+
self._expires_at = time.time() + expires_in
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def is_expired(self) -> bool:
|
|
33
|
+
"""Whether the token has expired (with a 30-second safety buffer)."""
|
|
34
|
+
return time.time() >= self._expires_at - 30
|
|
35
|
+
|
|
36
|
+
def __repr__(self) -> str:
|
|
37
|
+
return f"AccessToken(expired={self.is_expired})"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TokenManager:
|
|
41
|
+
"""Handles OAuth2 token acquisition and automatic refresh.
|
|
42
|
+
|
|
43
|
+
Supports ``client_credentials`` and ``password`` grant types.
|
|
44
|
+
Tokens are cached and automatically refreshed when expired.
|
|
45
|
+
Thread-safe under concurrent async access via an internal lock.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
config: The client configuration containing credentials and URLs.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, config: OrderCloudConfig) -> None:
|
|
52
|
+
self._config = config
|
|
53
|
+
self._token: Optional[AccessToken] = None
|
|
54
|
+
self._lock = asyncio.Lock()
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def _parse_token_response(body: dict[str, Any]) -> AccessToken:
|
|
58
|
+
"""Parse an OAuth2 token response into an ``AccessToken``."""
|
|
59
|
+
return AccessToken(
|
|
60
|
+
access_token=body["access_token"],
|
|
61
|
+
expires_in=body.get("expires_in", 600),
|
|
62
|
+
refresh_token=body.get("refresh_token", ""),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
async def get_token(self, client: httpx.AsyncClient) -> str:
|
|
66
|
+
"""Return a valid access token, refreshing if needed.
|
|
67
|
+
|
|
68
|
+
If a refresh token is available, attempts refresh first. On
|
|
69
|
+
failure, clears the stale token and falls back to a full
|
|
70
|
+
``client_credentials`` authentication.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
client: The HTTP client to use for token requests.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
A valid bearer token string.
|
|
77
|
+
"""
|
|
78
|
+
async with self._lock:
|
|
79
|
+
if self._token is None or self._token.is_expired:
|
|
80
|
+
if self._token and self._token.refresh_token:
|
|
81
|
+
try:
|
|
82
|
+
await self._refresh(client)
|
|
83
|
+
except httpx.HTTPStatusError:
|
|
84
|
+
self._token = None
|
|
85
|
+
await self._authenticate(client)
|
|
86
|
+
else:
|
|
87
|
+
await self._authenticate(client)
|
|
88
|
+
assert self._token is not None
|
|
89
|
+
return self._token.access_token
|
|
90
|
+
|
|
91
|
+
async def _authenticate(self, client: httpx.AsyncClient) -> None:
|
|
92
|
+
"""Authenticate via the ``client_credentials`` grant type."""
|
|
93
|
+
data = {
|
|
94
|
+
"grant_type": "client_credentials",
|
|
95
|
+
"client_id": self._config.client_id,
|
|
96
|
+
"client_secret": self._config.client_secret,
|
|
97
|
+
"scope": " ".join(self._config.scopes),
|
|
98
|
+
}
|
|
99
|
+
resp = await client.post(self._config.auth_url, data=data)
|
|
100
|
+
resp.raise_for_status()
|
|
101
|
+
self._token = self._parse_token_response(resp.json())
|
|
102
|
+
|
|
103
|
+
async def _refresh(self, client: httpx.AsyncClient) -> None:
|
|
104
|
+
"""Refresh an existing token using the ``refresh_token`` grant type."""
|
|
105
|
+
assert self._token is not None
|
|
106
|
+
data = {
|
|
107
|
+
"grant_type": "refresh_token",
|
|
108
|
+
"client_id": self._config.client_id,
|
|
109
|
+
"refresh_token": self._token.refresh_token,
|
|
110
|
+
}
|
|
111
|
+
resp = await client.post(self._config.auth_url, data=data)
|
|
112
|
+
resp.raise_for_status()
|
|
113
|
+
self._token = self._parse_token_response(resp.json())
|
|
114
|
+
|
|
115
|
+
async def authenticate_password(
|
|
116
|
+
self, client: httpx.AsyncClient, username: str, password: str
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Authenticate via the ``password`` grant type.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
client: The HTTP client to use for the token request.
|
|
122
|
+
username: The buyer user's username.
|
|
123
|
+
password: The buyer user's password.
|
|
124
|
+
"""
|
|
125
|
+
data: dict[str, str] = {
|
|
126
|
+
"grant_type": "password",
|
|
127
|
+
"client_id": self._config.client_id,
|
|
128
|
+
"username": username,
|
|
129
|
+
"password": password,
|
|
130
|
+
"scope": " ".join(self._config.scopes),
|
|
131
|
+
}
|
|
132
|
+
if self._config.client_secret:
|
|
133
|
+
data["client_secret"] = self._config.client_secret
|
|
134
|
+
resp = await client.post(self._config.auth_url, data=data)
|
|
135
|
+
resp.raise_for_status()
|
|
136
|
+
self._token = self._parse_token_response(resp.json())
|
ordercloud/client.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# GENERATED by tools/codegen — DO NOT EDIT
|
|
2
|
+
# Source: ordercloud-openapi-v3.json
|
|
3
|
+
"""OrderCloud API client with all resource accessors."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from .auth import TokenManager
|
|
8
|
+
from .config import OrderCloudConfig
|
|
9
|
+
from .http import HttpClient
|
|
10
|
+
from .middleware import AfterResponse, BeforeRequest
|
|
11
|
+
from .resources.addresses import AddressesResource
|
|
12
|
+
from .resources.admin_addresses import AdminAddressesResource
|
|
13
|
+
from .resources.admin_user_groups import AdminUserGroupsResource
|
|
14
|
+
from .resources.admin_users import AdminUsersResource
|
|
15
|
+
from .resources.api_clients import ApiClientsResource
|
|
16
|
+
from .resources.approval_rules import ApprovalRulesResource
|
|
17
|
+
from .resources.bundle_line_items import BundleLineItemsResource
|
|
18
|
+
from .resources.bundle_subscription_items import BundleSubscriptionItemsResource
|
|
19
|
+
from .resources.bundles import BundlesResource
|
|
20
|
+
from .resources.buyer_groups import BuyerGroupsResource
|
|
21
|
+
from .resources.buyers import BuyersResource
|
|
22
|
+
from .resources.cart import CartResource
|
|
23
|
+
from .resources.catalogs import CatalogsResource
|
|
24
|
+
from .resources.categories import CategoriesResource
|
|
25
|
+
from .resources.cost_centers import CostCentersResource
|
|
26
|
+
from .resources.credit_cards import CreditCardsResource
|
|
27
|
+
from .resources.delivery_configurations import DeliveryConfigurationsResource
|
|
28
|
+
from .resources.discounts import DiscountsResource
|
|
29
|
+
from .resources.entity_syncs import EntitySyncsResource
|
|
30
|
+
from .resources.error_configs import ErrorConfigsResource
|
|
31
|
+
from .resources.forgotten_credentials import ForgottenCredentialsResource
|
|
32
|
+
from .resources.group_orders import GroupOrdersResource
|
|
33
|
+
from .resources.impersonation_configs import ImpersonationConfigsResource
|
|
34
|
+
from .resources.incrementors import IncrementorsResource
|
|
35
|
+
from .resources.integration_events import IntegrationEventsResource
|
|
36
|
+
from .resources.inventory_integrations import InventoryIntegrationsResource
|
|
37
|
+
from .resources.inventory_records import InventoryRecordsResource
|
|
38
|
+
from .resources.line_items import LineItemsResource
|
|
39
|
+
from .resources.locales import LocalesResource
|
|
40
|
+
from .resources.me import MeResource
|
|
41
|
+
from .resources.message_senders import MessageSendersResource
|
|
42
|
+
from .resources.open_id_connects import OpenIdConnectsResource
|
|
43
|
+
from .resources.order_returns import OrderReturnsResource
|
|
44
|
+
from .resources.order_syncs import OrderSyncsResource
|
|
45
|
+
from .resources.orders import OrdersResource
|
|
46
|
+
from .resources.payments import PaymentsResource
|
|
47
|
+
from .resources.price_schedules import PriceSchedulesResource
|
|
48
|
+
from .resources.product_collections import ProductCollectionsResource
|
|
49
|
+
from .resources.product_facets import ProductFacetsResource
|
|
50
|
+
from .resources.product_syncs import ProductSyncsResource
|
|
51
|
+
from .resources.products import ProductsResource
|
|
52
|
+
from .resources.promotion_integrations import PromotionIntegrationsResource
|
|
53
|
+
from .resources.promotions import PromotionsResource
|
|
54
|
+
from .resources.security_profiles import SecurityProfilesResource
|
|
55
|
+
from .resources.seller_approval_rules import SellerApprovalRulesResource
|
|
56
|
+
from .resources.shipments import ShipmentsResource
|
|
57
|
+
from .resources.specs import SpecsResource
|
|
58
|
+
from .resources.spending_accounts import SpendingAccountsResource
|
|
59
|
+
from .resources.subscription_integrations import SubscriptionIntegrationsResource
|
|
60
|
+
from .resources.subscription_items import SubscriptionItemsResource
|
|
61
|
+
from .resources.subscriptions import SubscriptionsResource
|
|
62
|
+
from .resources.supplier_addresses import SupplierAddressesResource
|
|
63
|
+
from .resources.supplier_user_groups import SupplierUserGroupsResource
|
|
64
|
+
from .resources.supplier_users import SupplierUsersResource
|
|
65
|
+
from .resources.suppliers import SuppliersResource
|
|
66
|
+
from .resources.tracking_events import TrackingEventsResource
|
|
67
|
+
from .resources.user_groups import UserGroupsResource
|
|
68
|
+
from .resources.users import UsersResource
|
|
69
|
+
from .resources.webhooks import WebhooksResource
|
|
70
|
+
from .resources.xp_indices import XpIndicesResource
|
|
71
|
+
|
|
72
|
+
__all__ = ["OrderCloudClient"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class OrderCloudClient:
|
|
76
|
+
"""Async client for the OrderCloud API.
|
|
77
|
+
|
|
78
|
+
Provides access to all API resources as attributes. Handles
|
|
79
|
+
authentication (OAuth2 client credentials or password grant) and
|
|
80
|
+
token lifecycle automatically.
|
|
81
|
+
|
|
82
|
+
Usage::
|
|
83
|
+
|
|
84
|
+
async with OrderCloudClient(config) as client:
|
|
85
|
+
products = await client.products.list()
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(self, config: OrderCloudConfig) -> None:
|
|
89
|
+
self._config = config
|
|
90
|
+
self._token_manager = TokenManager(config)
|
|
91
|
+
self._http = HttpClient(config, self._token_manager)
|
|
92
|
+
|
|
93
|
+
# Resource accessors.
|
|
94
|
+
self.addresses = AddressesResource(self._http)
|
|
95
|
+
self.admin_addresses = AdminAddressesResource(self._http)
|
|
96
|
+
self.admin_user_groups = AdminUserGroupsResource(self._http)
|
|
97
|
+
self.admin_users = AdminUsersResource(self._http)
|
|
98
|
+
self.api_clients = ApiClientsResource(self._http)
|
|
99
|
+
self.approval_rules = ApprovalRulesResource(self._http)
|
|
100
|
+
self.bundle_line_items = BundleLineItemsResource(self._http)
|
|
101
|
+
self.bundle_subscription_items = BundleSubscriptionItemsResource(self._http)
|
|
102
|
+
self.bundles = BundlesResource(self._http)
|
|
103
|
+
self.buyer_groups = BuyerGroupsResource(self._http)
|
|
104
|
+
self.buyers = BuyersResource(self._http)
|
|
105
|
+
self.cart = CartResource(self._http)
|
|
106
|
+
self.catalogs = CatalogsResource(self._http)
|
|
107
|
+
self.categories = CategoriesResource(self._http)
|
|
108
|
+
self.cost_centers = CostCentersResource(self._http)
|
|
109
|
+
self.credit_cards = CreditCardsResource(self._http)
|
|
110
|
+
self.delivery_configurations = DeliveryConfigurationsResource(self._http)
|
|
111
|
+
self.discounts = DiscountsResource(self._http)
|
|
112
|
+
self.entity_syncs = EntitySyncsResource(self._http)
|
|
113
|
+
self.error_configs = ErrorConfigsResource(self._http)
|
|
114
|
+
self.forgotten_credentials = ForgottenCredentialsResource(self._http)
|
|
115
|
+
self.group_orders = GroupOrdersResource(self._http)
|
|
116
|
+
self.impersonation_configs = ImpersonationConfigsResource(self._http)
|
|
117
|
+
self.incrementors = IncrementorsResource(self._http)
|
|
118
|
+
self.integration_events = IntegrationEventsResource(self._http)
|
|
119
|
+
self.inventory_integrations = InventoryIntegrationsResource(self._http)
|
|
120
|
+
self.inventory_records = InventoryRecordsResource(self._http)
|
|
121
|
+
self.line_items = LineItemsResource(self._http)
|
|
122
|
+
self.locales = LocalesResource(self._http)
|
|
123
|
+
self.me = MeResource(self._http)
|
|
124
|
+
self.message_senders = MessageSendersResource(self._http)
|
|
125
|
+
self.open_id_connects = OpenIdConnectsResource(self._http)
|
|
126
|
+
self.order_returns = OrderReturnsResource(self._http)
|
|
127
|
+
self.order_syncs = OrderSyncsResource(self._http)
|
|
128
|
+
self.orders = OrdersResource(self._http)
|
|
129
|
+
self.payments = PaymentsResource(self._http)
|
|
130
|
+
self.price_schedules = PriceSchedulesResource(self._http)
|
|
131
|
+
self.product_collections = ProductCollectionsResource(self._http)
|
|
132
|
+
self.product_facets = ProductFacetsResource(self._http)
|
|
133
|
+
self.product_syncs = ProductSyncsResource(self._http)
|
|
134
|
+
self.products = ProductsResource(self._http)
|
|
135
|
+
self.promotion_integrations = PromotionIntegrationsResource(self._http)
|
|
136
|
+
self.promotions = PromotionsResource(self._http)
|
|
137
|
+
self.security_profiles = SecurityProfilesResource(self._http)
|
|
138
|
+
self.seller_approval_rules = SellerApprovalRulesResource(self._http)
|
|
139
|
+
self.shipments = ShipmentsResource(self._http)
|
|
140
|
+
self.specs = SpecsResource(self._http)
|
|
141
|
+
self.spending_accounts = SpendingAccountsResource(self._http)
|
|
142
|
+
self.subscription_integrations = SubscriptionIntegrationsResource(self._http)
|
|
143
|
+
self.subscription_items = SubscriptionItemsResource(self._http)
|
|
144
|
+
self.subscriptions = SubscriptionsResource(self._http)
|
|
145
|
+
self.supplier_addresses = SupplierAddressesResource(self._http)
|
|
146
|
+
self.supplier_user_groups = SupplierUserGroupsResource(self._http)
|
|
147
|
+
self.supplier_users = SupplierUsersResource(self._http)
|
|
148
|
+
self.suppliers = SuppliersResource(self._http)
|
|
149
|
+
self.tracking_events = TrackingEventsResource(self._http)
|
|
150
|
+
self.user_groups = UserGroupsResource(self._http)
|
|
151
|
+
self.users = UsersResource(self._http)
|
|
152
|
+
self.webhooks = WebhooksResource(self._http)
|
|
153
|
+
self.xp_indices = XpIndicesResource(self._http)
|
|
154
|
+
|
|
155
|
+
@classmethod
|
|
156
|
+
def create(
|
|
157
|
+
cls,
|
|
158
|
+
*,
|
|
159
|
+
client_id: str,
|
|
160
|
+
client_secret: str,
|
|
161
|
+
base_url: str = "https://api.ordercloud.io/v1",
|
|
162
|
+
auth_url: str = "https://auth.ordercloud.io/oauth/token",
|
|
163
|
+
scopes: list[str] | None = None,
|
|
164
|
+
timeout: float = 30.0,
|
|
165
|
+
max_retries: int = 0,
|
|
166
|
+
retry_backoff: float = 0.5,
|
|
167
|
+
) -> OrderCloudClient:
|
|
168
|
+
"""Create a client from individual parameters.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
client_id: OAuth2 client ID.
|
|
172
|
+
client_secret: OAuth2 client secret.
|
|
173
|
+
base_url: API base URL (default: US production).
|
|
174
|
+
auth_url: OAuth2 token endpoint.
|
|
175
|
+
scopes: Requested scopes (default: ``["FullAccess"]``).
|
|
176
|
+
timeout: HTTP request timeout in seconds.
|
|
177
|
+
max_retries: Max retries on 429/5xx (0 = disabled).
|
|
178
|
+
retry_backoff: Base delay in seconds for exponential backoff.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
A configured ``OrderCloudClient`` instance.
|
|
182
|
+
"""
|
|
183
|
+
config = OrderCloudConfig(
|
|
184
|
+
client_id=client_id,
|
|
185
|
+
client_secret=client_secret,
|
|
186
|
+
base_url=base_url,
|
|
187
|
+
auth_url=auth_url,
|
|
188
|
+
scopes=tuple(scopes) if scopes else ("FullAccess",),
|
|
189
|
+
timeout=timeout,
|
|
190
|
+
max_retries=max_retries,
|
|
191
|
+
retry_backoff=retry_backoff,
|
|
192
|
+
)
|
|
193
|
+
return cls(config)
|
|
194
|
+
|
|
195
|
+
def add_before_request(self, hook: "BeforeRequest") -> None:
|
|
196
|
+
"""Register a hook called before each HTTP request."""
|
|
197
|
+
self._http.add_before_request(hook)
|
|
198
|
+
|
|
199
|
+
def add_after_response(self, hook: "AfterResponse") -> None:
|
|
200
|
+
"""Register a hook called after each HTTP response."""
|
|
201
|
+
self._http.add_after_response(hook)
|
|
202
|
+
|
|
203
|
+
async def close(self) -> None:
|
|
204
|
+
"""Close the underlying HTTP client."""
|
|
205
|
+
await self._http.close()
|
|
206
|
+
|
|
207
|
+
async def __aenter__(self) -> OrderCloudClient:
|
|
208
|
+
return self
|
|
209
|
+
|
|
210
|
+
async def __aexit__(self, *args: object) -> None:
|
|
211
|
+
await self.close()
|
ordercloud/config.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""OrderCloud client configuration."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
__all__ = ["OrderCloudConfig"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class OrderCloudConfig:
|
|
10
|
+
"""Immutable configuration for an OrderCloud API client.
|
|
11
|
+
|
|
12
|
+
Attributes:
|
|
13
|
+
client_id: OAuth2 client ID for API access.
|
|
14
|
+
client_secret: OAuth2 client secret (empty for public clients).
|
|
15
|
+
base_url: OrderCloud API base URL (including ``/v1``).
|
|
16
|
+
auth_url: OAuth2 token endpoint URL.
|
|
17
|
+
scopes: OAuth2 scopes to request (converted to tuple for immutability).
|
|
18
|
+
timeout: HTTP request timeout in seconds.
|
|
19
|
+
max_retries: Maximum number of retries on 429/5xx responses (0 = disabled).
|
|
20
|
+
retry_backoff: Base delay in seconds for exponential backoff between retries.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
client_id: str
|
|
24
|
+
client_secret: str = ""
|
|
25
|
+
base_url: str = "https://api.ordercloud.io/v1"
|
|
26
|
+
auth_url: str = "https://auth.ordercloud.io/oauth/token"
|
|
27
|
+
scopes: tuple[str, ...] = ("FullAccess",)
|
|
28
|
+
timeout: float = 30.0
|
|
29
|
+
max_retries: int = 0
|
|
30
|
+
retry_backoff: float = 0.5
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
if not isinstance(self.scopes, tuple):
|
|
34
|
+
object.__setattr__(self, "scopes", tuple(self.scopes))
|
|
35
|
+
|
|
36
|
+
def __repr__(self) -> str:
|
|
37
|
+
return (
|
|
38
|
+
f"OrderCloudConfig(client_id={self.client_id!r}, "
|
|
39
|
+
f"client_secret='***', "
|
|
40
|
+
f"base_url={self.base_url!r}, "
|
|
41
|
+
f"auth_url={self.auth_url!r})"
|
|
42
|
+
)
|
ordercloud/errors.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""OrderCloud API error types."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
__all__ = ["ApiError", "OrderCloudError", "AuthenticationError"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ApiError:
|
|
11
|
+
"""A single error returned by the OrderCloud API.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
error_code: Machine-readable error code (e.g. ``"NotFound"``).
|
|
15
|
+
message: Human-readable error description.
|
|
16
|
+
data: Optional structured data associated with the error.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
error_code: str
|
|
20
|
+
message: str
|
|
21
|
+
data: Any = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OrderCloudError(Exception):
|
|
25
|
+
"""Raised when the OrderCloud API returns an error response.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
status_code: The HTTP status code.
|
|
29
|
+
errors: List of individual API errors from the response body.
|
|
30
|
+
raw: The raw JSON response body, if available.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
status_code: int,
|
|
36
|
+
errors: list[ApiError],
|
|
37
|
+
raw: Optional[dict[str, Any]] = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
self.status_code = status_code
|
|
40
|
+
self.errors = errors
|
|
41
|
+
self.raw = raw
|
|
42
|
+
messages = "; ".join(e.message for e in errors)
|
|
43
|
+
super().__init__(f"OrderCloud {status_code}: {messages}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AuthenticationError(OrderCloudError):
|
|
47
|
+
"""Raised on 401/403 responses from the OrderCloud API."""
|
ordercloud/http.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Low-level HTTP client for the OrderCloud API."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .auth import TokenManager
|
|
10
|
+
from .config import OrderCloudConfig
|
|
11
|
+
from .errors import ApiError, AuthenticationError, OrderCloudError
|
|
12
|
+
from .middleware import AfterResponse, BeforeRequest, RequestContext, ResponseContext
|
|
13
|
+
|
|
14
|
+
__all__ = ["HttpClient"]
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("ordercloud")
|
|
17
|
+
|
|
18
|
+
_RETRYABLE_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HttpClient:
|
|
22
|
+
"""Async HTTP client with automatic authentication and error handling.
|
|
23
|
+
|
|
24
|
+
Wraps ``httpx.AsyncClient`` to inject bearer tokens, strip ``None``
|
|
25
|
+
query parameters, raise typed exceptions on error responses, and
|
|
26
|
+
optionally retry on transient failures (429, 5xx) with exponential
|
|
27
|
+
backoff.
|
|
28
|
+
|
|
29
|
+
Supports before-request and after-response middleware hooks for
|
|
30
|
+
custom header injection, logging, metrics, etc.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
config: The client configuration.
|
|
34
|
+
token_manager: The token manager for acquiring bearer tokens.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, config: OrderCloudConfig, token_manager: TokenManager) -> None:
|
|
38
|
+
self._config = config
|
|
39
|
+
self._token_manager = token_manager
|
|
40
|
+
self._client = httpx.AsyncClient(timeout=config.timeout)
|
|
41
|
+
self._before_request: list[BeforeRequest] = []
|
|
42
|
+
self._after_response: list[AfterResponse] = []
|
|
43
|
+
|
|
44
|
+
def add_before_request(self, hook: BeforeRequest) -> None:
|
|
45
|
+
"""Register a hook called before each HTTP request.
|
|
46
|
+
|
|
47
|
+
The hook receives a mutable ``RequestContext`` and can modify
|
|
48
|
+
headers, params, or the JSON body before the request is sent.
|
|
49
|
+
"""
|
|
50
|
+
self._before_request.append(hook)
|
|
51
|
+
|
|
52
|
+
def add_after_response(self, hook: AfterResponse) -> None:
|
|
53
|
+
"""Register a hook called after each HTTP response.
|
|
54
|
+
|
|
55
|
+
The hook receives a ``ResponseContext`` with the request details
|
|
56
|
+
and the response. Called on every attempt, including retries.
|
|
57
|
+
"""
|
|
58
|
+
self._after_response.append(hook)
|
|
59
|
+
|
|
60
|
+
async def request(
|
|
61
|
+
self,
|
|
62
|
+
method: str,
|
|
63
|
+
path: str,
|
|
64
|
+
*,
|
|
65
|
+
params: Optional[dict[str, Any]] = None,
|
|
66
|
+
json: Optional[dict[str, Any]] = None,
|
|
67
|
+
) -> httpx.Response:
|
|
68
|
+
"""Make an authenticated request to the OrderCloud API.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
method: HTTP method (e.g. ``"GET"``, ``"POST"``).
|
|
72
|
+
path: API path relative to the base URL (e.g. ``"/products"``).
|
|
73
|
+
params: Query parameters (``None`` values are stripped).
|
|
74
|
+
json: JSON request body.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
The HTTP response.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
AuthenticationError: On 401 or 403 responses.
|
|
81
|
+
OrderCloudError: On any other 4xx/5xx response.
|
|
82
|
+
"""
|
|
83
|
+
url = f"{self._config.base_url}{path}"
|
|
84
|
+
|
|
85
|
+
if params:
|
|
86
|
+
params = {k: v for k, v in params.items() if v is not None}
|
|
87
|
+
|
|
88
|
+
max_attempts = 1 + self._config.max_retries
|
|
89
|
+
|
|
90
|
+
for attempt in range(max_attempts):
|
|
91
|
+
token = await self._token_manager.get_token(self._client)
|
|
92
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
93
|
+
|
|
94
|
+
ctx = RequestContext(
|
|
95
|
+
method=method,
|
|
96
|
+
path=path,
|
|
97
|
+
url=url,
|
|
98
|
+
headers=dict(headers),
|
|
99
|
+
params=dict(params) if params else None,
|
|
100
|
+
json=dict(json) if json else None,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
for hook in self._before_request:
|
|
104
|
+
await hook(ctx)
|
|
105
|
+
|
|
106
|
+
logger.debug("Request: %s %s", method, path)
|
|
107
|
+
|
|
108
|
+
resp = await self._client.request(
|
|
109
|
+
ctx.method,
|
|
110
|
+
ctx.url,
|
|
111
|
+
headers=ctx.headers,
|
|
112
|
+
params=ctx.params,
|
|
113
|
+
json=ctx.json,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
logger.debug("Response: %s %s %d", method, path, resp.status_code)
|
|
117
|
+
|
|
118
|
+
resp_ctx = ResponseContext(request=ctx, response=resp, attempt=attempt)
|
|
119
|
+
for after_hook in self._after_response:
|
|
120
|
+
await after_hook(resp_ctx)
|
|
121
|
+
|
|
122
|
+
if resp.status_code < 400:
|
|
123
|
+
return resp
|
|
124
|
+
|
|
125
|
+
if resp.status_code in _RETRYABLE_STATUSES and attempt < max_attempts - 1:
|
|
126
|
+
delay = self._retry_delay(resp, attempt)
|
|
127
|
+
logger.warning(
|
|
128
|
+
"Retry %d/%d: %s %s returned %d, waiting %.1fs",
|
|
129
|
+
attempt + 1,
|
|
130
|
+
self._config.max_retries,
|
|
131
|
+
method,
|
|
132
|
+
path,
|
|
133
|
+
resp.status_code,
|
|
134
|
+
delay,
|
|
135
|
+
)
|
|
136
|
+
await asyncio.sleep(delay)
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
self._raise_error(resp)
|
|
140
|
+
|
|
141
|
+
# Unreachable — loop always returns or raises
|
|
142
|
+
raise AssertionError("unreachable") # pragma: no cover
|
|
143
|
+
|
|
144
|
+
def _retry_delay(self, resp: httpx.Response, attempt: int) -> float:
|
|
145
|
+
"""Calculate the delay before the next retry attempt.
|
|
146
|
+
|
|
147
|
+
Respects the ``Retry-After`` header if present, otherwise uses
|
|
148
|
+
exponential backoff: ``retry_backoff * 2^attempt``.
|
|
149
|
+
"""
|
|
150
|
+
max_delay = 120.0
|
|
151
|
+
retry_after = resp.headers.get("Retry-After")
|
|
152
|
+
if retry_after:
|
|
153
|
+
try:
|
|
154
|
+
return min(float(retry_after), max_delay)
|
|
155
|
+
except ValueError:
|
|
156
|
+
pass
|
|
157
|
+
delay: float = self._config.retry_backoff * (2**attempt)
|
|
158
|
+
return min(delay, max_delay)
|
|
159
|
+
|
|
160
|
+
async def get(self, path: str, **params: Any) -> httpx.Response:
|
|
161
|
+
"""Send a GET request."""
|
|
162
|
+
return await self.request("GET", path, params=params or None)
|
|
163
|
+
|
|
164
|
+
async def post(
|
|
165
|
+
self,
|
|
166
|
+
path: str,
|
|
167
|
+
json: Optional[dict[str, Any]] = None,
|
|
168
|
+
params: Optional[dict[str, Any]] = None,
|
|
169
|
+
) -> httpx.Response:
|
|
170
|
+
"""Send a POST request."""
|
|
171
|
+
return await self.request("POST", path, json=json, params=params)
|
|
172
|
+
|
|
173
|
+
async def put(
|
|
174
|
+
self,
|
|
175
|
+
path: str,
|
|
176
|
+
json: Optional[dict[str, Any]] = None,
|
|
177
|
+
params: Optional[dict[str, Any]] = None,
|
|
178
|
+
) -> httpx.Response:
|
|
179
|
+
"""Send a PUT request."""
|
|
180
|
+
return await self.request("PUT", path, json=json, params=params)
|
|
181
|
+
|
|
182
|
+
async def patch(
|
|
183
|
+
self,
|
|
184
|
+
path: str,
|
|
185
|
+
json: Optional[dict[str, Any]] = None,
|
|
186
|
+
params: Optional[dict[str, Any]] = None,
|
|
187
|
+
) -> httpx.Response:
|
|
188
|
+
"""Send a PATCH request."""
|
|
189
|
+
return await self.request("PATCH", path, json=json, params=params)
|
|
190
|
+
|
|
191
|
+
async def delete(self, path: str, **params: Any) -> httpx.Response:
|
|
192
|
+
"""Send a DELETE request."""
|
|
193
|
+
return await self.request("DELETE", path, params=params or None)
|
|
194
|
+
|
|
195
|
+
async def close(self) -> None:
|
|
196
|
+
"""Close the underlying HTTP client and release connections."""
|
|
197
|
+
await self._client.aclose()
|
|
198
|
+
|
|
199
|
+
@staticmethod
|
|
200
|
+
def _raise_error(resp: httpx.Response) -> None:
|
|
201
|
+
"""Parse an error response and raise the appropriate exception."""
|
|
202
|
+
try:
|
|
203
|
+
body = resp.json()
|
|
204
|
+
errors = [
|
|
205
|
+
ApiError(
|
|
206
|
+
error_code=e.get("ErrorCode", ""),
|
|
207
|
+
message=e.get("Message", ""),
|
|
208
|
+
data=e.get("Data"),
|
|
209
|
+
)
|
|
210
|
+
for e in body.get("Errors", [])
|
|
211
|
+
]
|
|
212
|
+
except Exception:
|
|
213
|
+
errors = [ApiError(error_code="Unknown", message=resp.text)]
|
|
214
|
+
body = None
|
|
215
|
+
|
|
216
|
+
if resp.status_code in (401, 403):
|
|
217
|
+
raise AuthenticationError(resp.status_code, errors, raw=body)
|
|
218
|
+
raise OrderCloudError(resp.status_code, errors, raw=body)
|