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.
Files changed (114) hide show
  1. ordercloud/__init__.py +37 -0
  2. ordercloud/auth.py +136 -0
  3. ordercloud/client.py +211 -0
  4. ordercloud/config.py +42 -0
  5. ordercloud/errors.py +47 -0
  6. ordercloud/http.py +218 -0
  7. ordercloud/middleware.py +66 -0
  8. ordercloud/models/__init__.py +271 -0
  9. ordercloud/models/address.py +47 -0
  10. ordercloud/models/api_client.py +116 -0
  11. ordercloud/models/approval.py +73 -0
  12. ordercloud/models/assignments.py +402 -0
  13. ordercloud/models/auth_models.py +114 -0
  14. ordercloud/models/bundle.py +31 -0
  15. ordercloud/models/buyer.py +271 -0
  16. ordercloud/models/catalog.py +33 -0
  17. ordercloud/models/category.py +35 -0
  18. ordercloud/models/cost_center.py +27 -0
  19. ordercloud/models/credit_card.py +35 -0
  20. ordercloud/models/delivery.py +277 -0
  21. ordercloud/models/discount.py +63 -0
  22. ordercloud/models/integration.py +76 -0
  23. ordercloud/models/inventory_record.py +53 -0
  24. ordercloud/models/line_item.py +95 -0
  25. ordercloud/models/line_item_types.py +89 -0
  26. ordercloud/models/message_sender.py +80 -0
  27. ordercloud/models/misc.py +280 -0
  28. ordercloud/models/open_id_connect.py +47 -0
  29. ordercloud/models/order.py +477 -0
  30. ordercloud/models/order_return.py +92 -0
  31. ordercloud/models/payment.py +77 -0
  32. ordercloud/models/price_schedule.py +76 -0
  33. ordercloud/models/product.py +227 -0
  34. ordercloud/models/product_collection.py +186 -0
  35. ordercloud/models/promotion.py +297 -0
  36. ordercloud/models/security.py +89 -0
  37. ordercloud/models/shared.py +131 -0
  38. ordercloud/models/shipment.py +150 -0
  39. ordercloud/models/spec.py +67 -0
  40. ordercloud/models/spending_account.py +33 -0
  41. ordercloud/models/subscription.py +125 -0
  42. ordercloud/models/supplier.py +43 -0
  43. ordercloud/models/sync.py +172 -0
  44. ordercloud/models/user.py +207 -0
  45. ordercloud/models/user_group.py +27 -0
  46. ordercloud/models/webhook.py +58 -0
  47. ordercloud/py.typed +0 -0
  48. ordercloud/resources/__init__.py +65 -0
  49. ordercloud/resources/addresses.py +228 -0
  50. ordercloud/resources/admin_addresses.py +128 -0
  51. ordercloud/resources/admin_user_groups.py +185 -0
  52. ordercloud/resources/admin_users.py +150 -0
  53. ordercloud/resources/api_clients.py +308 -0
  54. ordercloud/resources/approval_rules.py +144 -0
  55. ordercloud/resources/base.py +145 -0
  56. ordercloud/resources/bundle_line_items.py +59 -0
  57. ordercloud/resources/bundle_subscription_items.py +54 -0
  58. ordercloud/resources/bundles.py +278 -0
  59. ordercloud/resources/buyer_groups.py +128 -0
  60. ordercloud/resources/buyers.py +164 -0
  61. ordercloud/resources/cart.py +613 -0
  62. ordercloud/resources/catalogs.py +311 -0
  63. ordercloud/resources/categories.py +392 -0
  64. ordercloud/resources/cost_centers.py +222 -0
  65. ordercloud/resources/credit_cards.py +227 -0
  66. ordercloud/resources/delivery_configurations.py +132 -0
  67. ordercloud/resources/discounts.py +201 -0
  68. ordercloud/resources/entity_syncs.py +534 -0
  69. ordercloud/resources/error_configs.py +71 -0
  70. ordercloud/resources/forgotten_credentials.py +74 -0
  71. ordercloud/resources/group_orders.py +28 -0
  72. ordercloud/resources/impersonation_configs.py +132 -0
  73. ordercloud/resources/incrementors.py +128 -0
  74. ordercloud/resources/integration_events.py +203 -0
  75. ordercloud/resources/inventory_integrations.py +65 -0
  76. ordercloud/resources/inventory_records.py +484 -0
  77. ordercloud/resources/line_items.py +262 -0
  78. ordercloud/resources/locales.py +203 -0
  79. ordercloud/resources/me.py +1882 -0
  80. ordercloud/resources/message_senders.py +261 -0
  81. ordercloud/resources/open_id_connects.py +128 -0
  82. ordercloud/resources/order_returns.py +306 -0
  83. ordercloud/resources/order_syncs.py +65 -0
  84. ordercloud/resources/orders.py +689 -0
  85. ordercloud/resources/payments.py +176 -0
  86. ordercloud/resources/price_schedules.py +164 -0
  87. ordercloud/resources/product_collections.py +116 -0
  88. ordercloud/resources/product_facets.py +128 -0
  89. ordercloud/resources/product_syncs.py +76 -0
  90. ordercloud/resources/products.py +454 -0
  91. ordercloud/resources/promotion_integrations.py +65 -0
  92. ordercloud/resources/promotions.py +203 -0
  93. ordercloud/resources/security_profiles.py +222 -0
  94. ordercloud/resources/seller_approval_rules.py +128 -0
  95. ordercloud/resources/shipments.py +256 -0
  96. ordercloud/resources/specs.py +313 -0
  97. ordercloud/resources/spending_accounts.py +227 -0
  98. ordercloud/resources/subscription_integrations.py +65 -0
  99. ordercloud/resources/subscription_items.py +146 -0
  100. ordercloud/resources/subscriptions.py +128 -0
  101. ordercloud/resources/supplier_addresses.py +144 -0
  102. ordercloud/resources/supplier_user_groups.py +210 -0
  103. ordercloud/resources/supplier_users.py +170 -0
  104. ordercloud/resources/suppliers.py +190 -0
  105. ordercloud/resources/tracking_events.py +130 -0
  106. ordercloud/resources/user_groups.py +210 -0
  107. ordercloud/resources/users.py +254 -0
  108. ordercloud/resources/webhooks.py +128 -0
  109. ordercloud/resources/xp_indices.py +77 -0
  110. ordercloud/sync_client.py +170 -0
  111. ordercloud_python-2026.4.1.dist-info/METADATA +552 -0
  112. ordercloud_python-2026.4.1.dist-info/RECORD +114 -0
  113. ordercloud_python-2026.4.1.dist-info/WHEEL +4 -0
  114. 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)