Skip to content

feat: cross-SDK contract v1 parity pass (1.4.0) - #7

Merged
pbertsch merged 4 commits into
mainfrom
feat/contract-v1-parity
Sep 7, 2026
Merged

feat: cross-SDK contract v1 parity pass (1.4.0)#7
pbertsch merged 4 commits into
mainfrom
feat/contract-v1-parity

Conversation

@pbertsch

@pbertsch pbertsch commented Sep 7, 2026

Copy link
Copy Markdown
Member

Summary

Brings this SDK into compliance with the shared Python/TS/Go behavior contract
(docs/sdk-behavior-contract.md + contracts/sdk-contract.json v1.1/1.0.6 in
the awsys-shortener repo), coordinated with awsys-orch across all three
SDKs. 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 path
    that never existed on the platform (always 404'd) → fixed to
    /api/user/clicks/recent, added since param.
  • folders.update() called PATCH /api/v1/folders/:id, which 404s — the
    platform 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.
  • webhooks list/create/delete/test used non-canonical unversioned
    paths → fixed to /api/v1/webhooks/* (update correctly stays unversioned).
  • TrustScoreResult.score/.status were always None — platform sends
    trustScore/trustStatus → fixed via field alias (no public rename).
  • ProfileResource.update(**kwargs) sent raw snake_case keys on the wire
    instead of camelCase → fields silently no-opped server-side.
  • Link model had no full_path/namespace fields — namespaced-link
    responses silently dropped these into unqueryable extras.
  • tests/conftest.py's pytest_runtest_call wasn'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

  • New .profile resource, imports.get_redirect_map_csv/json,
    links.list_all() auto-pagination (sync + async generator).
  • Full error hierarchy: AwsysServerError, AwsysNetworkError,
    AwsysTimeoutError, AwsysConfigurationError; AwsysRateLimitError gains
    .code/.resets_at. 422 now maps to AwsysValidationError. A non-JSON 2xx
    body raises a typed error instead of a raw JSONDecodeError.
  • Shared retry/backoff engine (awsysco/_transport.py) used by both sync and
    async 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.CancelledError passes through unmodified.
  • Config: AWSYS_API_KEY/AWSYS_BASE_URL env fallback, base_url scheme
    validation, one-shot warnings for a non-awsys_ key or plain-http URL,
    redacted repr() on Client/AsyncClient/transports, correct
    version-derived User-Agent, per-call timeout=.
  • Firestore-timestamp tolerance ({_seconds,_nanoseconds} → ISO-8601 string,
    never crashes on a bad shape; fields stay str-typed — native datetime
    is deferred to 2.0 per cross-SDK ADR-017).
  • Webhook.secret/.success_count fields; Webhook.__repr__ redacts secret.
  • CustomDomain.default_redirect + custom_domains.update(default_redirect=...).
  • custom_domains.activate() deprecated (Firebase-only, unreachable with an
    API key) — raises immediately with a DeprecationWarning, no network call.
  • CI: .github/workflows/ci.yml (ruff/mypy/pytest matrix Python 3.9–3.13,
    integration gated on the AWSYS_API_KEY secret), publish.yml now gates
    on tests passing and fails if the pushed tag doesn't match v<version>.
  • .github/workflows/contract-drift.yml: weekly + repository_dispatch
    drift check against the platform's live contract (files an sdk-parity
    issue on drift), plus a nightly staging integration run.
  • Full docs pass: README rewritten (all 20 resources, pagination, errors,
    config, async/retry/timeout), CHANGELOG.md, SECURITY-REVIEW.md, and a
    LICENSE file (referenced by pyproject.toml/README but previously
    missing from the repo).

Test plan

  • pytest -q -m "not integration" — 378 passed, 1 skipped, 0 network
    calls (this is what CI's unit+contract job runs)
  • pytest -q (full suite incl. live staging) — 389 passed before hitting
    the 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 . — clean
  • mypy awsysco — clean (via uvx --with httpx --with pydantic mypy awsysco)
  • python -m build && pip install dist/*.whl in a fresh venv →
    import awsysco; awsysco.__version__ == "1.4.0" confirmed
  • pip-audit against installed httpx/pydantic — no known vulnerabilities
  • Live re-verification of every fixed bug against redeployed staging
    (webhook paths, folders.update, recent-clicks) during this work

Coordination

Developed in lockstep with awsys-orch (cross-SDK parity orchestrator) and
the 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

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
Comment thread awsysco/_http.py
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
Comment thread awsysco/client.py
"environment variable."
)
if not resolved.startswith("awsys_") and not _warned_non_awsys_key:
_warned_non_awsys_key = True
Comment thread tests/test_config.py

import pytest

import awsysco
pbertsch and others added 3 commits September 7, 2026 07:51
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
@pbertsch
pbertsch merged commit 076e68d into main Sep 7, 2026
15 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant