feat: cross-SDK contract v1 parity pass (1.4.0) - #7
Merged
Conversation
Brings the Python SDK into compliance with the shared Python/TS/Go behavior
contract (docs/sdk-behavior-contract.md, contracts/sdk-contract.json in the
awsys-shortener repo). No breaking changes.
Fixes 8 real, previously-undetected bugs found via a new fixture-driven
contract test suite (tests/test_contract.py, tests/contracts/sdk-contract.json):
- analytics.get_recent_clicks() called a path that never existed
(/api/user/recent-clicks -> /api/user/clicks/recent)
- folders.update() called a /api/v1 path that 404s (platform has no v1 alias)
- tags.add() sent {"tag": "..."} instead of the required {"tags": [...]}
- webhooks list/create/delete/test used non-canonical unversioned paths
- TrustScoreResult.score/.status were always None (wrong wire field names)
- ProfileResource.update() sent snake_case keys instead of camelCase
- Link model dropped fullPath/namespace into unqueryable extras
- tests/conftest.py's pytest_runtest_call wasn't a hookwrapper, silently
running every test twice (including live calls against staging)
Adds: profile resource, import redirect-map downloads, links.list_all()
pagination iterator, a full error hierarchy (ServerError/NetworkError/
TimeoutError/ConfigurationError), env-var config with validation and
redaction, a shared retry/backoff engine (429/5xx/transport, full jitter,
Retry-After incl. capping and HTTP-date parsing, quota-class no-retry),
Firestore-timestamp tolerance, CI (ruff/mypy/pytest matrix 3.9-3.13),
a contract-drift-detection workflow, and a full documentation pass
(README, CHANGELOG, SECURITY-REVIEW, LICENSE).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BLo12Xi5cjtEA5HiNb3y8
| f"base_url must start with 'http://' or 'https://', got {base_url!r}." | ||
| ) | ||
| if normalized.startswith("http://") and not _warned_http_base_url: | ||
| _warned_http_base_url = True |
| "environment variable." | ||
| ) | ||
| if not resolved.startswith("awsys_") and not _warned_non_awsys_key: | ||
| _warned_non_awsys_key = True |
|
|
||
| import pytest | ||
|
|
||
| import awsysco |
CI installs the latest ruff via `pip install -e .[dev]` (no version pin was set). ruff 0.16 changed its bare-default rule selection to include I001 (import sorting) where 0.15 didn't, so the PR's first CI run failed with 545 errors despite `ruff check .` passing locally against the older, already-installed 0.15.12. Pin `[tool.ruff.lint] select` explicitly to the classic default (E4/E7/E9/F) so behavior can't drift with future ruff releases, and add a version range to the dev dependency for good measure. Verified clean against both ruff 0.15.12 and 0.16.6. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014BLo12Xi5cjtEA5HiNb3y8
…k, pagination) Fixes every High/Medium finding from an independent review of this PR, plus the discretionary Low/Info items: HIGH - Retry-After capping (raise immediately, never sleep, when the value exceeds 30s or is non-finite) previously only applied to 429s — a retryable 5xx with an oversized Retry-After slept for the full uncapped duration instead. Also fixed: "nan" was silently clamped to 0.0 before the excessiveness check ever saw it, causing an instant-sleep-and-retry instead of an immediate raise. - Webhook.secret leaked via str()/f-strings — a prior __repr__ override didn't cover pydantic's independently-generated __str__. Fixed via Field(repr=False) + __str__ = __repr__. - The Firestore-timestamp validator could itself raise (non-numeric nanoseconds hit an uncaught TypeError) or leave a raw dict in a str field on conversion failure (crashing downstream instead of the validator itself). Both fixed; the "never raise" guarantee now actually holds. MEDIUM - LinkList.has_more was always None — the platform nests pagination under pagination.hasMore, not top-level. This silently broke links.list_all()'s primary stop condition (it only worked by accident via the length-based fallback). Fixed with a before-validator hoisting pagination.* up. - links.list_all(limit=0) (or negative) could loop forever — min(limit, 100) had no lower bound. Clamped to >=1. - pyproject.toml and awsysco/_version.py each held their own copy of the version, requiring manual sync. Now single-sourced via hatchling's [tool.hatch.version] reading _version.py; publish.yml's tag-check updated to match. LOW / INFO (discretionary) - qr.get_url()'s default bg_color aligned to lowercase "ffffff", matching the platform's own convention. - mypy python_version documented as pinned to 3.10 (not 3.9, matching requires-python) with the reason: checking as 3.9 makes mypy follow into a transitive dependency's own source and fail on a 3.10+ match-statement there — a false positive unrelated to this SDK's own code. - Full retry-loop consolidation (get/get_text x sync/async sharing one implementation) deferred — the correctness-relevant duplication (the Retry-After cap) is now fixed consistently across all four call sites via shared _transport.py helpers; the remaining structural duplication is a larger, riskier refactor for a marginal further DRY improvement. Also vendors sdk-contract.json 1.0.8 (adds err_503_retry_after_oversized, iterator_links_limit_zero, redaction_str, timestamp_never_raises, links_list_has_more_from_pagination — all now covered) and adds resource/ transport str()-formatting redaction tests per the follow-up contract note. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014BLo12Xi5cjtEA5HiNb3y8
Per awsys-orch sign-off on PR #7: record the deferred full structural consolidation of the sync/async retry loops as tracked debt now that the correctness-relevant duplication (the Retry-After cap) has been eliminated via shared _transport.py helpers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014BLo12Xi5cjtEA5HiNb3y8
3 tasks
pbertsch
added a commit
that referenced
this pull request
Sep 9, 2026
…oute (#8) * fix: utm_templates.list() now uses the real GET /api/user/utm-templates route Follow-up to #7 / ADR-020: the platform never actually populated utmTemplates on GET /api/v1/me (ADR-003 was based on incorrect information — there was no dedicated list route at the time). #833 added a real GET /api/user/utm-templates route returning {templates:[...]}; list() now calls it instead of silently returning [] via a field that never existed. create() already sent the correct source/medium/campaign body fields, no change needed there — the 500 it used to get back (#831) was a server-side bug (undefined uuidv4), not a client-side wire-format mismatch. Vendors sdk-contract.json 1.0.10 (utm_list_via_me renamed to utm_list to match; utm_create's expected body corrected from utmSource/utmMedium/ utmCampaign to plain source/medium/campaign, matching what the SDK already sends). Adds previously-missing async test coverage for this resource. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014BLo12Xi5cjtEA5HiNb3y8 * fix: correct model field mappings found in a platform re-audit (ADR-022) Fixes wrong/missing fields in 4 typed models, found by re-verifying the fixtures they were originally built from against live staging response bodies. extra="allow" meant a wrong alias silently yielded None rather than erroring, so none of this surfaced as a test failure until the re-audit. - TrustScoreResult: added source, created_at (raw epoch-ms int on this endpoint specifically — own field validator, not the shared Firestore-dict coercion). short was already correct. - NamespaceInfo: added can_claim_custom_domain, can_claim_subdomain, namespace_data (the real fields) — upgrade_required is never actually sent. - AggregateAnalytics: added bot_clicks_excluded (the only field that was actually missing; the rest — clicks_by_day, country_breakdown, etc. — was already correctly named). - Link: added geo_restriction, og_meta, is_custom, is_disabled, disabled_reason, trust_score, trust_status, threats. All additive (no field renamed or removed) — no breaking changes. affiliate.get_limits()/custom_domains.add()/webhooks.list_event_types() return raw dicts by design; confirmed no wrong-alias risk there and left them alone rather than change their return type to a typed model (which would be breaking). Vendors sdk-contract.json 1.0.11 (ADR-022 fixture corrections). Adds platform-verified-shape tests for all four models fixed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014BLo12Xi5cjtEA5HiNb3y8 * fix: AffiliateProgram field mapping (ADR-024 re-audit) cookie_days (kept as the field/kwarg name for backward compatibility) was reading and writing the wrong wire key: cookieDays instead of the platform's actual cookieDurationDays. Wrong on both sides — create_program()/ update_program()'s request body, and the response model. Also adds merchant_id, max_partners, partner_count, is_public, created_at, updated_at (present on the owned-program endpoints; discover()'s public-summary response is a subset, tolerated since every field stays Optional). list_partners()/list_partnerships()/join()/get_partnership_stats() return raw dicts by design (confirmed against the fixture, same reasoning as get_limits()/custom_domains.add()/webhooks.list_event_types() from the prior audit) — no change needed there. Open question flagged to awsys-orch rather than guessed at: the platform's create_program request fixture shows a single `commissionRate` field where this SDK sends commissionType/cpcRate/cpaRate — left unchanged pending confirmation, since restructuring that guess-first risked breaking a currently-working (if unverified) code path in a different way. Vendors sdk-contract.json 1.0.12. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014BLo12Xi5cjtEA5HiNb3y8 --------- Co-authored-by: pbertsch <alphawavesystems@gmail.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Brings this SDK into compliance with the shared Python/TS/Go behavior contract
(
docs/sdk-behavior-contract.md+contracts/sdk-contract.jsonv1.1/1.0.6 inthe
awsys-shortenerrepo), coordinated withawsys-orchacross all threeSDKs. No breaking changes — release itself stays gated separately (no tag,
no PyPI publish from this PR).
8 real, previously-undetected bugs found and fixed, all via a new
fixture-driven contract test suite (
tests/test_contract.py+tests/contracts/sdk-contract.json, parametrized over every capability/error/behavior scenario — a scenario with no registered handler fails the build
rather than silently passing):
analytics.get_recent_clicks()called/api/user/recent-clicks, a paththat never existed on the platform (always 404'd) → fixed to
/api/user/clicks/recent, addedsinceparam.folders.update()calledPATCH /api/v1/folders/:id, which 404s — theplatform only exposes this route unversioned → fixed to
/api/folders/:id.tags.add()sent{"tag": "..."}(singular) — platform requires{"tags": [...]}(array) → every call previously failed server-side.webhookslist/create/delete/testused non-canonical unversionedpaths → fixed to
/api/v1/webhooks/*(updatecorrectly stays unversioned).TrustScoreResult.score/.statuswere alwaysNone— platform sendstrustScore/trustStatus→ fixed via field alias (no public rename).ProfileResource.update(**kwargs)sent raw snake_case keys on the wireinstead of camelCase → fields silently no-opped server-side.
Linkmodel had nofull_path/namespacefields — namespaced-linkresponses silently dropped these into unqueryable extras.
tests/conftest.py'spytest_runtest_callwasn't a proper hookwrapper,so every test in the suite silently ran twice (confirmed empirically) —
including live calls against staging, which explains some of the
rate-limit exhaustion seen during this work. Fixed to a real hookwrapper.
What else changed
.profileresource,imports.get_redirect_map_csv/json,links.list_all()auto-pagination (sync + async generator).AwsysServerError,AwsysNetworkError,AwsysTimeoutError,AwsysConfigurationError;AwsysRateLimitErrorgains.code/.resets_at. 422 now maps toAwsysValidationError. A non-JSON 2xxbody raises a typed error instead of a raw
JSONDecodeError.awsysco/_transport.py) used by both sync andasync transports (previously duplicated): 429 retried for all methods
except quota-exhaustion codes; 502/503/504/transport errors retried only
for idempotent methods; full jitter;
Retry-After(seconds or HTTP-date)respected and capped at 30s (larger/non-finite → raise immediately);
asyncio.CancelledErrorpasses through unmodified.AWSYS_API_KEY/AWSYS_BASE_URLenv fallback,base_urlschemevalidation, one-shot warnings for a non-
awsys_key or plain-httpURL,redacted
repr()onClient/AsyncClient/transports, correctversion-derived User-Agent, per-call
timeout=.{_seconds,_nanoseconds}→ ISO-8601 string,never crashes on a bad shape; fields stay
str-typed — nativedatetimeis deferred to 2.0 per cross-SDK ADR-017).
Webhook.secret/.success_countfields;Webhook.__repr__redactssecret.CustomDomain.default_redirect+custom_domains.update(default_redirect=...).custom_domains.activate()deprecated (Firebase-only, unreachable with anAPI key) — raises immediately with a
DeprecationWarning, no network call..github/workflows/ci.yml(ruff/mypy/pytest matrix Python 3.9–3.13,integration gated on the
AWSYS_API_KEYsecret),publish.ymlnow gateson tests passing and fails if the pushed tag doesn't match
v<version>..github/workflows/contract-drift.yml: weekly +repository_dispatchdrift check against the platform's live contract (files an
sdk-parityissue on drift), plus a nightly staging integration run.
config, async/retry/timeout),
CHANGELOG.md,SECURITY-REVIEW.md, and aLICENSEfile (referenced bypyproject.toml/README but previouslymissing from the repo).
Test plan
pytest -q -m "not integration"— 378 passed, 1 skipped, 0 networkcalls (this is what CI's unit+contract job runs)
pytest -q(full suite incl. live staging) — 389 passed before hittingthe account's hourly quota (50/h) partway through a second consecutive
full run in this session; all failures are
AwsysRateLimitError(environmental), not code failures
ruff check .— cleanmypy awsysco— clean (viauvx --with httpx --with pydantic mypy awsysco)python -m build && pip install dist/*.whlin a fresh venv →import awsysco; awsysco.__version__ == "1.4.0"confirmedpip-auditagainst installedhttpx/pydantic— no known vulnerabilities(webhook paths,
folders.update, recent-clicks) during this workCoordination
Developed in lockstep with
awsys-orch(cross-SDK parity orchestrator) andthe TS/Go SDK sessions — see inline commit history for the back-and-forth on
several fixture corrections (webhook field name, import body casing) that
were caught and reverted before landing here.
Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com
🤖 Generated with Claude Code
https://claude.ai/code/session_014BLo12Xi5cjtEA5HiNb3y8