From 290c6e0c5b3446d95715505ea0139233213931ba Mon Sep 17 00:00:00 2001 From: dbritto-dev Date: Sat, 12 Sep 2026 00:26:12 +0000 Subject: [PATCH] chore: drop the 0.1.x wrappers and the plan, move the sandbox runner into tests 1.0 shipped, so the compatibility subpackages (client, reports, vendor, utils), their helpers and the never-released error aliases go; MIGRATION.md maps every 0.1.x entry point to the 1.0 API instead. The sandbox runner, the schema example generator, the spec locator and the sandbox-example parser are test tooling and now live in tests/ (`python -m tests.sandbox`), off the package and its public plugin API. docs/PLAN.md and the references to it are removed; README and UPDATING_SPECS follow the new paths. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RDH3P3vmiBCWwaDYnvpWHn --- MIGRATION.md | 80 ++++---- README.md | 3 +- codegen/src/amazon.ts | 6 +- codegen/src/convert.ts | 2 +- codegen/src/policy/transforms.ts | 2 +- codegen/src/python/ratelimits.ts | 2 +- docs/PLAN.md | 192 ------------------ docs/UPDATING_SPECS.md | 11 +- pyproject.toml | 3 - src/amzn_selling_partner/__init__.py | 15 +- src/amzn_selling_partner/_compat.py | 39 ---- src/amzn_selling_partner/_naming.py | 51 +---- src/amzn_selling_partner/client/__init__.py | 79 ------- src/amzn_selling_partner/client/auth.py | 64 ------ .../plugins/_amazon/documents.py | 8 +- .../plugins/amazon_spapi.py | 11 +- src/amzn_selling_partner/reports/__init__.py | 147 -------------- src/amzn_selling_partner/reports/models.py | 114 ----------- src/amzn_selling_partner/utils/__init__.py | 3 - src/amzn_selling_partner/utils/date.py | 18 -- src/amzn_selling_partner/utils/file.py | 3 - src/amzn_selling_partner/vendor/__init__.py | 3 - .../vendor/orders/__init__.py | 122 ----------- .../vendor/orders/models.py | 126 ------------ .../_examples.py | 4 +- .../sandbox.py => tests/_sandbox_examples.py | 0 .../_amazon/specs.py => tests/_specs.py | 12 +- .../sandbox_tests.py => tests/sandbox.py | 27 ++- tests/test_amazon_plugin.py | 14 +- tests/test_client.py | 9 +- tests/test_compat.py | 171 ---------------- 31 files changed, 103 insertions(+), 1238 deletions(-) delete mode 100644 docs/PLAN.md delete mode 100644 src/amzn_selling_partner/_compat.py delete mode 100644 src/amzn_selling_partner/client/__init__.py delete mode 100644 src/amzn_selling_partner/client/auth.py delete mode 100644 src/amzn_selling_partner/reports/__init__.py delete mode 100644 src/amzn_selling_partner/reports/models.py delete mode 100644 src/amzn_selling_partner/utils/__init__.py delete mode 100644 src/amzn_selling_partner/utils/date.py delete mode 100644 src/amzn_selling_partner/utils/file.py delete mode 100644 src/amzn_selling_partner/vendor/__init__.py delete mode 100644 src/amzn_selling_partner/vendor/orders/__init__.py delete mode 100644 src/amzn_selling_partner/vendor/orders/models.py rename {src/amzn_selling_partner => tests}/_examples.py (96%) rename src/amzn_selling_partner/plugins/_amazon/sandbox.py => tests/_sandbox_examples.py (100%) rename src/amzn_selling_partner/plugins/_amazon/specs.py => tests/_specs.py (88%) rename src/amzn_selling_partner/sandbox_tests.py => tests/sandbox.py (93%) delete mode 100644 tests/test_compat.py diff --git a/MIGRATION.md b/MIGRATION.md index 9c409af..e094bd0 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,51 +1,57 @@ -# Migrating from 0.1.x +# Migrating from 0.1.x to 1.0 -Version 0.2 replaces the hand-written `requests` client with an SDK generated +Version 1.0 replaces the hand-written `requests` client with an SDK generated from Amazon's API models by [oagen](https://github.com/workos/oagen), inside -the same `amzn_selling_partner` package. The old `amzn_selling_partner` entry -points keep working as thin wrappers, but several things changed. +the same `amzn_selling_partner` package. The 0.1.x entry points (`client`, +`reports`, `vendor`, `utils`) are gone; this page maps them to the new API. -## Breaking changes +## What changed -| Area | 0.1.x | 0.2 | +| Area | 0.1.x | 1.0 | |---|---|---| -| Python | 3.10+ | 3.10+ (unchanged) | -| HTTP | `requests` | `httpx2` (sync and async), generated `sdk/_http.py` | +| Python | 3.10+ | 3.10+ | +| HTTP | `requests` | `httpx2`, sync and async, in the generated `sdk/_http.py` | | Models | pydantic 1 (`.dict()`, `class Config`) | pydantic 2, generated from the specs (`.model_dump()`, frozen, `extra="allow"`), under `amzn_selling_partner.sdk.models._` | -| Auth | LWA + AWS Signature V4 (boto3, `requests_aws4auth`) | **LWA only.** The `aws_*` constructor arguments are accepted and ignored with a `DeprecationWarning`; `ClientSessionAuth` / `ClientSessionAuthTemporaryCredentials` raise `NotImplementedError` | -| Exceptions | `requests.HTTPError` | `amzn_selling_partner.APIStatusError` and subclasses (`RateLimitExceededError`, `NotFoundError`, `AuthenticationError` (401), `AuthorizationError` (403), `ServerError`, ...), `APIConnectionError`, `APITimeoutError`, `APIResponseValidationError` | -| `http_session` attribute | `requests.Session` | removed; use `client.sp.http_client` (`httpx2.Client`) | -| Model field names | wire casing (`order.purchaseOrderNumber`) | snake_case attributes with wire aliases (`order.purchase_order_number`; `Order(purchaseOrderNumber=...)` still works thanks to `populate_by_name`) | -| Enum fields on models | Python enums (`PurchaseOrderState.NEW`) | generated `str` enums (`PurchaseOrderState.NEW == "New"`); the old enum classes still exist and compare equal | -| `reports.Client.get_reports` | slept `x-amzn-RateLimit-Limit * 100` seconds after every call | token-bucket throttling from the spec's rate table | -| Distribution deps | `requests`, `requests_aws4auth`, `boto3`, `pydantic<2` | `httpx2`, `pydantic>=2.9` | +| Auth | LWA + AWS Signature V4 (boto3, `requests_aws4auth`) | LWA only; Restricted Data Tokens and grantless scopes are obtained automatically | +| Errors | `requests.HTTPError` | `amzn_selling_partner.APIStatusError` and subclasses (`RateLimitExceededError`, `NotFoundError`, `AuthenticationError` for 401, `AuthorizationError` for 403, `ServerError`, ...), `APIConnectionError`, `APITimeoutError`, `APIResponseValidationError` | +| Model fields | wire casing (`order.purchaseOrderNumber`) | snake_case attributes with wire aliases (`order.purchase_order_number`; `Order(purchaseOrderNumber=...)` still validates) | +| Enums | hand-written Python enums | generated `str` enums (`PurchaseOrderState.NEW == "New"`) | +| Rate limits | `reports.Client.get_reports` slept `x-amzn-RateLimit-Limit * 100` seconds after every call | token-bucket throttling from the spec's rate table, retries with `Retry-After` | +| Dependencies | `requests`, `requests_aws4auth`, `boto3`, `pydantic<2` | `httpx2`, `pydantic>=2.9` | -## Kept entry points +## Entry points -| Old | Now | +| 0.1.x | 1.0 | |---|---| -| `amzn_selling_partner.client.SellingPartnerRegion` | alias of `amzn_selling_partner.plugins.amazon_spapi.Region` (same members and properties) | -| `amzn_selling_partner.client.BaseClient` | wraps `amzn_selling_partner.SellingPartner` (available as `.sp`) | -| `amzn_selling_partner.vendor.orders.Client` | `get_purchase_orders(query=)`, `get_purchase_order(id)` (+ `get_purchase_orders_status`, `submit_acknowledgement`) | -| `amzn_selling_partner.vendor.orders.Order`, `OrderDetails`, ... | spec-generated models (`amzn_selling_partner.sdk.models.vendor_orders_v1`) | -| `amzn_selling_partner.reports.Client` | all seven public methods, same signatures | -| `amzn_selling_partner.reports.Report`, `ReportDocument`, ... | spec-generated models | -| `*Query` / `CreateReport*Specification` / `ReportOptions` | kept (pydantic 2) | -| `amzn_selling_partner.utils.date`, `utils.file` | unchanged | - -Environment variables `SELLING_PARTNER_APP_CLIENT_ID`, `..._CLIENT_SECRET`, -`..._REFRESH_TOKEN` are still honoured (`AMZN_SELLING_PARTNER_*` are the new names). - -## New API +| `amzn_selling_partner.client.BaseClient(selling_partner_region=..., selling_partner_app_client_id=..., ...)` | `amzn_selling_partner.AsyncSellingPartner(region=..., client_id=..., client_secret=..., refresh_token=...)` or the synchronous `SellingPartner`; one client serves every API | +| `amzn_selling_partner.client.SellingPartnerRegion.NORTH_AMERICA` / `EUROPE` / `FAR_EAST` | `amzn_selling_partner.Region.NA` / `EU` / `FE` | +| `aws_access_key_id`, `aws_secret_access_key`, `aws_selling_partner_role`, `aws_selling_partner_role_session_name` | removed; the Selling Partner API no longer uses AWS Signature V4 | +| `amzn_selling_partner.client.auth.ClientSessionAuthAccessToken` | `amzn_selling_partner.plugins.amazon_spapi.LWAAuth` (created by the client; pass `token_store=` for a custom cache) | +| `amzn_selling_partner.client.auth.ClientSessionAuth`, `ClientSessionAuthTemporaryCredentials` | removed | +| `amzn_selling_partner.vendor.orders.Client().get_purchase_orders(query=...)` | `client.vendor_orders_v1.list_purchase_orders(created_after=..., ...)` (`iter_list_purchase_orders` follows the pages) | +| `amzn_selling_partner.vendor.orders.Client().get_purchase_order(id)` | `client.vendor_orders_v1.get_purchase_order(id)` | +| `amzn_selling_partner.vendor.orders.Order`, `OrderDetails`, ... | `amzn_selling_partner.sdk.models.vendor_orders_v1` | +| `amzn_selling_partner.reports.Client().get_reports(query=...)` | `client.reports.list_reports(report_types=[...], ...)` (`iter_list_reports` follows the pages) | +| `amzn_selling_partner.reports.Client().create_report(...)` | `client.reports.create_report(CreateReportSpecification(...))` | +| `amzn_selling_partner.reports.Client().get_report(id)` | `client.reports.get_report(id)` | +| `amzn_selling_partner.reports.Client().get_report_document(id)` / download | `client.reports.get_document(id)`; `client.documents.download_report(id)` downloads and decompresses it | +| `amzn_selling_partner.reports.Report`, `ReportDocument`, `*Query`, `CreateReport*Specification`, `ReportOptions` | `amzn_selling_partner.sdk.models.reports_v2021_06_30` | +| `amzn_selling_partner.utils.date.amazon_isoformat(value)` | pass a `datetime` directly; the client serialises it as ISO 8601 with `Z` | +| `amzn_selling_partner.utils.date.datetime_utcnow()` / `datetime_utcpast(...)` | `datetime.datetime.now(datetime.timezone.utc)` and `datetime.timedelta` | +| `amzn_selling_partner.utils.file.write_binary_file(path, content)` | `client.documents.download_report(id, path=...)` streams to a file; otherwise `pathlib.Path(path).write_bytes(content)` | +| `SELLING_PARTNER_APP_CLIENT_ID`, `..._CLIENT_SECRET`, `..._REFRESH_TOKEN` | still read; `AMZN_SELLING_PARTNER_CLIENT_ID`, `..._CLIENT_SECRET`, `..._REFRESH_TOKEN` are the new names | + +`amzn_selling_partner.sdk.resources.OPERATIONS` maps Amazon's operationIds +(`getPurchaseOrders`) to the generated method names. + +## The 1.0 API in short One resource per API version (`client.orders_v0`, `client.orders_v2026_01_01`; -`client.orders` is the newest version), one method per operation. Method names -are derived by oagen from the operation (`list_orders`, `get_order`, -`create_feed`); `amzn_selling_partner.sdk.resources.OPERATIONS` maps Amazon's -operationIds to them. +`client.orders` is the newest version), one method per operation, every +paginated operation with an `iter_` twin. ```python -from amzn_selling_partner import AsyncSellingPartner +from amzn_selling_partner import AsyncSellingPartner, Region async with AsyncSellingPartner(region=Region.NA) as client: async for order in client.vendor_orders_v1.iter_list_purchase_orders(created_after="2024-01-01T00:00:00Z"): @@ -78,8 +84,8 @@ assert client.orders_v0.get_order("1").payload.order_status is OrderOrderStatus. The same transport serves the LWA token endpoint, the Tokens API and pre-signed document URLs, so a single handler can emulate a whole flow -(see `tests/_amazon_mock.py`). `amzn_selling_partner.sandbox_tests` runs every -operation through the examples embedded in Amazon's models the same way. +(see `tests/_amazon_mock.py`). `tests/sandbox.py` runs every operation through +the examples embedded in Amazon's models the same way. If your application still imports `httpx` elsewhere, `httpx2.alias_httpx()` (called once at start-up, before anything imports `httpx`) makes both names diff --git a/README.md b/README.md index fd4035b..ee6f066 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,6 @@ notification models) lives in `amzn_selling_partner.plugins.amazon_spapi`. - **Bug reports:** https://github.com/dbritto-dev/amzn-selling-partner-python/issues - **Migration from 0.1.x:** [MIGRATION.md](MIGRATION.md) - **Updating the bundled specs:** [docs/UPDATING_SPECS.md](docs/UPDATING_SPECS.md) -- **Design notes:** [docs/PLAN.md](docs/PLAN.md) ## Installation @@ -277,7 +276,7 @@ uv run pytest uv run ty check uv run pytest benchmarks uvx nox -s security_test -uv run python -m amzn_selling_partner.sandbox_tests +uv run python -m tests.sandbox cd codegen && npm ci --ignore-scripts && npm run generate cd codegen && npm test && npm run typecheck diff --git a/codegen/src/amazon.ts b/codegen/src/amazon.ts index 2466a2b..f3a49b6 100644 --- a/codegen/src/amazon.ts +++ b/codegen/src/amazon.ts @@ -1,7 +1,7 @@ /** - * Amazon-specific generation policy: API attribute names for the model files - * (docs/PLAN.md §3), aliases, and the pagination overrides the heuristic cannot - * settle (§9). Restricted-operation and grantless tables stay in Python + * Amazon-specific generation policy: API attribute names for the model files, + * aliases, and the pagination overrides the heuristic cannot settle. + * Restricted-operation and grantless tables stay in Python * (`plugins/_amazon/rdt.py`) because the auth hook reads them at run time. */ import type { PaginationDescriptor } from './python/pagination.js'; diff --git a/codegen/src/convert.ts b/codegen/src/convert.ts index c7f7d05..71fb1dc 100644 --- a/codegen/src/convert.ts +++ b/codegen/src/convert.ts @@ -30,7 +30,7 @@ function collectRefs(node: unknown, out: Set): void { } /** - * Repair the two irregularities found in the pinned models (docs/PLAN.md §10): + * Repair the two irregularities found in the pinned models: * `#ref` instead of `$ref`, and references to definitions that do not exist * (replaced by an empty schema, with a warning). */ diff --git a/codegen/src/policy/transforms.ts b/codegen/src/policy/transforms.ts index d0079a3..8c16c47 100644 --- a/codegen/src/policy/transforms.ts +++ b/codegen/src/policy/transforms.ts @@ -3,7 +3,7 @@ * `schemaNameTransform` and `operationIdTransform`. * * `transformSpec` is the pre-IR overlay that keeps oagen's parser from losing - * information present in the Amazon files (docs/PLAN.md): + * information present in the Amazon files: * * 1. Named schemas that are not objects (`OrderList: array of Order`, * `MarketplaceId: string`, bare `oneOf` unions) are inlined at every diff --git a/codegen/src/python/ratelimits.ts b/codegen/src/python/ratelimits.ts index e734df5..ed25a12 100644 --- a/codegen/src/python/ratelimits.ts +++ b/codegen/src/python/ratelimits.ts @@ -1,4 +1,4 @@ -/** Parse the "Rate (requests per second) | Burst" usage-plan tables (docs/PLAN.md §8). */ +/** Parse the "Rate (requests per second) | Burst" usage-plan tables of the operation descriptions. */ export interface RateLimit { rate: number; diff --git a/docs/PLAN.md b/docs/PLAN.md deleted file mode 100644 index 239d99b..0000000 --- a/docs/PLAN.md +++ /dev/null @@ -1,192 +0,0 @@ -# Design notes - -`amzn_selling_partner` is generated from the Amazon Selling Partner API models -(git submodule `spec/selling-partner-api-models`) by `codegen/`, an emitter -project built on [oagen](https://github.com/workos/oagen) the way the WorkOS -tutorial [How to build a custom SDK generator with oagen](https://workos.com/blog/build-a-custom-sdk-generator-with-oagen) -describes. This file records the decisions and what the generator had to work -around; the update procedure is in `UPDATING_SPECS.md`, the 0.1.x differences -in `../MIGRATION.md`. - -## Decisions - -1. Package and distribution names unchanged (`amzn-selling-partner` / - `amzn_selling_partner`); Python 3.10+. -2. Runtime dependencies are `httpx2` and `pydantic>=2` only; `aiohttp` is an - extra. Schemas, validation and parsing use pydantic v2. -3. The SDK is generated, standalone, at build time (decision of 2026-09-11, - following the tutorial): `src/amzn_selling_partner/sdk/` holds everything - the emitter produces from the spec (client, HTTP client, errors, models, - resources) and is committed; CI regenerates it from the submodule and fails - on drift; the wheel ships Python only. -4. Method names are oagen's resolved names (`list_orders`, `get_order`, - `create_feed`), not Amazon's operationIds; the 29 operations whose derived - names collide inside their API version are named through `operationHints` - in `codegen/oagen.config.ts`. `sdk.resources.OPERATIONS` maps operationIds - to methods, so the RDT / grantless tables, the sandbox runner and the 0.1.x - wrappers stay keyed by Amazon's names. -5. One resource per API version (`client.orders_v0`, `client.orders` = newest), - one models package per API version (`sdk.models.orders_v0`), notification - payloads under `sdk.models.notifications.`. -6. LWA-only auth (refresh-token and `client_credentials` grants, Restricted - Data Tokens through the Tokens API); boto3 / SigV4 are gone. It is a plugin - (`plugins/amazon_spapi.py`) over the generated `Auth` hook, not generated. -7. `date-time` → `datetime`, `date` → `date`, `byte`/`binary` → `bytes`, other - formats stay `str`; enums are `str` (or `int`) `Enum` classes as in the - tutorial; unknown fields are kept (`extra="allow"`); models are frozen. -8. Rate limits come from the usage-plan tables in the operation descriptions - (never guessed) and are generated into each call as `RateLimit(rate, burst)`; - pagination is detected from `nextToken`-style parameters with an override - table for the operations the heuristic cannot settle, and generated as - `iter_` helpers; restricted (RDT) and grantless operations are - hand-maintained tables in `plugins/_amazon/rdt.py` that still need checking - against Amazon's Tokens API guide. - -## The pipeline - -``` -spec/selling-partner-api-models 67 Swagger 2.0 files + 23 notification JSON Schemas (submodule) - │ npm run spec:build convert.ts (swagger2openapi, #ref/dangling-ref repairs), namespace components - ▼ as :, tag every operation with its API version, merge -codegen/spec/open-api-spec.yaml one OpenAPI 3 document (committed): 67 services, 373 operations, 2032 schemas - │ oagen generate oagen.config.ts = plugin + src/policy/: transformSpec (alias inlining, - ▼ inline-object hoisting, name protection), operationHints, mountRules -oagen IR (ApiSpec) services, operations, models, enums, sdk behavior - │ src/python/ (the emitter) types.ts, models.ts, resources.ts, client.ts, index.ts - ▼ -src/amzn_selling_partner/sdk/ __init__.py, client.py, _http.py, errors.py, models/, resources/ (+ .oagen-manifest.json) -``` - -`npm run sdk:generate` runs the tutorial's command (`oagen generate --lang -python --spec spec/open-api-spec.yaml --namespace Client --output -../src/amzn_selling_partner/sdk`); `npm run generate` runs the whole thing -(spec build, Amazon into `sdk/`, the petstore fixtures into -`tests/petstore_sdk`, ruff). The layout follows -[workos/openapi-spec](https://github.com/workos/openapi-spec): the committed -spec in `spec/`, the resolution policy in `src/policy/` behind a thin -`oagen.config.ts`, `sdk:resolve` / `sdk:generate` / `sdk:diff` npm scripts -that are plain `oagen` CLI invocations. - -### Step 0: the spec build (`src/spec/build.ts`) - -oagen consumes one OpenAPI 3 document; Amazon ships 67 Swagger 2.0 files. -The build converts each file with `swagger2openapi` (after repairing the -`#ref` typo and the dangling references in the pinned models), renames its -components to `:` (`orders_v0:Order`) so equally named schemas -of different API versions never collide, tags every operation with its API -version (`OrdersV0`: the oagen service, hence the resource class) and merges -everything. Notification JSON Schemas are wrapped into components under -`notifications.:`; `x-root-schemas` remembers their roots. -Path collisions are an error (none in the pinned models). - -### The policy (`src/policy/`, consumed by `oagen.config.ts`) - -* `transformSpec` (`transforms.ts`): the pre-IR fixes oagen needs for these files. Named - non-object schemas (`OrderList: array`, `MarketplaceId: string`, bare - `oneOf` unions) would become empty models: they are inlined at every `$ref` - site. Inline objects are hoisted to named components (``). - Every component name is replaced by an opaque token (`X17`) because oagen's - `cleanSchemaName` singularises and re-cases names (`OrdersList` → - `OrderList`, `ASINIdentifier` → `AsinIdentifier`); `schemaNameTransform` - maps the token back. -* `operationIdTransform` (`transforms.ts`): identity (oagen would camelCase `getFeatureSKU`). -* `operationHints` (`operation-hints.ts`): the colliding derived names - (`npm run sdk:resolve -- --format table` shows the table); the vitest suite - fails on a hint that no longer names an operation of the committed spec. -* `mountRules` (`mount-rules.ts`): oagen splits a service whose paths start with different - segments (`/products/...` and `/batches/...` of pricing v0); the rule mounts - both back on `ProductPricingV0`. -* `emitterOptions.python` (in `oagen.config.ts`, language-specific like the - example's `emitterOptions.node`): `sdkBehavior` (retry on 408/429/5xx, 2 retries, - 0.5 s initial delay, ×2, 8 s cap, 50 % jitter, 30 s timeout overridable with - `AMZN_SELLING_PARTNER_TIMEOUT`), `requestIdHeader`, `rateHintHeader`, - `greedyPathParams` (`resource` of the Uploads API), `serviceAliases` - (`invoices` → `invoices_api_model`, ...), `distribution`. - -### The emitter (`src/python/`) - -Laid out as the reference prompt for an oagen Python emitter prescribes: five -modules assembled in `index.ts`, plus three small support modules. - -* `types.ts` – `TypeRef` → Python annotation, an exhaustive switch over every - `kind` with `assertNever` in the default branch (a new kind breaks the build - instead of emitting bad Python); `importsFor` derives the `typing` / - `datetime` imports a module needs from its annotations. -* `models.ts` – models and enums. Package planning (the `:` prefix - of the spec build, or for unprefixed names the package of the service that - uses them); `models//enums.py` (`class Status(str, Enum)` with - `__str__ = str.__str__`, so `str(Status.DONE)` is `"done"` on Python 3.11+ - and never leaks `Status.DONE` into a query string); `models//__init__.py` - with every model of the package in one module (`from __future__ import - annotations`, so cross-references never form import cycles), required - fields first, optional fields `X | None = None`, snake_case attributes with - the wire name as alias; `models/_base.py` (`SpecModel`). -* `resources.ts` – `resources/.py`: `class OrdersV0Resource` and - `AsyncOrdersV0Resource`, one method per resolved operation (names from - `ctx.resolvedOperations`). Path parameters, the body and required parameters - are positional, optional ones keyword-only; each method builds `params` / - `headers` explicitly and calls `self._http.request(...)` with the response - model, the error model, the `RateLimit` and the operationId; `iter_` - for paginated operations; `resources/__init__.py` with the `SERVICES` / - `OPERATIONS` registry. Also reads `emitterOptions.python`. -* `client.ts` – `client.py` (the `--namespace` class `Client` / `AsyncClient` - with one lazily created resource per service, a latest-version alias per - API and `with_options()`), `__init__.py`, `errors.py` (from the error - policy: `BadRequestError`, ..., `RateLimitExceededError`, `ServerError`) and - `_http.py`, built on httpx2: it constructs `httpx2.Request`s (URL, query - and header encoding are httpx2's) and sends them through an `httpx2.Client` - whose transport carries the connection retries; the module adds the - status-code retries with backoff (constants from the SDK behavior in the - IR), per-operation token buckets, the `Auth` hook, response decoding, - `paginate` / `apaginate`. The httpx2 client is built through a factory - method (`_create_client`, the sync and async subclass each pick their - product) whose products are public factories, `DefaultHttpxClient`, - `DefaultAsyncHttpxClient` and `DefaultAioHttpClient`, so a caller - configures one and injects it as `http_client=`; the aiohttp transport, - written for `httpx`, is driven through an adapter to httpx2's transport - interface. -* `index.ts` – assembles the `Emitter`; `naming.ts`, `pagination.ts` - (token-parameter heuristic + the Amazon override table) and `ratelimits.ts` - (usage-plan tables) support the above. - -`src/plugin.ts` exports `{ emitters, extractors, smokeRunners }` and -`oagen.config.ts` spreads it (the CLI bundles its own registry, so -`registerEmitter()` would not be seen); its `formatCommand` runs ruff over -every written file, so generation needs no formatting step of its own. -`npm run generate` produces both SDKs from clean output directories, -`npm test` (vitest over an inline fixture spec written to a temp file, -`tests/fixtures/tasks-api.yml` and the helper modules), `npm run typecheck` -and `npm run build` (tsup) are the generator's checks; the generated SDK is -exercised by pytest (`tests/test_sdk_end_to_end.py` over -`httpx2.MockTransport`, `tests/petstore_sdk` for every operation shape). -`codegen/README.md` lists the commands and the oagen API discrepancies met -on the way. - -### Hand-written (never generated) - -`plugins/amazon_spapi.py` (`SellingPartner(Client)` / `AsyncSellingPartner`: -regions, credentials, LWA auth hook with RDT and grantless scopes, document -helpers, notification registry), `plugins/_amazon/*`, the 0.1.x compatibility -subpackages (`client/`, `reports/`, `vendor/`, `utils/`), `sandbox_tests.py`, -`_examples.py`, `_naming.py`, `_compat.py`, `__init__.py`. - -### Known irregularities in the pinned models - -`B2bAnyOfferChangedNotification.json` spells a reference `#ref` (repaired), -`ShipmentTrackingMilestoneChangedNotification.json` is a dangling -`$ref` (becomes an empty model), `ListingsItemStatusChangeNotification.json`'s -own example contradicts its enum, `linkCarrierAccount` (Shipping v2) is the -operationId of two methods on one path (`OPERATIONS` keys the second one -`...:PUT`), 70 of the 2034 embedded sandbox examples violate their own schemas. - -## Measured (Python 3.13, this container) - -| | | -|---|---| -| import one resource (its models included) | 46 ms worst, 9 ms median | -| import all 67 resources | 742 ms | -| `import amzn_selling_partner` + `SellingPartner()` | 177 ms | -| generated method vs hand-written httpx2 call (`pytest benchmarks`, best of rounds) | 0.91 sync / 0.95 async | -| wheel | 551 KB, 347 Python files | -| ty strict (hand-written + generated + tests/petstore_sdk) | 0 errors | -| tests | 116 pytest + 28 vitest; sandbox runner 1964 / 2034 examples | diff --git a/docs/UPDATING_SPECS.md b/docs/UPDATING_SPECS.md index 08af5d5..cb55cdc 100644 --- a/docs/UPDATING_SPECS.md +++ b/docs/UPDATING_SPECS.md @@ -5,7 +5,7 @@ The Amazon models are a git submodule at `spec/selling-partner-api-models` package ships the Python that `codegen/` (an [oagen](https://github.com/workos/oagen) emitter) generates from them, committed under `src/amzn_selling_partner/sdk`. -All commands run in `codegen/` after `npm ci --ignore-scripts` (Node 22; +All commands run in `codegen/` after `npm ci --ignore-scripts` (Node 24; `--ignore-scripts` skips the native builds of tree-sitter grammars oagen lists for its compat extractors, which this project never loads). @@ -58,8 +58,7 @@ Look at: - **New API files or versions** – the naming rule lives in `codegen/src/amazon.ts` (`apiNaming`, `UNVERSIONED`, `ALIASES`) and is - mirrored for the sandbox runner in - `src/amzn_selling_partner/plugins/_amazon/specs.py`. A file whose stem + mirrored for the sandbox runner in `tests/_specs.py`. A file whose stem carries no version suffix needs an `UNVERSIONED` entry in both. The latest-version alias (`client.orders`) moves automatically. - **Rate-limit tables** – `test_rate_limit_counts_across_pinned_specs` pins the @@ -89,7 +88,7 @@ uv run ruff check src tests benchmarks && uv run ruff format --check src tests b uv run ty check uv run pytest uvx nox -s security_test # bandit over the package (generated code included) + safety -uv run python -m amzn_selling_partner.sandbox_tests # every operation against its embedded examples +uv run python -m tests.sandbox # every operation against its embedded examples ``` bandit runs over the generated code too: the emitter marks the three kinds of @@ -108,8 +107,8 @@ python`, built the way the WorkOS tutorial describes and organised like [workos/openapi-spec](https://github.com/workos/openapi-spec): the spec in `spec/`, the resolution policy in `src/policy/` (operation hints, mount rules, -transforms, consumed by a thin `oagen.config.ts`), the `sdk:*` scripts wrapping -the `oagen` CLI (`scripts/`), the emitter in `src/python/`. The tutorial's own +transforms, consumed by a thin `oagen.config.ts`), the `sdk:*` npm scripts +wrapping the `oagen` CLI, the emitter in `src/python/`. The tutorial's own spec is checked in as `tests/fixtures/tasks-api.yml`, so every step can be reproduced here: diff --git a/pyproject.toml b/pyproject.toml index 706da02..648171c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,9 +54,6 @@ exclude = ["build", ".venv", ".nox", "spec", "codegen"] select = ["B", "C4", "E", "W", "F", "I", "Q", "UP"] ignore = ["E501", "B904", "B008"] -[tool.ruff.lint.per-file-ignores] -"tests/*.py" = ["S101", "S106"] - [tool.ruff.format] quote-style = "double" indent-style = "space" diff --git a/src/amzn_selling_partner/__init__.py b/src/amzn_selling_partner/__init__.py index 581a2b1..a644f1f 100644 --- a/src/amzn_selling_partner/__init__.py +++ b/src/amzn_selling_partner/__init__.py @@ -9,9 +9,7 @@ ``AsyncSellingPartner`` / ``SellingPartner`` (``plugins.amazon_spapi``) add the Amazon specifics on top: regions, Login-with-Amazon auth with Restricted Data -Tokens and grantless scopes, document helpers and notification models. The -``client``, ``reports``, ``vendor`` and ``utils`` subpackages keep the 0.1.x -entry points working (see ``MIGRATION.md``). +Tokens and grantless scopes, document helpers and notification models. """ from __future__ import annotations @@ -24,7 +22,6 @@ except PackageNotFoundError: # pragma: no cover - source checkout without install __version__ = "0.0.0" -from . import client, reports, utils, vendor # noqa: E402 (0.1.x compatibility subpackages) from .sdk._http import DefaultAioHttpClient, DefaultAsyncHttpxClient, DefaultHttpxClient, RateLimit, RequestOptions from .sdk.errors import ( APIConnectionError, @@ -46,10 +43,6 @@ from .plugins.amazon_spapi import AsyncSellingPartner, Marketplace, Region, SellingPartner from .sdk.client import AsyncClient, Client -#: 0.2.0 names of the status errors. -RateLimitError = RateLimitExceededError -InternalServerError = ServerError - __all__ = [ "APIConnectionError", "APIError", @@ -66,11 +59,9 @@ "DefaultAioHttpClient", "DefaultAsyncHttpxClient", "DefaultHttpxClient", - "InternalServerError", "Marketplace", "NotFoundError", "RateLimit", - "RateLimitError", "RateLimitExceededError", "Region", "RequestOptions", @@ -78,10 +69,6 @@ "ServerError", "UnprocessableEntityError", "__version__", - "client", - "reports", - "utils", - "vendor", ] _LAZY = { diff --git a/src/amzn_selling_partner/_compat.py b/src/amzn_selling_partner/_compat.py deleted file mode 100644 index d1b7b6e..0000000 --- a/src/amzn_selling_partner/_compat.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Helpers shared by the compatibility resources.""" - -from __future__ import annotations - -from typing import Any - -from pydantic import BaseModel - -from ._naming import param_name -from .sdk.resources import OPERATIONS - - -def query_kwargs(query: BaseModel | dict[str, Any] | None) -> dict[str, Any]: - """Old ``*Query`` models used the wire parameter names as fields; map - them to the snake_case keyword arguments of the generated methods.""" - if query is None: - return {} - data = query.model_dump(exclude_none=True, by_alias=True) if isinstance(query, BaseModel) else dict(query) - return {param_name(k): v for k, v in data.items()} - - -def to_body(data: Any) -> Any: - if isinstance(data, BaseModel): - return data.model_dump(exclude_none=True, by_alias=True, mode="json") - return data - - -def require_str(value: object, name: str) -> str: - """The 0.1.x clients validated ids at runtime; keep that contract.""" - if not value or not isinstance(value, str): - raise ValueError(f"{name} must be a string present but found `{value}`") - return value - - -def operation(client: Any, module: str, operation_id: str) -> Any: - """The generated method of ``operationId`` on ``client.`` (looked up - through the resources registry, so Amazon's operation names stay the key).""" - method, *_rest = OPERATIONS[f"{module}.{operation_id}"] - return getattr(getattr(client, module), method) diff --git a/src/amzn_selling_partner/_naming.py b/src/amzn_selling_partner/_naming.py index 4b8330d..ab06e0f 100644 --- a/src/amzn_selling_partner/_naming.py +++ b/src/amzn_selling_partner/_naming.py @@ -1,14 +1,9 @@ -"""Python naming rules, mirroring ``codegen/src/python/naming.ts`` (used by the -sandbox runner and the compatibility wrappers to map wire names to the -generated keyword arguments).""" +"""Naming rules shared with ``codegen/src/python/naming.ts``.""" from __future__ import annotations -import keyword import re -_KEYWORDS = set(keyword.kwlist) | {"match", "case", "type"} - def snake_case(name: str) -> str: s = re.sub(r"[^0-9a-zA-Z_]+", "_", name) @@ -18,52 +13,10 @@ def snake_case(name: str) -> str: return s or "field" -def pascal_case(name: str) -> str: - parts = [p for p in re.sub(r"[^0-9a-zA-Z_]+", "_", name).split("_") if p] - return "".join(p[0].upper() + p[1:] for p in parts) or "Model" - - -def param_name(wire_name: str) -> str: - """Keyword argument name of an operation parameter.""" - s = snake_case(wire_name) - if s[:1].isdigit(): - s = "p" + s - if s in _KEYWORDS or s in {"self", "request_options", "body", "params", "headers"}: - s += "_" - return s - - -def field_name(wire_name: str) -> str: - """Attribute name of a model field.""" - s = snake_case(wire_name) - if s[:1].isdigit(): - s = "n" + s - if s.startswith("_"): - s = "x" + s - reserved = { - "schema", - "copy", - "json", - "dict", - "validate", - "construct", - "fields", - "parse_obj", - "parse_raw", - "parse_file", - "from_orm", - "update_forward_refs", - "schema_json", - } - if s in _KEYWORDS or s in reserved or s.startswith("model_"): - s += "_" - return s - - def api_version_of(module: str) -> tuple[str, str] | None: """``orders_v0`` -> ``("orders", "v0")`` (the resource module of an API version).""" m = re.match(r"^(.+?)_(v\d.*)$", module) return (m.group(1), m.group(2)) if m else None -__all__ = ["api_version_of", "field_name", "param_name", "pascal_case", "snake_case"] +__all__ = ["api_version_of", "snake_case"] diff --git a/src/amzn_selling_partner/client/__init__.py b/src/amzn_selling_partner/client/__init__.py deleted file mode 100644 index 7bbf4bf..0000000 --- a/src/amzn_selling_partner/client/__init__.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Compatibility ``BaseClient`` / ``SellingPartnerRegion``. - -``BaseClient`` wraps :class:`amzn_selling_partner.SellingPartner`. The AWS keyword arguments -of the old constructor are accepted and ignored (the Selling Partner API no -longer requires AWS Signature V4). -""" - -from __future__ import annotations - -import os -import warnings -from typing import Any - -from ..plugins.amazon_spapi import Region, SellingPartner -from . import auth - -#: Same members as before (``NORTH_AMERICA`` / ``EUROPE`` / ``FAR_EAST``) with -#: ``api_endpoint``, ``api_sandbox_endpoint`` and ``region_name`` properties. -SellingPartnerRegion = Region - - -class BaseClient: - """Old-style client: one instance per API resource. - - ``sp`` is the underlying :class:`amzn_selling_partner.SellingPartner`; subclasses map - their old methods onto it. - """ - - def __init__( - self, - *, - selling_partner_region: Region = Region.NORTH_AMERICA, - selling_partner_app_client_id: str | None = None, - selling_partner_app_client_secret: str | None = None, - selling_partner_app_refresh_token: str | None = None, - aws_access_key_id: str | None = None, - aws_secret_access_key: str | None = None, - aws_selling_partner_role: str | None = None, - aws_selling_partner_role_session_name: str | None = None, - sandbox: bool = False, - **options: Any, - ) -> None: - if any((aws_access_key_id, aws_secret_access_key, aws_selling_partner_role, aws_selling_partner_role_session_name)): - warnings.warn( - "AWS credentials are no longer used by the Selling Partner API; the aws_* arguments are ignored", - DeprecationWarning, - stacklevel=2, - ) - self.region = selling_partner_region - self.sandbox = sandbox - client_id = selling_partner_app_client_id or os.getenv("SELLING_PARTNER_APP_CLIENT_ID") - client_secret = selling_partner_app_client_secret or os.getenv("SELLING_PARTNER_APP_CLIENT_SECRET") - refresh_token = selling_partner_app_refresh_token or os.getenv("SELLING_PARTNER_APP_REFRESH_TOKEN") - self.sp = SellingPartner( - region=selling_partner_region, - sandbox=sandbox, - client_id=client_id or None, - client_secret=client_secret or None, - refresh_token=refresh_token or None, - **options, - ) - - def get_api_endpoint(self) -> str: - return self.region.api_sandbox_endpoint if self.sandbox else self.region.api_endpoint - - def get_resource_path(self) -> str: - raise NotImplementedError() - - def get_resource_endpoint(self) -> str: - return f"{self.get_api_endpoint()}/{self.get_resource_path()}" - - def get_operation_endpoint(self, operation_method: str) -> str: - return f"{self.get_resource_endpoint()}/{operation_method}" - - def close(self) -> None: - self.sp.close() - - -__all__ = ["BaseClient", "SellingPartnerRegion", "auth"] diff --git a/src/amzn_selling_partner/client/auth.py b/src/amzn_selling_partner/client/auth.py deleted file mode 100644 index ed3ce31..0000000 --- a/src/amzn_selling_partner/client/auth.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Compatibility shims for the old auth classes. - -Only the LWA access-token part survives; it delegates to -:class:`amzn_selling_partner.plugins.amazon_spapi.LWAAuth`. The AWS SigV4 classes are gone -(see ``MIGRATION.md``) and raise on use. -""" - -from __future__ import annotations - -from typing import Any, TypedDict - -from ..plugins.amazon_spapi import LWAAuth, LWACredentials - - -class ClientSessionAuthAccessTokenError(Exception): - def __init__(self, *args: Any, cause: Exception | None = None) -> None: - super().__init__(*args) - self.cause = cause - - -class ClientSessionAuthAccessTokenData(TypedDict): - access_token: str - expires_at: int - - -class ClientSessionAuthAccessToken: - """Refresh-token grant with caching (single instance per client).""" - - def __init__(self, *, client_id: str, client_secret: str, refresh_token: str, **options: Any) -> None: - self.client_id = client_id - self.client_secret = client_secret - self.refresh_token = refresh_token - self._auth = LWAAuth(LWACredentials(client_id=client_id, client_secret=client_secret, refresh_token=refresh_token), **options) - - def get_access_token(self) -> str: - try: - return self._auth.access_token() - except Exception as error: # noqa: BLE001 - old API contract wraps every failure - raise ClientSessionAuthAccessTokenError(cause=error) from error - - -class ClientSessionAuthTemporaryCredentialsError(Exception): - def __init__(self, *args: Any, cause: Exception | None = None) -> None: - super().__init__(*args) - self.cause = cause - - -def _removed(*_args: Any, **_kwargs: Any) -> Any: - raise NotImplementedError( - "AWS Signature V4 authentication was removed: the Selling Partner API only needs the LWA access token. See MIGRATION.md." - ) - - -ClientSessionAuthTemporaryCredentials = _removed -ClientSessionAuth = _removed - -__all__ = [ - "ClientSessionAuth", - "ClientSessionAuthAccessToken", - "ClientSessionAuthAccessTokenData", - "ClientSessionAuthAccessTokenError", - "ClientSessionAuthTemporaryCredentials", - "ClientSessionAuthTemporaryCredentialsError", -] diff --git a/src/amzn_selling_partner/plugins/_amazon/documents.py b/src/amzn_selling_partner/plugins/_amazon/documents.py index 8fd7dfb..8a7ba89 100644 --- a/src/amzn_selling_partner/plugins/_amazon/documents.py +++ b/src/amzn_selling_partner/plugins/_amazon/documents.py @@ -11,13 +11,19 @@ import httpx2 -from ..._compat import operation from ...sdk.errors import APIConnectionError, status_error_class +from ...sdk.resources import OPERATIONS from .rdt import RESTRICTED_REPORT_TYPES CHUNK = 64 * 1024 +def operation(client: Any, module: str, operation_id: str) -> Any: + """The generated method of Amazon's ``operationId`` on ``client.``.""" + method, *_rest = OPERATIONS[f"{module}.{operation_id}"] + return getattr(getattr(client, module), method) + + def _check(response: httpx2.Response, what: str) -> None: if response.status_code >= 400: cls = status_error_class(response.status_code) diff --git a/src/amzn_selling_partner/plugins/amazon_spapi.py b/src/amzn_selling_partner/plugins/amazon_spapi.py index cebe7e6..1d1b1cc 100644 --- a/src/amzn_selling_partner/plugins/amazon_spapi.py +++ b/src/amzn_selling_partner/plugins/amazon_spapi.py @@ -2,7 +2,7 @@ Everything Amazon-specific lives here and in ``amzn_selling_partner.plugins._amazon``: regional servers, LWA auth with Restricted Data Tokens and grantless scopes, -document helpers, sandbox examples and notification models. Rate limits and +document helpers and notification models. Rate limits and pagination helpers are generated into the resource modules by ``codegen/``. """ @@ -19,8 +19,6 @@ from ._amazon.notifications import Notifications from ._amazon.rdt import GRANTLESS, RESTRICTED, RESTRICTED_REPORT_TYPES, RestrictedOperation, restricted_for from ._amazon.regions import LWA_TOKEN_URL, Marketplace, Region -from ._amazon.sandbox import SandboxExample, is_dynamic_sandbox, sandbox_examples -from ._amazon.specs import api_naming, default_schema_dir, default_spec_dir, spec_files log = logging.getLogger("amzn_selling_partner.plugins.amazon") @@ -182,17 +180,10 @@ async def aclose(self) -> None: "Notifications", "Region", "RestrictedOperation", - "SandboxExample", "SellingPartner", "Token", "TokenStore", - "api_naming", - "default_schema_dir", - "default_spec_dir", - "is_dynamic_sandbox", "restricted_for", - "sandbox_examples", - "spec_files", "unparsed_rate_limits", "with_rdt", ] diff --git a/src/amzn_selling_partner/reports/__init__.py b/src/amzn_selling_partner/reports/__init__.py deleted file mode 100644 index 65009d6..0000000 --- a/src/amzn_selling_partner/reports/__init__.py +++ /dev/null @@ -1,147 +0,0 @@ -"""Compatibility ``reports`` resource over ``amzn_selling_partner`` (Reports API 2021-06-30).""" - -from __future__ import annotations - -import json -from typing import Any - -from pydantic import BaseModel, ConfigDict - -from .. import client as _compat_client -from .._compat import operation, query_kwargs, require_str, to_body -from ..utils import file as _file -from .models import ( - CompressionAlgorithm, - DistributorView, - MarketPlaceId, - ProcessingStatus, - ReportPeriod, - ReportType, - SchedulePeriod, - SellingProgram, -) - - -class ReportOptions(BaseModel): - model_config = ConfigDict(extra="forbid") - - reportPeriod: ReportPeriod | str | None = None - distributorView: DistributorView | str | None = None - sellingProgram: SellingProgram | str | None = None - - -class CreateReportSpecification(BaseModel): - model_config = ConfigDict(extra="allow") - - reportType: ReportType | str - marketplaceIds: list[MarketPlaceId | str] - reportOptions: ReportOptions | dict[str, str] | None = None - dataStartTime: str | None = None - dataEndTime: str | None = None - - -class CreateReportScheduleSpecification(BaseModel): - model_config = ConfigDict(extra="allow") - - reportType: ReportType | str - marketplaceIds: list[MarketPlaceId | str] - period: SchedulePeriod | str - reportOptions: ReportOptions | dict[str, str] | None = None - nextReportCreationTime: str | None = None - - -class GetReportsQuery(BaseModel): - model_config = ConfigDict(extra="allow") - - reportTypes: list[ReportType | str] | None = None - processingStatuses: list[ProcessingStatus | str] | None = None - marketplaceIds: list[MarketPlaceId | str] | None = None - pageSize: int | None = None - createdSince: str | None = None - createdUntil: str | None = None - nextToken: str | None = None - - -class Client(_compat_client.BaseClient): - def get_resource_path(self) -> str: - return "reports/2021-06-30" - - @property - def api(self) -> Any: - return self.sp.reports_v2021_06_30 - - def _op(self, operation_id: str) -> Any: - return operation(self.sp, "reports_v2021_06_30", operation_id) - - def create_report(self, data: CreateReportSpecification | dict[str, Any]) -> Any: - """Create the report and return the ``Report`` (as before: one extra ``getReport`` call).""" - response = self._op("createReport")(body=to_body(data)) - return self.get_report(response.report_id) - - def get_reports(self, *, query: GetReportsQuery | dict[str, Any] | None = None, pages_limit: int = 3) -> list[Any]: - """Reports across up to ``pages_limit`` pages.""" - reports: list[Any] = [] - kwargs = query_kwargs(query) - for _ in range(pages_limit): - page = self._op("getReports")(**kwargs) - reports.extend(page.reports) - if not page.next_token: - break - kwargs = {"next_token": page.next_token} - return reports - - def get_report(self, report_id: str) -> Any: - require_str(report_id, "report_id") - return self._op("getReport")(report_id=report_id) - - def get_report_document(self, report_document_id: str, *, enable_content_encoding_url_header: bool | None = None) -> Any: - self._check_id(report_document_id) - kwargs: dict[str, Any] = {"report_document_id": report_document_id} - if enable_content_encoding_url_header is not None: - kwargs["enable_content_encoding_url_header"] = enable_content_encoding_url_header - return self._op("getReportDocument")(**kwargs) - - def get_report_document_content(self, report_document_id: str, *, enable_content_encoding_url_header: bool | None = None) -> Any: - """The document parsed as JSON (report types with JSON payloads).""" - return json.loads(self._raw_content(report_document_id, enable_content_encoding_url_header)) - - def download_report_document_content( - self, report_document_id: str, file_path: str, *, enable_content_encoding_url_header: bool | None = None - ) -> None: - self._check_id(report_document_id) - require_str(file_path, "file_path") - _file.write_binary_file(file_path, self._raw_content(report_document_id, enable_content_encoding_url_header)) - - def _raw_content(self, report_document_id: str, enable_content_encoding_url_header: bool | None) -> bytes: - from amzn_selling_partner.plugins._amazon.documents import download_document - - doc = self.get_report_document(report_document_id, enable_content_encoding_url_header=enable_content_encoding_url_header) - return download_document(doc.url, compression=doc.compression_algorithm, http_client=self.sp.http_client) - - @staticmethod - def _check_id(report_document_id: str) -> None: - require_str(report_document_id, "report_document_id") - - -def __getattr__(name: str) -> Any: - """Spec-generated models (``Report``, ``ReportDocument``, ``ReportSchedule``, ...).""" - from .models import model - - return model(name) - - -__all__ = [ - "Client", - "CompressionAlgorithm", - "CreateReportScheduleSpecification", - "CreateReportSpecification", - "DistributorView", - "GetReportsQuery", - "MarketPlaceId", - "ProcessingStatus", - "ReportOptions", - "ReportPeriod", - "ReportType", - "SchedulePeriod", - "SellingProgram", -] diff --git a/src/amzn_selling_partner/reports/models.py b/src/amzn_selling_partner/reports/models.py deleted file mode 100644 index 29e368e..0000000 --- a/src/amzn_selling_partner/reports/models.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Enum values kept from the 0.1.x hand-written models plus access to the -spec-generated pydantic models of the Reports API.""" - -from __future__ import annotations - -import enum -from typing import Any - - -class ReportType(str, enum.Enum): - VENDOR_REAL_TIME_INVENTORY_REPORT = "GET_VENDOR_REAL_TIME_INVENTORY_REPORT" - VENDOR_REAL_TIME_TRAFFIC_REPORT = "GET_VENDOR_REAL_TIME_TRAFFIC_REPORT" - VENDOR_REAL_TIME_SALES_REPORT = "GET_VENDOR_REAL_TIME_SALES_REPORT" - VENDOR_SALES_REPORT = "GET_VENDOR_SALES_REPORT" - VENDOR_NET_PURE_PRODUCT_MARGIN_REPORT = "GET_VENDOR_NET_PURE_PRODUCT_MARGIN_REPORT" - VENDOR_TRAFFIC_REPORT = "GET_VENDOR_TRAFFIC_REPORT" - VENDOR_FORECASTING_REPORT = "GET_VENDOR_FORECASTING_REPORT" - VENDOR_INVENTORY_REPORT = "GET_VENDOR_INVENTORY_REPORT" - - -class MarketPlaceId(str, enum.Enum): - CANADA = "A2EUQ1WTGCTBG2" - UNITED_STATES_OF_AMERICA = "ATVPDKIKX0DER" - MEXICO = "A1AM78C64UM0Y8" - BRAZIL = "A2Q3Y263D00KWC" - SPAIN = "A1RKKUPIHCS9HS" - UNITED_KINGDOM = "A1F83G8C2ARO7P" - FRANCE = "A13V1IB3VIYZZH" - BELGIUM = "AMEN7PMS3EDWL" - NETHERLANDS = "A1805IZSGTT6HS" - GERMANY = "A1PA6795UKMFR9" - ITALY = "APJ6JRA9NG5V4" - SWEDEN = "A2NODRKZP88ZB9" - POLAND = "A1C3SOZRARQ6R3" - EGYPT = "ARBP9OOSHTCHU" - TURKEY = "A33AVAJ2PDY3EV" - SAUDI_ARABIA = "A17E79C6D8DWNP" - UNITED_ARAB_EMIRATES = "A2VIGQ35RCS4UG" - INDIA = "A21TJRUUN4KGV" - SINGAPORE = "A19VAU5U5O7RUS" - AUSTRALIA = "A39IBJ37TRP1C6" - JAPAN = "A1VC38T7YXB528" - - -class ProcessingStatus(str, enum.Enum): - CANCELLED = "CANCELLED" - DONE = "DONE" - FATAL = "FATAL" - IN_PROGRESS = "IN_PROGRESS" - IN_QUEUE = "IN_QUEUE" - - -class CompressionAlgorithm(str, enum.Enum): - GZIP = "GZIP" - - -class SchedulePeriod(str, enum.Enum): - FIVE_MINUTES = "PT5M" - FIFTEEN_MINUTES = "PT15M" - THIRTY_MINUTES = "PT30M" - ONE_HOUR = "PT1H" - TWO_HOURS = "PT2H" - FOUR_HOURS = "PT4H" - EIGHT_HOURS = "PT8H" - TWELVE_HOURS = "PT12H" - ONE_DAY = "P1D" - TWO_DAYS = "P2D" - THREE_DAYS = "P3D" - EIGHTY_FOUR_HOURS = "PT84H" - ONE_WEEK = "P7D" - TWO_WEEKS = "P14D" - FIFTEEN_DAYS = "P15D" - EIGHTEEN_DAYS = "P18D" - THIRTY_DAYS = "P30D" - ONE_MONTH = "P1M" - - -class ReportPeriod(str, enum.Enum): - DAY = "DAY" - WEEK = "WEEK" - MONTH = "MONTH" - QUARTER = "QUARTER" - YEAR = "YEAR" - - -class DistributorView(str, enum.Enum): - SOURCING = "SOURCING" - MANUFACTURING = "MANUFACTURING" - - -class SellingProgram(str, enum.Enum): - RETAIL = "RETAIL" - BUSINESS = "BUSINESS" - FRESH = "FRESH" - - -def namespace() -> Any: - """The generated models module for ``reports``.""" - import importlib - - return importlib.import_module("amzn_selling_partner.sdk.models.reports_v2021_06_30") - - -def model(name: str) -> Any: - ns = namespace() - if name not in ns.__all__: - raise AttributeError(f"module 'amzn_selling_partner.reports' has no attribute {name!r}") - return getattr(ns, name) - - -def __getattr__(name: str) -> Any: - if name.startswith("_"): - raise AttributeError(name) - return model(name) diff --git a/src/amzn_selling_partner/utils/__init__.py b/src/amzn_selling_partner/utils/__init__.py deleted file mode 100644 index 284f13e..0000000 --- a/src/amzn_selling_partner/utils/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from . import date, file - -__all__ = ["date", "file"] diff --git a/src/amzn_selling_partner/utils/date.py b/src/amzn_selling_partner/utils/date.py deleted file mode 100644 index 3476410..0000000 --- a/src/amzn_selling_partner/utils/date.py +++ /dev/null @@ -1,18 +0,0 @@ -import datetime -import typing - - -def datetime_utcnow() -> datetime.datetime: - """Naive UTC "now" (same contract as before, without ``utcnow()``).""" - return datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - - -def datetime_utcpast( - amount: int | float, - amount_type: typing.Literal["weeks", "days", "hours", "minutes", "seconds", "milliseconds", "microseconds"], -) -> datetime.datetime: - return datetime_utcnow() - datetime.timedelta(**{amount_type: amount}) - - -def amazon_isoformat(value: datetime.datetime) -> str: - return f"{value.isoformat(timespec='milliseconds')}Z" diff --git a/src/amzn_selling_partner/utils/file.py b/src/amzn_selling_partner/utils/file.py deleted file mode 100644 index bcd9571..0000000 --- a/src/amzn_selling_partner/utils/file.py +++ /dev/null @@ -1,3 +0,0 @@ -def write_binary_file(file_path: str, content: bytes) -> None: - with open(file_path, "bw") as f: - f.write(content) diff --git a/src/amzn_selling_partner/vendor/__init__.py b/src/amzn_selling_partner/vendor/__init__.py deleted file mode 100644 index f081b64..0000000 --- a/src/amzn_selling_partner/vendor/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from . import orders - -__all__ = ["orders"] diff --git a/src/amzn_selling_partner/vendor/orders/__init__.py b/src/amzn_selling_partner/vendor/orders/__init__.py deleted file mode 100644 index 5aa8084..0000000 --- a/src/amzn_selling_partner/vendor/orders/__init__.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Compatibility ``vendor.orders`` resource over ``amzn_selling_partner``. - -Models are the spec-generated ones (``sdk.models.vendor_orders_v1``); the -old enum classes are kept as plain ``str`` enums. -""" - -from __future__ import annotations - -from typing import Any - -from pydantic import BaseModel, ConfigDict - -from ... import client as _compat_client -from ..._compat import operation, query_kwargs, require_str, to_body -from .models import ( - AcknowledgementCode, - InternationalCommercialTerms, - ItemConfirmationStatus, - ItemReceiveStatus, - MethodOfPayment, - MoneyUnitOfMeasure, - PaymentMethod, - PoItemState, - PurchaseOrderState, - PurchaseOrderStatus, - PurchaseOrderType, - RejectionReason, - SortOrder, - TaxRegistrationType, - UnitOfMeasure, -) - - -class GetPurchaseOrdersQuery(BaseModel): - model_config = ConfigDict(extra="allow") - - limit: int | None = None - createdAfter: str | None = None - createdBefore: str | None = None - sortOrder: SortOrder | str | None = None - nextToken: str | None = None - includeDetails: str | bool | None = None - changedAfter: str | None = None - changedBefore: str | None = None - poItemState: PoItemState | str | None = None - isPOChanged: str | bool | None = None - purchaseOrderState: PurchaseOrderState | str | None = None - orderingVendorCode: str | None = None - - -class GetPurchaseOrdersStatusQuery(BaseModel): - model_config = ConfigDict(extra="allow") - - limit: int | None = None - sortOrder: SortOrder | str | None = None - nextToken: str | None = None - createdAfter: str | None = None - createdBefore: str | None = None - updatedAfter: str | None = None - updatedBefore: str | None = None - purchaseOrderNumber: str | None = None - purchaseOrderStatus: PurchaseOrderStatus | str | None = None - itemConfirmationStatus: ItemConfirmationStatus | str | None = None - itemReceiveStatus: ItemReceiveStatus | str | None = None - orderingVendorCode: str | None = None - shipToPartyId: str | None = None - - -class Client(_compat_client.BaseClient): - def get_resource_path(self) -> str: - return "vendor/orders/v1" - - @property - def api(self) -> Any: - return self.sp.vendor_orders_v1 - - def _op(self, operation_id: str, *, pages: bool = False) -> Any: - method = operation(self.sp, "vendor_orders_v1", operation_id) - return getattr(self.api, f"iter_{method.__name__}") if pages else method - - def get_purchase_orders(self, *, query: GetPurchaseOrdersQuery | dict[str, Any] | None = None) -> list[Any]: - """All purchase orders matching ``query`` (pages are followed automatically).""" - return list(self._op("getPurchaseOrders", pages=True)(**query_kwargs(query))) - - def get_purchase_order(self, purchase_order_number: str) -> Any: - require_str(purchase_order_number, "purchase_order_number") - return self._op("getPurchaseOrder")(purchase_order_number=purchase_order_number).payload - - def get_purchase_orders_status(self, *, query: GetPurchaseOrdersStatusQuery | dict[str, Any] | None = None) -> list[Any]: - return list(self._op("getPurchaseOrdersStatus", pages=True)(**query_kwargs(query))) - - def submit_acknowledgement(self, data: Any) -> Any: - return self._op("submitAcknowledgement")(body=to_body(data)) - - -def __getattr__(name: str) -> Any: - """Spec-generated models (``Order``, ``OrderDetails``, ``Address``, ...).""" - from .models import model - - return model(name) - - -__all__ = [ - "AcknowledgementCode", - "Client", - "GetPurchaseOrdersQuery", - "GetPurchaseOrdersStatusQuery", - "InternationalCommercialTerms", - "ItemConfirmationStatus", - "ItemReceiveStatus", - "MethodOfPayment", - "MoneyUnitOfMeasure", - "PaymentMethod", - "PoItemState", - "PurchaseOrderState", - "PurchaseOrderStatus", - "PurchaseOrderType", - "RejectionReason", - "SortOrder", - "TaxRegistrationType", - "UnitOfMeasure", -] diff --git a/src/amzn_selling_partner/vendor/orders/models.py b/src/amzn_selling_partner/vendor/orders/models.py deleted file mode 100644 index 8dd48fc..0000000 --- a/src/amzn_selling_partner/vendor/orders/models.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Enum values kept from the 0.1.x hand-written models plus access to the -spec-generated pydantic models of the Vendor Orders API.""" - -from __future__ import annotations - -import enum -from typing import Any - - -class PurchaseOrderType(str, enum.Enum): - REGULAR_ORDER = "RegularOrder" - CONSIGNED_ORDER = "ConsignedOrder" - NEW_PRODUCT_INTRODUCTION = "NewProductIntroduction" - RUSH_ORDER = "RushOrder" - - -class PurchaseOrderState(str, enum.Enum): - NEW = "New" - ACKNOWLEDGED = "Acknowledged" - CLOSED = "Closed" - - -class UnitOfMeasure(str, enum.Enum): - CASES = "Cases" - EACHES = "Eaches" - - -class MoneyUnitOfMeasure(str, enum.Enum): - POUNDS = "POUNDS" - OUNCES = "OUNCES" - GRAMS = "GRAMS" - KILOGRAMS = "KILOGRAMS" - - -class MethodOfPayment(str, enum.Enum): - PAID_BY_BUYER = "PaidByBuyer" - COLLECT_ON_DELIVERY = "CollectOnDelivery" - DEFINED_BY_BUYER_AND_SELLER = "DefinedByBuyerAndSeller" - FOB_PORT_OF_CALL = "FOBPortOfCall" - PREPAID_BY_SELLER = "PrepaidBySeller" - PAID_BY_SELLER = "PaidBySeller" - - -class InternationalCommercialTerms(str, enum.Enum): - EX_WORKS = "ExWorks" - FREE_CARRIER = "FreeCarrier" - FREE_ON_BOARD = "FreeOnBoard" - FREE_ALONG_SIDE_SHIP = "FreeAlongSideShip" - CARRIAGE_PAID_TO = "CarriagePaidTo" - COST_AND_FREIGHT = "CostAndFreight" - CARRIAGE_AND_INSURANCE_PAID_TO = "CarriageAndInsurancePaidTo" - COST_INSURANCE_AND_FREIGHT = "CostInsuranceAndFreight" - DELIVERED_AT_TERMINAL = "DeliveredAtTerminal" - DELIVERED_AT_PLACE = "DeliveredAtPlace" - DELIVER_DUTY_PAID = "DeliverDutyPaid" - - -class TaxRegistrationType(str, enum.Enum): - VALUE_ADDED_TAX = "VAT" - GOODS_AND_SERVICES_TAX = "GST" - - -class PaymentMethod(str, enum.Enum): - INVOICE = "Invoice" - CONSIGNMENT = "Consignment" - CREDIT_CARD = "CreditCard" - PREPAID = "Prepaid" - - -class SortOrder(str, enum.Enum): - ASCENDING = "ASC" - DESCENDING = "DESC" - - -class PoItemState(str, enum.Enum): - CANCELLED = "Cancelled" - - -class AcknowledgementCode(str, enum.Enum): - ACCEPTED = "Accepted" - BACKORDERED = "Backordered" - REJECTED = "Rejected" - - -class RejectionReason(str, enum.Enum): - TEMPORARILY_UNAVAILABLE = "TemporarilyUnavailable" - INVALID_PRODUCT_IDENTIFIER = "InvalidProductIdentifier" - OBSOLETE_PRODUCT = "ObsoleteProduct" - - -class PurchaseOrderStatus(str, enum.Enum): - OPEN = "OPEN" - CLOSED = "CLOSED" - - -class ItemConfirmationStatus(str, enum.Enum): - ACCEPTED = "ACCEPTED" - PARTIALLY_ACCEPTED = "PARTIALLY_ACCEPTED" - REJECTED = "REJECTED" - UNCONFIRMED = "UNCONFIRMED" - - -class ItemReceiveStatus(str, enum.Enum): - NOT_RECEIVED = "NOT_RECEIVED" - PARTIALLY_RECEIVED = "PARTIALLY_RECEIVED" - RECEIVED = "RECEIVED" - - -def namespace() -> Any: - """The generated models module for ``vendor_orders``.""" - import importlib - - return importlib.import_module("amzn_selling_partner.sdk.models.vendor_orders_v1") - - -def model(name: str) -> Any: - ns = namespace() - if name not in ns.__all__: - raise AttributeError(f"module 'amzn_selling_partner.vendor.orders' has no attribute {name!r}") - return getattr(ns, name) - - -def __getattr__(name: str) -> Any: - if name.startswith("_"): - raise AttributeError(name) - return model(name) diff --git a/src/amzn_selling_partner/_examples.py b/tests/_examples.py similarity index 96% rename from src/amzn_selling_partner/_examples.py rename to tests/_examples.py index f6d790f..4379e26 100644 --- a/src/amzn_selling_partner/_examples.py +++ b/tests/_examples.py @@ -1,7 +1,7 @@ """Minimal example values derived from raw (Swagger 2.0 / OpenAPI 3) schemas. -Used by the sandbox runner for operations that ship no example, and by the -test-suite to synthesise request arguments and response bodies. +Used by the sandbox runner (``tests/sandbox.py``) for operations that ship no +example. """ from __future__ import annotations diff --git a/src/amzn_selling_partner/plugins/_amazon/sandbox.py b/tests/_sandbox_examples.py similarity index 100% rename from src/amzn_selling_partner/plugins/_amazon/sandbox.py rename to tests/_sandbox_examples.py diff --git a/src/amzn_selling_partner/plugins/_amazon/specs.py b/tests/_specs.py similarity index 88% rename from src/amzn_selling_partner/plugins/_amazon/specs.py rename to tests/_specs.py index 72cfa58..6af25ac 100644 --- a/src/amzn_selling_partner/plugins/_amazon/specs.py +++ b/tests/_specs.py @@ -1,5 +1,5 @@ -"""Locating and naming the pinned Amazon model files (generator inputs; used -at run time only by the sandbox runner and the tests).""" +"""Locating and naming the pinned Amazon model files (generator inputs, read +by the sandbox runner and the tests).""" from __future__ import annotations @@ -7,12 +7,12 @@ import pathlib import re -from ..._naming import snake_case +from amzn_selling_partner._naming import snake_case _HERE = pathlib.Path(__file__).resolve().parent _VERSION_SUFFIX = re.compile(r"(?:[_-]|(?<=[a-z])V)(?P\d{4}-\d{2}-\d{2}|\d+)$") -# stem -> version for files whose stem carries no version (mirrors codegen/src/amazon.ts) +# stem -> version for files whose stem carries no version (mirrors UNVERSIONED in codegen/src/amazon.ts) UNVERSIONED = { "fbaInbound": "v1", "fbaInventory": "v1", @@ -54,7 +54,7 @@ def default_spec_dir() -> pathlib.Path: """The submodule ``models`` directory (or ``AMZN_SELLING_PARTNER_MODELS``).""" env = os.environ.get("AMZN_SELLING_PARTNER_MODELS") candidates = [pathlib.Path(env)] if env else [] - candidates.append(_HERE.parents[3] / "spec" / "selling-partner-api-models" / "models") + candidates.append(_HERE.parent / "spec" / "selling-partner-api-models" / "models") for c in candidates: if c.is_dir() and any(c.glob("*/*.json")): return c @@ -67,7 +67,7 @@ def default_spec_dir() -> pathlib.Path: def default_schema_dir() -> pathlib.Path: env = os.environ.get("AMZN_SELLING_PARTNER_SCHEMAS") candidates = [pathlib.Path(env)] if env else [] - candidates.append(_HERE.parents[3] / "spec" / "selling-partner-api-models" / "schemas") + candidates.append(_HERE.parent / "spec" / "selling-partner-api-models" / "schemas") for c in candidates: if (c / "notifications").is_dir(): return c diff --git a/src/amzn_selling_partner/sandbox_tests.py b/tests/sandbox.py similarity index 93% rename from src/amzn_selling_partner/sandbox_tests.py rename to tests/sandbox.py index c6f9346..54ae3d4 100644 --- a/src/amzn_selling_partner/sandbox_tests.py +++ b/tests/sandbox.py @@ -12,7 +12,7 @@ (``sdk.resources.OPERATIONS``) maps Amazon's operationIds to the methods. Usage (CLI):: - python -m amzn_selling_partner.sandbox_tests orders listings_items # or no args = all APIs + uv run python -m tests.sandbox orders listings_items # or no args = all APIs """ from __future__ import annotations @@ -21,6 +21,7 @@ import asyncio import inspect import json +import keyword import logging import pathlib import sys @@ -31,16 +32,26 @@ import httpx2 from pydantic import ValidationError +from amzn_selling_partner._naming import api_version_of, snake_case +from amzn_selling_partner.sdk.errors import APIStatusError +from amzn_selling_partner.sdk.resources import OPERATIONS + from ._examples import example_from_schema, resolve -from ._naming import api_version_of, param_name -from .plugins._amazon.sandbox import sandbox_examples -from .plugins._amazon.specs import api_naming, default_spec_dir, spec_files -from .sdk.errors import APIStatusError -from .sdk.resources import OPERATIONS +from ._sandbox_examples import sandbox_examples +from ._specs import api_naming, default_spec_dir, spec_files -log = logging.getLogger("amzn_selling_partner.sandbox_tests") +log = logging.getLogger("tests.sandbox") _METHODS = ("get", "put", "post", "delete", "patch", "head", "options") +_RESERVED = set(keyword.kwlist) | {"match", "case", "type", "self", "request_options", "body", "params", "headers"} + + +def param_name(wire_name: str) -> str: + """Keyword argument name of an operation parameter (mirrors ``paramName`` in ``codegen/src/python/naming.ts``).""" + s = snake_case(wire_name) + if s[:1].isdigit(): + s = "p" + s + return s + "_" if s in _RESERVED else s @dataclass(slots=True, kw_only=True) @@ -307,7 +318,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("-v", "--verbose", action="store_true") args = parser.parse_args(argv) logging.basicConfig(level=logging.DEBUG if args.verbose else logging.WARNING) - from .plugins.amazon_spapi import AsyncSellingPartner, SellingPartner + from amzn_selling_partner.plugins.amazon_spapi import AsyncSellingPartner, SellingPartner def sync_factory(transport: httpx2.MockTransport | None) -> Any: return SellingPartner(transport=transport, sandbox=True, throttle=False, max_retries=0, credentials=None) diff --git a/tests/test_amazon_plugin.py b/tests/test_amazon_plugin.py index 98892e6..912f8fe 100644 --- a/tests/test_amazon_plugin.py +++ b/tests/test_amazon_plugin.py @@ -23,20 +23,18 @@ Marketplace, Region, SellingPartner, - default_schema_dir, - default_spec_dir, - is_dynamic_sandbox, restricted_for, - sandbox_examples, unparsed_rate_limits, with_rdt, ) -from amzn_selling_partner.sandbox_tests import load_documents, raw_operations from amzn_selling_partner.sdk._http import REQUEST_ID_HEADER, RequestContext, TokenBucket from amzn_selling_partner.sdk.resources import OPERATIONS, SERVICES from ._amazon_mock import AmazonMock +from ._sandbox_examples import is_dynamic_sandbox, sandbox_examples +from ._specs import default_schema_dir, default_spec_dir from .conftest import requires_amazon +from .sandbox import load_documents, raw_operations pytestmark = requires_amazon @@ -448,7 +446,7 @@ def test_notification_models() -> None: assert type(parsed).__name__ == "OrderChangeNotification" assert parsed.payload.order_change_notification.amazon_order_id == payload["Payload"]["OrderChangeNotification"]["AmazonOrderId"] assert models.model("ListingsItemIssuesChangeNotification_2023-12-13").__name__ == "ListingsItemIssuesChangeNotification_2023_12_13" - # Known irregularities in the pinned schemas (see docs/PLAN.md): + # Known irregularities in the pinned schemas: # - ListingsItemStatusChangeNotification.json's own example says LISTINGS_ITEM_STATUS_CHANGE # while the schema enum says LISTINGS_ITEM_STATUS_CHANGED # - ShipmentTrackingMilestoneChangedNotification.json is a dangling "$ref": "#/definitions/Notification"; @@ -486,7 +484,7 @@ def test_sandbox_examples_exposed() -> None: @pytest.mark.parametrize("api", ["orders", "listings_items"]) def test_sandbox_runner(api: str) -> None: - from amzn_selling_partner import sandbox_tests + from . import sandbox def sync_factory(transport: httpx2.MockTransport | None) -> Any: return SellingPartner(transport=transport, sandbox=True, throttle=False, max_retries=0, credentials=None) @@ -494,7 +492,7 @@ def sync_factory(transport: httpx2.MockTransport | None) -> Any: def async_factory(transport: httpx2.MockTransport | None) -> Any: return AsyncSellingPartner(transport=transport, sandbox=True, throttle=False, max_retries=0, credentials=None) - outcomes = sandbox_tests.run(sync_factory, async_factory, [api]) + outcomes = sandbox.run(sync_factory, async_factory, [api]) failures = [f"{o.mode} {o.case.module}.{o.case.operation_id}[{o.case.status}]: {o.error}" for o in outcomes if not o.ok] ops = {o.case.operation_id for o in outcomes} expected = {op_id for (a, _v), doc in load_documents().items() if a == api for op_id in raw_operations(doc)} diff --git a/tests/test_client.py b/tests/test_client.py index cdfdd66..f55f7b0 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -15,8 +15,7 @@ from petstore_sdk.models import petstore_v3 as m from petstore_sdk.resources import OPERATIONS, SERVICES -from amzn_selling_partner import sandbox_tests - +from . import sandbox from .conftest import OAS31, SWAGGER2 BASE = "https://api.example.com" @@ -38,10 +37,10 @@ def test_every_operation(key: str, mode: str) -> None: module, _, operation_id = key.partition(".") method, http_method, *_rest = OPERATIONS[key] document = DOCS[module] - raw_op = sandbox_tests.raw_operations(document)[operation_id] + raw_op = sandbox.raw_operations(document)[operation_id] probe = Client(base_url=BASE) fn = getattr(getattr(probe, module), method) - cases = sandbox_tests.cases_for(fn, raw_op, document, module, operation_id, method) + cases = sandbox.cases_for(fn, raw_op, document, module, operation_id, method) assert cases, "no example could be derived" def factory(transport: httpx2.MockTransport | None) -> Any: @@ -49,7 +48,7 @@ def factory(transport: httpx2.MockTransport | None) -> Any: return cls(base_url=BASE, transport=transport, max_retries=0) for case in cases: - outcome = sandbox_tests.run_case(factory, case, http_method, mode=mode) + outcome = sandbox.run_case(factory, case, http_method, mode=mode) assert outcome.ok, outcome.error diff --git a/tests/test_compat.py b/tests/test_compat.py deleted file mode 100644 index df16244..0000000 --- a/tests/test_compat.py +++ /dev/null @@ -1,171 +0,0 @@ -"""The 0.1.x ``amzn_selling_partner`` entry points keep working on top of amzn_selling_partner.""" - -from __future__ import annotations - -import gzip -import json -import pathlib -import warnings - -import httpx2 -import pytest - -import amzn_selling_partner as sp -from amzn_selling_partner import client as compat_client - -from .conftest import requires_amazon - -pytestmark = requires_amazon - - -class VendorMock: - def __init__(self) -> None: - self.requests: list[httpx2.Request] = [] - - def __call__(self, request: httpx2.Request) -> httpx2.Response: - self.requests.append(request) - path = request.url.path - if path.endswith("/auth/o2/token"): - return httpx2.Response(200, json={"access_token": "tok", "expires_in": 3600}) - if path == "/vendor/orders/v1/purchaseOrders": - if request.url.params.get("nextToken") == "n2": - return httpx2.Response(200, json={"payload": {"orders": [{"purchaseOrderNumber": "PO-2", "purchaseOrderState": "Closed"}]}}) - return httpx2.Response( - 200, - json={ - "payload": {"pagination": {"nextToken": "n2"}, "orders": [{"purchaseOrderNumber": "PO-1", "purchaseOrderState": "New"}]} - }, - ) - if path == "/vendor/orders/v1/purchaseOrders/PO-1": - return httpx2.Response( - 200, - json={ - "payload": { - "purchaseOrderNumber": "PO-1", - "purchaseOrderState": "New", - "orderDetails": { - "purchaseOrderDate": "2020-01-01T00:00:00Z", - "purchaseOrderStateChangedDate": "2020-01-01T00:00:00Z", - "purchaseOrderType": "RegularOrder", - "items": [], - }, - } - }, - ) - if path == "/reports/2021-06-30/reports" and request.method == "POST": - return httpx2.Response(202, json={"reportId": "R1"}) - if path == "/reports/2021-06-30/reports": - token = request.url.params.get("nextToken") - n = int(token[1:]) if token else 1 - return httpx2.Response( - 200, - json={ - "reports": [ - { - "reportId": f"R{n}", - "reportType": "GET_VENDOR_SALES_REPORT", - "createdTime": "2020-01-01T00:00:00Z", - "processingStatus": "DONE", - } - ], - "nextToken": f"n{n + 1}", - }, - ) - if path == "/reports/2021-06-30/reports/R1": - return httpx2.Response( - 200, - json={ - "reportId": "R1", - "reportType": "GET_VENDOR_SALES_REPORT", - "createdTime": "2020-01-01T00:00:00Z", - "processingStatus": "DONE", - "reportDocumentId": "D1", - }, - ) - if path == "/reports/2021-06-30/documents/D1": - return httpx2.Response(200, json={"reportDocumentId": "D1", "url": "https://s3.example/d1.gz", "compressionAlgorithm": "GZIP"}) - if path == "/d1.gz": - return httpx2.Response(200, content=gzip.compress(json.dumps({"salesByAsin": []}).encode())) - return httpx2.Response(404, json={"errors": [{"code": "NotFound", "message": path}]}) - - -def _kw(mock: VendorMock) -> dict[str, object]: - return { - "transport": httpx2.MockTransport(mock), - "throttle": False, - "selling_partner_app_client_id": "id", - "selling_partner_app_client_secret": "sec", - "selling_partner_app_refresh_token": "rt", - } - - -def test_region_and_base_client_endpoints() -> None: - assert sp.client.SellingPartnerRegion.NORTH_AMERICA.api_endpoint == "https://sellingpartnerapi-na.amazon.com" - assert sp.client.SellingPartnerRegion.EUROPE.api_sandbox_endpoint == "https://sandbox.sellingpartnerapi-eu.amazon.com" - assert sp.client.SellingPartnerRegion.FAR_EAST.region_name == "us-west-2" - base = compat_client.BaseClient(transport=httpx2.MockTransport(VendorMock())) - assert base.get_api_endpoint() == "https://sellingpartnerapi-na.amazon.com" - with pytest.raises(NotImplementedError): - base.get_resource_endpoint() - sandbox = compat_client.BaseClient(sandbox=True, transport=httpx2.MockTransport(VendorMock())) - assert sandbox.get_api_endpoint().startswith("https://sandbox.") - - -def test_aws_arguments_are_ignored_with_a_warning() -> None: - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - compat_client.BaseClient(aws_access_key_id="AKIA", aws_secret_access_key="x", transport=httpx2.MockTransport(VendorMock())) - assert any(issubclass(w.category, DeprecationWarning) for w in caught) - with pytest.raises(NotImplementedError): - sp.client.auth.ClientSessionAuth() - - -def test_vendor_orders_client() -> None: - mock = VendorMock() - client = sp.vendor.orders.Client(**_kw(mock)) - assert client.get_resource_path() == "vendor/orders/v1" - assert client.get_operation_endpoint("purchaseOrders") == "https://sellingpartnerapi-na.amazon.com/vendor/orders/v1/purchaseOrders" - orders = client.get_purchase_orders( - query=sp.vendor.orders.GetPurchaseOrdersQuery(createdAfter="2020-01-01T00:00:00Z", sortOrder=sp.vendor.orders.SortOrder.ASCENDING) - ) - assert [o.purchase_order_number for o in orders] == ["PO-1", "PO-2"] # auto-paged - first = [r for r in mock.requests if r.url.path.endswith("/purchaseOrders")][0] - assert first.url.params["createdAfter"] == "2020-01-01T00:00:00Z" and first.url.params["sortOrder"] == "ASC" - order = client.get_purchase_order("PO-1") - assert order.purchase_order_number == "PO-1" and order.order_details.purchase_order_type == "RegularOrder" - assert isinstance(order, sp.vendor.orders.Order) - assert sp.vendor.orders.PurchaseOrderState.CLOSED == "Closed" - with pytest.raises(ValueError): - client.get_purchase_order("") - assert mock.requests[0].url.path.endswith("/auth/o2/token") - assert [r for r in mock.requests if r.url.path.endswith("/purchaseOrders")][0].headers["x-amz-access-token"] == "tok" - - -def test_reports_client(tmp_path: pathlib.Path) -> None: - mock = VendorMock() - client = sp.reports.Client(**_kw(mock)) - report = client.create_report( - sp.reports.CreateReportSpecification( - reportType=sp.reports.ReportType.VENDOR_SALES_REPORT, marketplaceIds=[sp.reports.MarketPlaceId.UNITED_STATES_OF_AMERICA] - ) - ) - assert report.report_id == "R1" and report.report_document_id == "D1" - create = [r for r in mock.requests if r.method == "POST" and r.url.path.endswith("/reports")][0] - assert json.loads(create.content) == {"reportType": "GET_VENDOR_SALES_REPORT", "marketplaceIds": ["ATVPDKIKX0DER"]} - reports = client.get_reports(query=sp.reports.GetReportsQuery(reportTypes=[sp.reports.ReportType.VENDOR_SALES_REPORT]), pages_limit=2) - assert [r.report_id for r in reports] == ["R1", "R2"] - doc = client.get_report_document("D1") - assert doc.url.endswith("/d1.gz") and doc.compression_algorithm == "GZIP" - assert client.get_report_document_content("D1") == {"salesByAsin": []} - target = tmp_path / "out.json" - client.download_report_document_content("D1", str(target)) - assert json.loads(target.read_bytes()) == {"salesByAsin": []} - with pytest.raises(ValueError): - client.get_report("") - assert sp.reports.Report is sp.reports.models.namespace().Report - assert isinstance(client.get_report("R1"), sp.reports.Report) - - -def test_utils_kept() -> None: - assert sp.utils.date.amazon_isoformat(sp.utils.date.datetime_utcnow()).endswith("Z") - assert callable(sp.utils.file.write_binary_file)