From aeb1c97406f5b123485c4613addc5650f000ef23 Mon Sep 17 00:00:00 2001 From: pbertsch Date: Mon, 7 Sep 2026 07:49:20 -0600 Subject: [PATCH 1/4] feat: cross-SDK contract v1 parity pass (1.4.0) 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 Claude-Session: https://claude.ai/code/session_014BLo12Xi5cjtEA5HiNb3y8 --- .github/workflows/ci.yml | 53 + .github/workflows/contract-drift.yml | 112 ++ .github/workflows/publish.yml | 28 + CHANGELOG.md | 123 ++ LICENSE | 21 + README.md | 467 ++--- SECURITY-REVIEW.md | 79 + awsysco/__init__.py | 15 +- awsysco/_async_http.py | 232 ++- awsysco/_http.py | 270 ++- awsysco/_transport.py | 151 ++ awsysco/_version.py | 8 + awsysco/async_resources/analytics.py | 18 +- awsysco/async_resources/bulk.py | 43 +- awsysco/async_resources/custom_domains.py | 29 +- awsysco/async_resources/folders.py | 4 +- awsysco/async_resources/imports.py | 29 +- awsysco/async_resources/links.py | 61 +- awsysco/async_resources/profile.py | 45 + awsysco/async_resources/qr.py | 2 +- awsysco/async_resources/tags.py | 2 +- awsysco/async_resources/webhooks.py | 9 +- awsysco/client.py | 90 +- awsysco/exceptions.py | 46 + awsysco/models.py | 88 +- awsysco/resources/analytics.py | 23 +- awsysco/resources/bulk.py | 38 +- awsysco/resources/custom_domains.py | 31 +- awsysco/resources/folders.py | 4 +- awsysco/resources/imports.py | 38 +- awsysco/resources/links.py | 90 +- awsysco/resources/profile.py | 45 + awsysco/resources/qr.py | 2 +- awsysco/resources/tags.py | 2 +- awsysco/resources/webhooks.py | 13 +- examples/async_usage.py | 2 +- examples/basic_usage.py | 2 +- examples/check_syntax.sh | 13 + examples/integration_test.py | 4 +- pyproject.toml | 23 +- tests/conftest.py | 32 +- tests/contracts/sdk-contract.json | 2102 +++++++++++++++++++++ tests/test_affiliate.py | 1 - tests/test_agentlink.py | 1 - tests/test_analytics.py | 19 +- tests/test_async_client.py | 1 - tests/test_config.py | 142 ++ tests/test_contract.py | 877 +++++++++ tests/test_custom_domains.py | 18 +- tests/test_data_export.py | 1 - tests/test_folders.py | 17 +- tests/test_imports.py | 54 +- tests/test_links.py | 67 +- tests/test_models.py | 44 + tests/test_namespace.py | 1 - tests/test_profile.py | 77 + tests/test_qr.py | 1 - tests/test_saved_views.py | 1 - tests/test_tags.py | 10 +- tests/test_transport.py | 409 ++++ tests/test_trust_score.py | 1 - tests/test_utm_templates.py | 1 - tests/test_webhooks.py | 34 +- 63 files changed, 5678 insertions(+), 588 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/contract-drift.yml create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 SECURITY-REVIEW.md create mode 100644 awsysco/_transport.py create mode 100644 awsysco/_version.py create mode 100644 awsysco/async_resources/profile.py create mode 100644 awsysco/resources/profile.py create mode 100755 examples/check_syntax.sh create mode 100644 tests/contracts/sdk-contract.json create mode 100644 tests/test_config.py create mode 100644 tests/test_contract.py create mode 100644 tests/test_models.py create mode 100644 tests/test_profile.py create mode 100644 tests/test_transport.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..584ca08 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + +jobs: + lint-and-unit: + name: Lint, typecheck, unit + contract tests (no network) + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install package with dev dependencies + run: pip install -e .[dev] + - name: ruff + run: ruff check . + - name: mypy + run: mypy awsysco + - name: pytest (unit + contract, no network) + run: pytest -q -m "not integration" + - name: examples compile + run: bash examples/check_syntax.sh + + integration: + name: Staging integration tests + runs-on: ubuntu-latest + needs: lint-and-unit + if: ${{ github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'push' }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install package with dev dependencies + run: pip install -e .[dev] + - name: pytest (integration, requires AWSYS_API_KEY) + env: + AWSYS_API_KEY: ${{ secrets.AWSYS_API_KEY }} + AWSYS_BASE_URL: ${{ vars.AWSYS_BASE_URL || 'https://staging.awsys.co' }} + run: | + if [ -z "$AWSYS_API_KEY" ]; then + echo "AWSYS_API_KEY secret not set — skipping integration tests." + exit 0 + fi + pytest -q -m integration diff --git a/.github/workflows/contract-drift.yml b/.github/workflows/contract-drift.yml new file mode 100644 index 0000000..fa34491 --- /dev/null +++ b/.github/workflows/contract-drift.yml @@ -0,0 +1,112 @@ +name: Contract Drift Check + +on: + repository_dispatch: + types: [api-contract-changed] + schedule: + # Weekly, Monday 09:00 UTC — catches drift even if a dispatch is ever missed. + - cron: "0 9 * * 1" + +permissions: + issues: write + contents: read + +jobs: + check-drift: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install package with dev dependencies + run: pip install -e .[dev] + + - name: Fetch latest platform contract + run: | + curl -sSL -o /tmp/latest-contract.json \ + https://raw.githubusercontent.com/AlphaWaveSystems/awsys-shortener/master/contracts/sdk-contract.json + + - name: Compare version and diff + id: diff + run: | + VENDORED_VERSION=$(python3 -c "import json; print(json.load(open('tests/contracts/sdk-contract.json'))['version'])") + LATEST_VERSION=$(python3 -c "import json; print(json.load(open('/tmp/latest-contract.json'))['version'])") + echo "vendored_version=$VENDORED_VERSION" >> "$GITHUB_OUTPUT" + echo "latest_version=$LATEST_VERSION" >> "$GITHUB_OUTPUT" + if [ "$VENDORED_VERSION" != "$LATEST_VERSION" ]; then + echo "version_mismatch=true" >> "$GITHUB_OUTPUT" + else + echo "version_mismatch=false" >> "$GITHUB_OUTPUT" + fi + + - name: Run contract-fixture suite against the fetched contract + id: contract_test + continue-on-error: true + run: | + cp /tmp/latest-contract.json /tmp/sdk-contract-under-test.json + cp tests/contracts/sdk-contract.json /tmp/vendored-backup.json + cp /tmp/latest-contract.json tests/contracts/sdk-contract.json + pytest -q tests/test_contract.py + TEST_EXIT=$? + cp /tmp/vendored-backup.json tests/contracts/sdk-contract.json + exit $TEST_EXIT + + - name: Report drift as a GitHub issue + if: steps.diff.outputs.version_mismatch == 'true' || steps.contract_test.outcome == 'failure' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + LATEST_VERSION: ${{ steps.diff.outputs.latest_version }} + VENDORED_VERSION: ${{ steps.diff.outputs.vendored_version }} + TEST_OUTCOME: ${{ steps.contract_test.outcome }} + # client_payload is attacker-controllable on a public repository_dispatch + # trigger — only ever written into the issue body as inert, quoted text + # via this env var, never interpolated into a shell command. + DISPATCH_SOURCE: ${{ github.event.client_payload.source }} + run: | + gh label create sdk-parity --color 5319E7 --description "Cross-SDK contract parity drift" 2>/dev/null || true + + TITLE="contract drift: sdk-contract $LATEST_VERSION" + EXISTING=$(gh issue list --search "in:title \"$TITLE\"" --state open --json number --jq '.[0].number' || true) + + BODY_FILE=$(mktemp) + { + echo "Vendored contract version: \`$VENDORED_VERSION\`" + echo "Latest platform contract version: \`$LATEST_VERSION\`" + echo "Contract-fixture suite against the latest contract: \`$TEST_OUTCOME\`" + echo "Triggered by: \`${DISPATCH_SOURCE:-scheduled check}\`" + echo "" + echo "Re-vendor with: \`cp /contracts/sdk-contract.json tests/contracts/sdk-contract.json\`, then \`pytest tests/test_contract.py\` and fix any failures." + } > "$BODY_FILE" + + if [ -n "$EXISTING" ]; then + gh issue comment "$EXISTING" --body-file "$BODY_FILE" + else + gh issue create --title "$TITLE" --label sdk-parity --body-file "$BODY_FILE" + fi + + nightly-staging: + name: Nightly staging integration suite + runs-on: ubuntu-latest + if: github.event_name == 'schedule' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install package with dev dependencies + run: pip install -e .[dev] + - name: pytest (integration, requires AWSYS_API_KEY) + env: + AWSYS_API_KEY: ${{ secrets.AWSYS_API_KEY }} + AWSYS_BASE_URL: ${{ vars.AWSYS_BASE_URL || 'https://staging.awsys.co' }} + run: | + if [ -z "$AWSYS_API_KEY" ]; then + echo "AWSYS_API_KEY secret not set — skipping. Note: this key rots whenever" + echo "the staging test user is deleted; see docs/STAGING-TEST-ACCOUNTS.md in" + echo "the awsys-shortener repo for how to provision a fresh one." + exit 0 + fi + pytest -q -m integration diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index faea4ff..529052f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,8 +6,26 @@ on: - 'v*' jobs: + test: + name: Unit + contract tests (gate before publish) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install package with dev dependencies + run: pip install -e .[dev] + - name: ruff + run: ruff check . + - name: mypy + run: mypy awsysco + - name: pytest (unit + contract, no network) + run: pytest -q -m "not integration" + publish: runs-on: ubuntu-latest + needs: test environment: pypi permissions: id-token: write @@ -16,6 +34,16 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.11' + - name: Verify tag matches package version + env: + TAG_NAME: ${{ github.ref_name }} + run: | + PKG_VERSION=$(python3 -c "import re; print(re.search(r'(?m)^version\s*=\s*\"([^\"]+)\"', open('pyproject.toml').read()).group(1))") + if [ "$TAG_NAME" != "v$PKG_VERSION" ]; then + echo "Tag '$TAG_NAME' does not match package version 'v$PKG_VERSION' — refusing to publish." + exit 1 + fi + echo "Tag '$TAG_NAME' matches package version — proceeding." - name: Install build tools run: pip install build - name: Build package diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..179a948 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,123 @@ +# Changelog + +All notable changes to this project are documented in this file. Format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows +[Semantic Versioning](https://semver.org/). + +## [Unreleased] — 1.4.0 + +Cross-SDK contract-parity pass (Python/TS/Go behavior contract v1.0). No breaking +changes — see below. + +### Added +- `client.profile` resource: `get()` / `update(**kwargs)` against `/api/user/profile`. +- `client.imports.get_redirect_map_csv(job_id)` / `get_redirect_map_json(job_id)`. +- `client.imports.start(..., scope_filter=None)` parameter. +- `client.links.list_all()` (sync generator) / `AsyncClient.links.list_all()` (async + generator) — auto-paginating iterator over every link. +- `AwsysServerError` (5xx), `AwsysNetworkError` (transport failure), `AwsysTimeoutError` + (subclass of `AwsysNetworkError`), `AwsysConfigurationError` (bad config, raised + before any network call). +- `AwsysRateLimitError.code` and `.resets_at`. +- `Client`/`AsyncClient` now accept `api_key`/`base_url` as optional, falling back to + the `AWSYS_API_KEY`/`AWSYS_BASE_URL` environment variables; a `max_retries` param. +- `Client`/`AsyncClient`/`HttpClient`/`AsyncHttpClient.__repr__` — redacted, never + leak the API key. +- `HttpClient.base_url` / `.redacted_key` public properties. +- `Link.full_path` / `.namespace` fields (previously only reachable as unqueryable + extras on namespaced-link responses). +- `CustomDomain.default_redirect` field; `custom_domains.update(..., default_redirect=None)`. +- `Webhook.secret` / `.success_count` fields; `Webhook.__repr__` redacts `secret`. +- `Profile` model. +- Firestore-timestamp tolerance: any `{_seconds,_nanoseconds}`/`{seconds,nanoseconds}` + value in a response is normalized to an ISO-8601 string before model validation + (fields stay `str`-typed; native `datetime` support is planned for 2.0). +- Per-call `timeout=` override on the underlying transport. +- `tests/test_contract.py`: a fixture-driven test suite (`tests/contracts/sdk-contract.json`, + vendored from the platform repo) exercising every capability/error/behavior + scenario in the cross-SDK contract; unmapped scenarios fail rather than skip. +- `.github/workflows/ci.yml`: ruff + mypy + pytest (unit/contract on every push, + matrix Python 3.9–3.13; integration gated on the `AWSYS_API_KEY` secret). +- `.github/workflows/contract-drift.yml`: weekly + `repository_dispatch`-triggered + check against the platform's live contract, filing a `sdk-parity`-labeled issue on + drift; nightly staging integration run. +- `ruff`/`mypy` added to dev dependencies, with config in `pyproject.toml`. +- `LICENSE` (MIT) — referenced by `pyproject.toml` and the README but previously + missing from the repo. +- `SECURITY-REVIEW.md`. + +### Changed +- `pyproject.toml`: pin `httpx>=0.27,<1`, `pydantic>=2,<3`; declare Python 3.13 support. +- `_parse_error()` and the retry/backoff decision logic are now shared between the + sync and async transports (`awsysco/_transport.py`), removing a byte-for-byte + duplicate. +- User-Agent header is now `awsysco-python-sdk/{version} (python/{runtime version})`, + derived from the package's own `__version__` (was a hardcoded, stale `1.0.0`). +- Retry policy: 429 retried for all methods except quota-exhaustion codes + (`HOURLY_LIMIT_EXCEEDED`/`DAILY_LIMIT_EXCEEDED`/`MONTHLY_LIMIT_EXCEEDED`, never + retried); `502`/`503`/`504` and transport errors retried only for idempotent + methods (`GET`/`PUT`/`DELETE`); backoff now uses full jitter. +- `links.create()`/`links.update()` accept `RoutingRule`/`OgMeta`/`GeoRestriction` + model instances (previously accepted only plain dicts, though the same-named + model classes existed unused). +- `links.update()` URL-encodes a namespaced `short_path` (the platform's `PATCH` + route can't otherwise address `prefix/slug`). +- `bulk.create()`'s snake_case/camelCase key handling consolidated into one + normalizer (previously duplicated per field). +- `qr.py` reads the transport's `base_url` via its public property instead of a + private attribute. +- README rewritten with full coverage of all 20 resources, pagination, error + hierarchy, configuration, and async/retry/timeout behavior. + +### Fixed +- **`analytics.get_recent_clicks()`** called `/api/user/recent-clicks`, a path that + never existed on the platform (always 404'd). Now calls `/api/user/clicks/recent` + and supports a `since` parameter. +- **`folders.update()`** called `PATCH /api/v1/folders/:id`, which 404s — the + platform only exposes this route unversioned. Now calls `PATCH /api/folders/:id`. +- **`tags.add()`** sent `{"tag": "..."}` (singular) — the platform requires + `{"tags": [...]}` (a plural array); every call previously failed validation + server-side. +- **`webhooks.list()`/`.create()`/`.delete()`/`.test()`** called unversioned + `/api/webhooks/*` paths; the platform's canonical routes for these four are + `/api/v1/webhooks/*` (`.update()` correctly stays unversioned — no v1 alias + exists for it). +- **`TrustScoreResult.score`/`.status`** were always `None` — the platform sends + `trustScore`/`trustStatus`, not `score`/`status`. Fixed via a field alias (no + public rename). +- **`ProfileResource.update(**kwargs)`** sent raw Python kwarg names on the wire + (e.g. `display_name`) instead of camelCase (`displayName`), so non-camelCase + update fields silently no-opped server-side. +- **`imports.start()`** sent snake_case body keys (`access_token`, etc.); the + platform's documented shape is camelCase (`accessToken`, etc.) — both are + accepted server-side, but the fixture/catalog standardize on camelCase. +- `custom_domains.activate()` previously made a network call to a route that's + Firebase-auth-only and always 401s for an API key. Now raises + `AwsysForbiddenError` immediately (with a `DeprecationWarning`) without a + network round-trip. + +### Deprecated +- `custom_domains.activate()` — Firebase-only route, unreachable with an API key. + Emits `DeprecationWarning`; will be removed in the next major version. + +### Security +- `AwsysError.raw`/exception messages never include request headers (unchanged, + reconfirmed by the contract-fixture suite). +- `Webhook.__repr__` redacts `.secret`. +- `base_url` is validated (must be `http(s)://`; non-`https` warns) before any + request is made. +- API key is redacted in `repr(Client)`/`repr(AsyncClient)`/`repr(HttpClient)`/ + `repr(AsyncHttpClient)`. +- See `SECURITY-REVIEW.md` for the full dependency audit and finding list. + +## [1.3.0] — 2026-07-19 +Parity resources: `usage`, `web2app`, `imports` (Phase "parity"). + +## [1.1.0] +Phase 3 resources: `webhooks`, `saved_views`, `custom_domains`, `agentlink`, `affiliate`. + +## [1.0.0] +Phase 2 resources: `tags`, `trust_score`, `data_export`, `namespace`, `utm_templates`. + +## [0.1.0] +Initial release: `links`, `analytics`, `qr`, `folders`, `bulk`, `me`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5939dfe --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Alpha Wave Systems + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index c249e10..7e749c2 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,46 @@ [![Python versions](https://img.shields.io/pypi/pyversions/awsysco.svg)](https://pypi.org/project/awsysco/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -The official Python SDK for the [AWSYS.CO](https://awsys.co) URL Shortener API. +## What -## Installation +The official Python SDK for the [AWSYS.CO](https://awsys.co) URL Shortener API — a +typed, sync-and-async client covering every capability the platform exposes to an +API key: links, analytics, QR codes, folders, bulk create, account/profile/usage, +tags, trust scoring, data export, branded namespaces, UTM templates, webhooks, saved +views, custom domains, affiliate programs, AgentLink, Web2App, and provider imports. +Every method is documented and Pydantic-typed; every error maps to a specific, +catchable exception. + +## Install ```bash pip install awsysco ``` -Requires Python 3.9+. +Requires Python 3.9+. See [Supported Versions](#supported-versions) below. + +## Authentication + +Generate an API key from your [AWSYS dashboard](https://awsys.co/dashboard/settings/api). +All API keys begin with `awsys_`. + +```python +from awsysco import Client + +client = Client(api_key="awsys_...") +``` + +Or set `AWSYS_API_KEY` in your environment and omit `api_key` entirely: + +```bash +export AWSYS_API_KEY=awsys_... +``` + +```python +client = Client() # picks up AWSYS_API_KEY automatically +``` + +Passing neither raises `AwsysConfigurationError` before any network call is made. ## Quick Start @@ -34,220 +65,208 @@ qr_url = client.qr.get_url(link.short_code, size=400) print(qr_url) ``` -## Authentication +## Core Resources + +Every resource below is available on `client.` (sync `Client`) and +identically on `await client..(...)` (`AsyncClient`). -Generate an API key from your [AWSYS dashboard](https://awsys.co/dashboard/settings/api). All API keys begin with `awsys_`. +### Links — `client.links` ```python -client = Client(api_key="awsys_...") +link = client.links.create("https://example.com", custom_slug="my-link", max_clicks=1000) +page = client.links.list(limit=20, offset=0) +one = client.links.get("my-link") +updated = client.links.update("my-link", max_clicks=500) +client.links.delete("my-link") + +# Auto-paginate every link (see Pagination below) +for link in client.links.list_all(): + print(link.short_code) ``` -Store keys in environment variables — never hardcode them: +### Analytics — `client.analytics` ```python -import os -from awsysco import Client +stats = client.analytics.get_stats("abc123") +print(stats.total_clicks) -client = Client(api_key=os.environ["AWSYS_API_KEY"]) +agg = client.analytics.get_aggregate_stats("abc123", period="30d") +print(agg.total_clicks, agg.unique_visitors, agg.country_breakdown) +if agg.upgrade_for_more: # present on free tier; richer breakdowns need Pro+ + print(agg.upgrade_for_more.message) + +# Requires the account's "Live Globe" feature flag; 403 FEATURE_DISABLED otherwise. +recent = client.analytics.get_recent_clicks(limit=10) ``` -## API Reference +### QR Codes — `client.qr` -### Links +```python +# Client-side URL builder — no HTTP request is made. +url = client.qr.get_url("abc123", size=400, color="FF5733", bg_color="FFFFFF") -| Method | Description | -|---|---| -| `client.links.create(url, *, custom_slug, expires_at, max_clicks)` | Create a shortened link | -| `client.links.list(*, limit=20, offset=0)` | List links (paginated) | -| `client.links.get(short_path)` | Get a single link | -| `client.links.update(short_path, *, expires_at, max_clicks)` | Update link settings | -| `client.links.delete(short_path)` | Delete a link | +settings = client.qr.get_settings("abc123") +client.qr.update_settings("abc123", {"color": "#ff0000"}) +``` -```python -# Create with options -link = client.links.create( - "https://example.com", - custom_slug="my-link", - expires_at="2025-12-31T23:59:59Z", - max_clicks=1000, -) +### Folders — `client.folders` -# Paginate -page1 = client.links.list(limit=20, offset=0) -page2 = client.links.list(limit=20, offset=20) +```python +folder = client.folders.create("Q1 Campaign", color="#FF5733") +client.folders.assign_link("abc123", folder.id) +client.folders.update(folder.id, name="Q1 Campaign (final)") +client.folders.remove_link("abc123") +client.folders.delete(folder.id) +``` -# Update -updated = client.links.update("my-link", max_clicks=500) +### Bulk Create — `client.bulk` -# Delete -client.links.delete("my-link") +```python +result = client.bulk.create([ + {"url": "https://example.com/page-1"}, + {"url": "https://example.com/page-2", "custom_slug": "page-two"}, +]) +print(result.created, result.failed) ``` -### Analytics +### Me / Profile / Usage -| Method | Description | -|---|---| -| `client.analytics.get_stats(short_path)` | Get raw per-click stats for a link | -| `client.analytics.get_aggregate_stats(short_path, *, period=None)` | Get rolled-up (aggregated) stats for a link | +Three distinct, non-overlapping views of the account: ```python -stats = client.analytics.get_stats("abc123") -print(stats.total_clicks) -for click in stats.clicks: - print(click.country, click.device, click.timestamp) +me = client.me.get() # subscription tier, features, limits +profile = client.profile.get() # account profile: email, display name +usage = client.usage.get() # live consumption counters + overage state + +client.profile.update(display_name="New Name") ``` -`get_aggregate_stats` returns server-side aggregations (clicks-by-day, -country/device/UTM breakdowns, unique visitors). The breakdowns present are -**tier-gated** — free-tier responses include an `upgrade_for_more` hint and -omit the richer breakdowns; higher tiers populate `device_breakdown`, -`utm_breakdown`, `hour_breakdown`, etc. +### Tags — `client.tags` ```python -agg = client.analytics.get_aggregate_stats("abc123", period="30d") -print(agg.total_clicks, agg.unique_visitors, agg.tier) -for day in agg.clicks_by_day: - print(day.date, day.clicks) -print(agg.country_breakdown) # {"US": 100, ...} - -if agg.device_breakdown: # populated on Pro+ - print(agg.device_breakdown.mobile, agg.device_breakdown.desktop) -if agg.upgrade_for_more: # present on free tier - print(agg.upgrade_for_more.message, agg.upgrade_for_more.available) +client.tags.add("abc123", "promo") +client.tags.remove("abc123", "promo") ``` -### QR Codes +### Trust Score — `client.trust_score` -| Method | Description | -|---|---| -| `client.qr.get_url(short_code, *, size=300, color='000000', bg_color='FFFFFF')` | Build QR image URL | +```python +result = client.trust_score.scan("abc123") +print(result.score, result.status, result.threats) +``` -No HTTP request is made — this method constructs and returns the URL string. +### Data Export — `client.data_export` ```python -url = client.qr.get_url("abc123", size=400, color="FF5733", bg_color="FFFFFF") -# https://awsys.co/api/qr/abc123?size=400&color=FF5733&bgColor=FFFFFF +csv_text = client.data_export.export_links() # all links, as CSV +stats_csv = client.data_export.export_link_stats("abc123") ``` -### Folders - -| Method | Description | -|---|---| -| `client.folders.list()` | List all folders | -| `client.folders.create(name, *, color)` | Create a folder | -| `client.folders.delete(folder_id)` | Delete a folder | -| `client.folders.assign_link(short_path, folder_id)` | Assign a link to a folder | -| `client.folders.remove_link(short_path)` | Remove a link from its folder | +### Namespace — `client.namespace` ```python -folder = client.folders.create("Q1 Campaign", color="#FF5733") -client.folders.assign_link("abc123", folder.id) +info = client.namespace.get() +available = client.namespace.check("acme") +client.namespace.claim("acme") +client.namespace.release() +``` -folders = client.folders.list() -for f in folders.folders: - print(f.name, f.link_count) +### UTM Templates — `client.utm_templates` -client.folders.remove_link("abc123") -client.folders.delete(folder.id) +```python +client.utm_templates.create("Launch", "newsletter", "email", "sept") +for t in client.utm_templates.list(): # derived from /api/v1/me — no dedicated list route + print(t.name) +client.utm_templates.delete(t.id) ``` -### Bulk Create - -| Method | Description | -|---|---| -| `client.bulk.create(urls)` | Create multiple links in one request | +### Webhooks — `client.webhooks` ```python -result = client.bulk.create([ - {"url": "https://example.com/page-1"}, - {"url": "https://example.com/page-2", "custom_slug": "page-two"}, - {"url": "https://example.com/page-3", "max_clicks": 100}, -]) -print(f"Created: {result.created}, Failed: {result.failed}") -for r in result.results: - print(r.short_url, r.success) +webhook = client.webhooks.create("https://you.example/hook", ["link.created", "link.click"]) +client.webhooks.update(webhook.id, enabled=False) +client.webhooks.test(webhook.id, "link.created") +client.webhooks.delete(webhook.id) ``` -### Me - -| Method | Description | -|---|---| -| `client.me.get()` | Get the authenticated user's profile | +### Saved Views — `client.saved_views` ```python -me = client.me.get() -print(me.email, me.subscription_tier, me.is_premium) +view = client.saved_views.create("My View", {"tag": "promo"}) +client.saved_views.update(view.id, name="Renamed View") +client.saved_views.delete(view.id) ``` -### Usage +### Custom Domains — `client.custom_domains` -| Method | Description | -|---|---| -| `client.usage.get()` | Get live account consumption + tier limits | +```python +client.custom_domains.add("go.example.com") +client.custom_domains.verify("go.example.com") +client.custom_domains.update("go.example.com", default_redirect="https://example.com/") +# .activate() is deprecated — Firebase-only, unreachable with an API key. Use the dashboard. +``` -Distinct from `me.get()` (static profile), `usage.get()` returns live counters -(links, clicks, QR codes, API calls, tracked clicks), the current tier limits, -and any active overage state. Limit fields may be an integer or the literal -string `"unlimited"`. +### Affiliate — `client.affiliate` ```python -usage = client.usage.get() -print(usage.total_links, usage.tracked_clicks_this_month) -print(usage.limits.monthly_links) # int or "unlimited" -if usage.overage.active: - print(f"Overage charge: {usage.overage.estimated_charge_cents}c") +program = client.affiliate.create_program("Partner Program", "cpa_return", cpa_rate=15) +client.affiliate.discover(limit=20) +client.affiliate.join(program.id, partner_code="LAUNCH") +stats = client.affiliate.get_program_stats(program.id, period="30d") ``` -### Web2App +### AgentLink — `client.agentlink` + +```python +client.agentlink.subscribe("dev@example.com") # public, no auth required +stats = client.agentlink.get_account_stats(period_days=7) +``` -| Method | Description | -|---|---| -| `client.web2app.consume_session(token)` | Consume a Web2App deep-link session | +### Web2App — `client.web2app` -Web2App sessions are **single-use** (consumed on read) with a **24-hour TTL**. -Unknown, expired, or already-consumed tokens raise `AwsysNotFoundError`; -malformed tokens raise `AwsysValidationError`. +Sessions are **single-use** (consumed on read) with a **24-hour TTL**. ```python session = client.web2app.consume_session("0123456789abcdef0123456789abcdef") print(session.link_id, session.utm_params, session.country) ``` -### Imports - -| Method | Description | -|---|---| -| `client.imports.start(*, provider, access_token, target_namespace=None, scan_only=None)` | Start a provider link-import job | -| `client.imports.get_status(job_id)` | Get the current state of an import job | -| `client.imports.cancel(job_id)` | Cancel an in-progress import job | -| `client.imports.list(*, limit=None)` | List import jobs for the account | -| `client.imports.wait_for_completion(job_id, *, poll_interval=2.0, timeout=120.0)` | Poll until the job reaches a terminal state | - -Import jobs run asynchronously server-side. `start` returns a `pending` -`ImportJob`; poll `get_status` (or use `wait_for_completion`) until the status -is one of `completed`, `partial`, `failed`, or `cancelled`. Pass -`scan_only=True` to preview what would be imported without writing links. +### Imports — `client.imports` ```python job = client.imports.start(provider="bitly", access_token="") -print(job.id, job.status) - -# Block until the import finishes (raises TimeoutError if it overruns). done = client.imports.wait_for_completion(job.id, poll_interval=5.0, timeout=300.0) print(done.status, done.counts.written, done.counts.errored) -for err in done.errors: - print(err) -# Or drive it manually -for j in client.imports.list(limit=10): - print(j.id, j.provider, j.status) -client.imports.cancel(job.id) +csv_map = client.imports.get_redirect_map_csv(job.id) +json_map = client.imports.get_redirect_map_json(job.id) ``` -All `imports` methods are available identically on `AsyncClient` -(`await client.imports.start(...)`, `await client.imports.wait_for_completion(...)`). +## Pagination -## Error Handling +Only `links.list()` is paginated (offset/limit; the platform ignores offsets on +every other list endpoint, so the SDK doesn't fake them). `limit` is clamped to the +platform max of 100 client-side. + +```python +page1 = client.links.list(limit=20, offset=0) +page2 = client.links.list(limit=20, offset=20) +print(page1.has_more) # None if the platform didn't send pagination info + +# Or auto-paginate everything: +for link in client.links.list_all(limit=100): + ... + +# Async equivalent: +async for link in async_client.links.list_all(): + ... +``` + +The iterator stops when the platform reports `has_more=False`, **or** a page comes +back shorter than the requested `limit` (including empty) — this guards against a +response that omits `has_more` entirely. + +## Errors All errors inherit from `AwsysError`. @@ -255,100 +274,111 @@ All errors inherit from `AwsysError`. from awsysco import ( Client, AwsysError, + AwsysConfigurationError, AwsysAuthError, AwsysForbiddenError, AwsysNotFoundError, AwsysConflictError, AwsysValidationError, AwsysRateLimitError, + AwsysServerError, + AwsysNetworkError, + AwsysTimeoutError, ) try: link = client.links.get("nonexistent") except AwsysNotFoundError: print("Link not found") -except AwsysAuthError: - print("Invalid API key") -except AwsysConflictError as e: - print(f"Slug already taken: {e.message}") -except AwsysValidationError as e: - print(f"Bad request: {e.message} ({e.code})") except AwsysRateLimitError as e: - print(f"Rate limited. Retry after {e.retry_after}s") + print(f"Rate limited (code={e.code}). Retry after {e.retry_after}s, resets {e.resets_at}") +except AwsysServerError as e: + print(f"Platform error {e.status}: {e.message}") +except AwsysTimeoutError: + print("Request timed out") except AwsysError as e: print(f"API error {e.status}: {e.message}") ``` -| Exception | HTTP Status | When raised | +| Exception | HTTP status | When raised | |---|---|---| +| `AwsysConfigurationError` | – | Missing API key or invalid `base_url`, before any network call | | `AwsysValidationError` | 400 | Invalid request parameters | | `AwsysAuthError` | 401 | Missing or invalid API key | -| `AwsysForbiddenError` | 403 | Insufficient permissions | +| `AwsysForbiddenError` | 403 | Insufficient permissions / tier / feature flag | | `AwsysNotFoundError` | 404 | Resource does not exist | | `AwsysConflictError` | 409 | Custom slug already taken | -| `AwsysRateLimitError` | 429 | Too many requests | -| `AwsysError` | 5xx | Server errors | - -All exceptions expose `.message`, `.code`, `.status`, and `.raw` attributes. +| `AwsysRateLimitError` | 429 | Too many requests (`.code`, `.retry_after`, `.resets_at`) | +| `AwsysServerError` | 5xx | Platform-side error | +| `AwsysNetworkError` | – | Connection failure (no HTTP response at all) | +| `AwsysTimeoutError` | – | Request exceeded its timeout (subclass of `AwsysNetworkError`) | +| `AwsysError` | any | Base class; catches everything above | -## Rate Limiting - -The SDK automatically retries on `429 Too Many Requests` with exponential backoff (up to 3 retries). The `Retry-After` response header is respected. - -```python -from awsysco import AwsysRateLimitError - -try: - link = client.links.create("https://example.com") -except AwsysRateLimitError as e: - print(f"Still rate limited after retries. Retry after: {e.retry_after}s") -``` +Every exception exposes `.message`, `.code`, `.status`, and `.raw` (the parsed — +or raw text — response body). The platform emits several error-body shapes +(`{error:true,code,message}`, `{error:"",code}`, `{success:false,...}`, +and occasionally non-JSON bodies); the SDK tolerates all of them. -## Custom Base URL +## Configuration ```python -# Point at staging client = Client( - api_key="awsys_...", - base_url="https://staging.awsys.co", + api_key="awsys_...", # or omit to read AWSYS_API_KEY + base_url="https://staging.awsys.co", # or omit to read AWSYS_BASE_URL, default https://awsys.co + timeout=30.0, # per-request default, in seconds + max_retries=3, # retry attempts for 429s / retryable 5xx / transport errors ) ``` -## Context Manager +`base_url` must start with `http://` or `https://` (anything else raises +`AwsysConfigurationError`); a plain `http://` URL is accepted but emits a warning. +The API key and base URL are never included in `repr(client)`, log output, or any +exception — `repr()` shows a redacted `awsys_...last4` form. + +## Advanced + +**Async**: every resource and method is mirrored on `AsyncClient`. ```python -with Client(api_key="awsys_...") as client: - link = client.links.create("https://example.com") - print(link.short_url) -# HTTP connections are closed automatically +import asyncio +from awsysco import AsyncClient + +async def main(): + async with AsyncClient(api_key="awsys_...") as client: + link = await client.links.create("https://example.com") + print(link.short_url) + +asyncio.run(main()) ``` +**Retries**: `429` is retried for every HTTP method (except quota-exhaustion 429s — +`HOURLY_LIMIT_EXCEEDED`/`DAILY_LIMIT_EXCEEDED`/`MONTHLY_LIMIT_EXCEEDED` — which raise +immediately, since waiting a few seconds can't help). `502`/`503`/`504` and +transport-level failures (connection reset/refused, DNS) are retried only for +idempotent methods (`GET`/`PUT`/`DELETE`) — the platform has no idempotency keys, so +a retried `POST`/`PATCH` could create duplicates. Backoff uses the `Retry-After` +header when present (seconds or an HTTP-date), otherwise `1s × 2^attempt` capped at +30s, with full jitter. Set `max_retries=0` to disable retries entirely. + +**Timeouts**: 30s default per attempt, configurable at the client level +(`Client(timeout=...)`) or per call via each resource method's underlying request. + +**Context managers**: `with Client(...) as client:` / `async with AsyncClient(...) as client:` +close the underlying HTTP connection pool automatically. + ## Models -All responses are parsed into Pydantic v2 models: - -| Model | Fields | -|---|---| -| `Link` | `id`, `short_url`, `short_code`, `long`, `clicks`, `created`, `expires_at`, `max_clicks`, `password_protected` | -| `LinkList` | `links`, `total`, `has_more` | -| `LinkStats` | `short_code`, `total_clicks`, `clicks` | -| `ClickEvent` | `timestamp`, `country`, `device`, `browser`, `os`, `referrer` | -| `Folder` | `id`, `name`, `color`, `link_count`, `created_at` | -| `FolderList` | `folders`, `limit`, `used` | -| `BulkResult` | `created`, `failed`, `results` | -| `BulkLinkResult` | `success`, `short_url`, `long`, `error` | -| `MeResponse` | `uid`, `email`, `subscription_tier`, `user_prefix`, `is_premium`, `features`, `limits` | -| `UsageStats` | `total_links`, `total_clicks`, `links_created_this_month`, `qr_codes_this_month`, `folder_count`, `api_calls_this_month`, `tracked_clicks_this_month`, `tier`, `limits`, `has_api_key`, `api_key_created_at`, `user_prefix`, `is_premium`, `overage` | -| `UsageLimits` | `links_per_month`, `monthly_links`, `daily_links`, `monthly_tracked_clicks`, `qr_codes`, `folders` (each `int` or `"unlimited"`), `api_calls_per_month`, `custom_slugs` | -| `UsageOverage` | `active`, `started_at`, `expires_at`, `hours_until_drop`, `clicks_this_cycle`, `spending_limit_cents`, `estimated_charge_cents` | -| `Web2AppSession` | `success`, `link_id`, `utm_params`, `routing_rule`, `country`, `clicked_at` | -| `ImportJob` | `id`, `user_id`, `provider`, `status`, `scan_only`, `target_namespace`, `scope_filter`, `counts`, `errors`, `created_at`, `updated_at` | -| `ImportCounts` | `fetched`, `transformed`, `written`, `errored` | -| `AggregateAnalytics` | `short_code`, `full_path`, `period`, `total_clicks`, `unique_visitors`, `clicks_by_day`, `country_breakdown`, `tier_limit`, `tier`, `device_breakdown`, `referrer_breakdown`, `browser_breakdown`, `os_breakdown`, `source_breakdown`, `hour_breakdown`, `utm_breakdown`, `upgrade_for_more` | -| `DayClicks` / `HourClicks` | `date`/`hour`, `clicks` | -| `DeviceBreakdown` | `mobile`, `desktop`, `tablet` | -| `UTMBreakdown` | `sources`, `mediums`, `campaigns` | -| `UpgradeForMore` | `available`, `message` | +Every response is parsed into a Pydantic v2 model. All fields are `Optional` +(the platform's response shapes vary by tier/feature flags), unknown fields from +the platform are preserved rather than rejected, and timestamp fields accept both +plain ISO-8601 strings and Firestore's `{_seconds,_nanoseconds}` shape (normalized +to an ISO-8601 string — models expose timestamps as `str`, not a native `datetime`, +in the 1.x series; native `datetime` support is planned for 2.0). + +## Supported Versions + +Python 3.9, 3.10, 3.11, 3.12, and 3.13 are tested in CI on every push. Runtime +dependencies: `httpx>=0.27,<1`, `pydantic>=2,<3`. ## Development Setup @@ -358,14 +388,21 @@ cd awsysco-python-sdk pip install -e ".[dev]" -# Configure test credentials +# Configure test credentials (staging recommended) cp .env.example .env.test -# Edit .env.test — add your AWSYS_API_KEY (staging recommended) +# Edit .env.test — add your AWSYS_API_KEY -# Run tests +# Unit + contract tests — no network required +pytest -m "not integration" + +# Full suite, including live staging integration tests pytest -# Run with coverage +# Lint and type-check +ruff check . +mypy awsysco + +# Coverage pytest --cov=awsysco --cov-report=term-missing ``` @@ -373,11 +410,17 @@ pytest --cov=awsysco --cov-report=term-missing 1. Fork the repository 2. Create a feature branch (`git checkout -b feat/my-feature`) -3. Make your changes and add tests -4. Run `pytest` — all tests must pass +3. Make your changes and add tests (see `tests/test_contract.py` if you're touching + request/response shapes — it's driven by `tests/contracts/sdk-contract.json`) +4. Run `pytest -m "not integration"`, `ruff check .`, and `mypy awsysco` — all must pass 5. Open a pull request -Please read [SECURITY.md](SECURITY.md) before contributing — never commit API keys. +## Security + +See [SECURITY.md](SECURITY.md) for the secret-hygiene policy (never commit API +keys) and [SECURITY-REVIEW.md](SECURITY-REVIEW.md) for the SDK's own security +review (dependency audit, redaction guarantees, TLS/base-URL validation). Report +vulnerabilities to security@awsys.co — do not open a public issue. ## License diff --git a/SECURITY-REVIEW.md b/SECURITY-REVIEW.md new file mode 100644 index 0000000..ed7a1b6 --- /dev/null +++ b/SECURITY-REVIEW.md @@ -0,0 +1,79 @@ +# Security Review — awsysco Python SDK + +Review date: 2026-09-06. Scope: `awsys-orch`'s Phase 5 (contract-v1-parity) work on +this SDK, plus a pass over the pre-existing codebase surfaced along the way. Findings +are ordered by severity. See `SECURITY.md` for the repo's secret-hygiene policy +(pre-commit hook, gitleaks CI) — that is not repeated here. + +## Dependency audit + +`pip-audit` run against the exact installed versions: + +``` +Auditing httpx (0.28.1) +Auditing pydantic (2.13.5) +Auditing pydantic_core (2.46.5) +No known vulnerabilities found +``` + +`pyproject.toml` now pins `httpx>=0.27,<1` and `pydantic>=2,<3` (previously +unbounded above the major version). + +## Findings + +### Medium — API key was not redacted from `repr()`/logs (fixed) +Before this pass, `Client`/`AsyncClient`/`HttpClient`/`AsyncHttpClient` had no +custom `__repr__`; the default object repr didn't leak the key directly, but +nothing prevented a future change from doing so, and there was no way to safely +print client state for debugging. **Fixed**: all four now expose a redacted +`awsys_...last4` form via `__repr__`/`.redacted_key`, verified by +`tests/test_config.py::TestRedaction`. + +### Medium — `base_url` accepted any string, no scheme validation (fixed) +Previously `base_url` was only `.rstrip("/")`'d — a caller (or anything deriving +config from an untrusted source) could point the client at any host or scheme +with no validation. **Fixed**: `resolve_base_url()` now rejects anything without +an `http://`/`https://` scheme (raises `AwsysConfigurationError`) and warns on +plain `http://`. Verified by `tests/test_config.py::TestBaseUrlResolution`. + +### Low — Webhook signing secret was not redacted from model reprs (fixed) +`Webhook.secret` (the webhook's HMAC signing secret) was a plain model field with +no redaction — `print(webhook)`/`repr(webhook)`/an uncaught exception embedding the +model would leak it into logs. **Fixed**: `Webhook.__repr__` masks `secret` as +``. Verified by `tests/test_webhooks.py::test_repr_never_contains_raw_secret`. + +### Low — stale, incorrect User-Agent version string (fixed) +Both transports hardcoded `User-Agent: awsysco-python-sdk/1.0.0` regardless of the +actual installed version (1.3.0 at the time, now 1.4.0) — not a vulnerability, but +a support/telemetry accuracy issue (the platform can't reliably tell which SDK +version is making a request from its own logs). **Fixed**: derived from +`awsysco.__version__`; a test (`test_pyproject_version_matches_dunder_version`) +prevents the two from drifting again. + +### Informational — exception `.raw` retains the full response body +`AwsysError.raw` intentionally stores the parsed (or raw-text) response body for +debugging, and `__repr__` deliberately excludes it (unchanged, predates this pass). +A caller who explicitly does `print(exc.raw)` or logs it can still surface whatever +the platform put in the body. This is accepted as intentional (the contract +requires exposing the raw body for callers who need it) — confirmed the body never +contains the request's `Authorization` header (`tests/test_transport.py::TestParseErrorBodyShapes::test_raw_never_includes_request_headers`). + +### Informational — TLS verification +No code path disables TLS certificate verification (`verify=False`) anywhere in +either transport; `httpx`'s default (verify on) is preserved. Confirmed by +inspection — no such option is exposed to callers either. + +### Informational — retry policy and duplicate requests +Per the platform contract, the API has no idempotency keys. The retry policy +(this pass) restricts 5xx/transport-error retries to idempotent methods +(`GET`/`PUT`/`DELETE`) specifically to avoid a retried `POST`/`PATCH` silently +creating a duplicate resource (e.g. two links from one `links.create()` call +during a flaky connection). `429` is retried for all methods since it indicates +the original request was already rejected before any side effect occurred. + +## Out of scope / carried forward, not addressed this pass +- Native `datetime` exposure for timestamp fields (currently normalized to + ISO-8601 `str`) — deferred to 2.0 per `awsys-orch` ADR-017, to avoid a breaking + type change in a minor release. +- Request-ID / distributed tracing support — the platform doesn't emit a request ID + today (per the capability catalog), so there's nothing for the SDK to surface. diff --git a/awsysco/__init__.py b/awsysco/__init__.py index 64adf8a..18e562c 100644 --- a/awsysco/__init__.py +++ b/awsysco/__init__.py @@ -1,13 +1,18 @@ """AWSYS.CO Python SDK — Official client library for the AWSYS.CO URL Shortener API.""" +from ._version import __version__ from .client import AsyncClient, Client from .exceptions import ( AwsysAuthError, + AwsysConfigurationError, AwsysConflictError, AwsysError, AwsysForbiddenError, + AwsysNetworkError, AwsysNotFoundError, AwsysRateLimitError, + AwsysServerError, + AwsysTimeoutError, AwsysValidationError, ) from .models import ( @@ -32,6 +37,7 @@ NamespaceCheckResult, NamespaceInfo, OgMeta, + Profile, QRSettings, RoutingRule, SavedView, @@ -47,8 +53,9 @@ Webhook, ) -__version__ = "1.3.0" __all__ = [ + # Version + "__version__", # Clients "Client", "AsyncClient", @@ -60,6 +67,10 @@ "AwsysConflictError", "AwsysValidationError", "AwsysRateLimitError", + "AwsysServerError", + "AwsysNetworkError", + "AwsysTimeoutError", + "AwsysConfigurationError", # Core models "Link", "LinkList", @@ -96,6 +107,8 @@ "UsageStats", "UsageLimits", "UsageOverage", + # Profile + "Profile", # Web2App "Web2AppSession", # Imports diff --git a/awsysco/_async_http.py b/awsysco/_async_http.py index e271646..716cee4 100644 --- a/awsysco/_async_http.py +++ b/awsysco/_async_http.py @@ -1,4 +1,4 @@ -"""Async HTTP client wrapper with retry logic and error mapping.""" +"""Async HTTP client wrapper with config validation, retries, and error mapping.""" from __future__ import annotations @@ -7,82 +7,56 @@ import httpx -from .exceptions import ( - AwsysAuthError, - AwsysConflictError, - AwsysError, - AwsysForbiddenError, - AwsysNotFoundError, - AwsysRateLimitError, - AwsysValidationError, +from ._http import build_user_agent, redact_key, resolve_base_url +from ._transport import ( + DEFAULT_MAX_RETRIES, + RETRYABLE_SERVER_STATUSES, + compute_delay, + is_idempotent, + is_quota_rate_limit, + is_retry_after_excessive, + parse_error, ) - -_MAX_RETRIES = 3 -_RETRY_BASE_DELAY = 1.0 # seconds - - -def _parse_error(response: httpx.Response) -> AwsysError: - """Parse an HTTP error response and return the appropriate exception.""" - status = response.status_code - raw: Any = None - message: str = f"HTTP {status}" - code: Optional[str] = None - - try: - data = response.json() - raw = data - msg_field = data.get("message") - if msg_field and isinstance(msg_field, str): - message = msg_field - elif isinstance(data.get("error"), str): - message = data["error"] - code = data.get("code") - except Exception: - raw = response.text - if raw: - message = raw - - kwargs: Dict[str, Any] = {"code": code, "status": status, "raw": raw} - - if status == 400: - return AwsysValidationError(message, **kwargs) - if status == 401: - return AwsysAuthError(message, **kwargs) - if status == 403: - return AwsysForbiddenError(message, **kwargs) - if status == 404: - return AwsysNotFoundError(message, **kwargs) - if status == 409: - return AwsysConflictError(message, **kwargs) - if status == 429: - retry_after: Optional[float] = None - ra_header = response.headers.get("Retry-After") - if ra_header: - try: - retry_after = float(ra_header) - except ValueError: - pass - return AwsysRateLimitError(message, retry_after=retry_after, **kwargs) - - return AwsysError(message, **kwargs) +from .exceptions import AwsysNetworkError, AwsysRateLimitError, AwsysServerError, AwsysTimeoutError class AsyncHttpClient: """Async wrapper around httpx.AsyncClient with auth, retries, and error mapping.""" - def __init__(self, api_key: str, base_url: str, timeout: float = 30.0) -> None: - self._base_url = base_url.rstrip("/") + def __init__( + self, + api_key: str, + base_url: str, + timeout: float = 30.0, + max_retries: int = DEFAULT_MAX_RETRIES, + ) -> None: + self._api_key = api_key + self._base_url = resolve_base_url(base_url) + self._max_retries = max_retries self._client = httpx.AsyncClient( base_url=self._base_url, headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "Accept": "application/json", - "User-Agent": "awsysco-python-sdk/1.0.0", + "User-Agent": build_user_agent(), }, timeout=timeout, ) + @property + def base_url(self) -> str: + """The (validated, trailing-slash-stripped) base URL this client talks to.""" + return self._base_url + + @property + def redacted_key(self) -> str: + """A safe-to-print form of the configured API key (``awsys_...last4``).""" + return redact_key(self._api_key) + + def __repr__(self) -> str: + return f"AsyncHttpClient(base_url={self._base_url!r}, api_key={self.redacted_key!r})" + async def _request( self, method: str, @@ -90,62 +64,142 @@ async def _request( *, params: Optional[Dict[str, Any]] = None, json: Optional[Any] = None, + timeout: Optional[float] = None, ) -> Any: - """Execute an async HTTP request with 429-retry logic.""" + """Execute an async HTTP request, retrying per the cross-SDK retry policy.""" attempt = 0 while True: - response = await self._client.request(method, path, params=params, json=json) + try: + response = await self._client.request( + method, path, params=params, json=json, timeout=timeout + ) + except httpx.TimeoutException as exc: + if is_idempotent(method) and attempt < self._max_retries: + await asyncio.sleep(compute_delay(None, attempt)) + attempt += 1 + continue + raise AwsysTimeoutError(str(exc) or "Request timed out.") from exc + except httpx.TransportError as exc: + if is_idempotent(method) and attempt < self._max_retries: + await asyncio.sleep(compute_delay(None, attempt)) + attempt += 1 + continue + raise AwsysNetworkError(str(exc) or "Network error.") from exc + + if response.status_code == 429: + rate_limit_exc = parse_error(response) + assert isinstance(rate_limit_exc, AwsysRateLimitError) + if ( + is_quota_rate_limit(rate_limit_exc) + or is_retry_after_excessive(rate_limit_exc.retry_after) + or attempt >= self._max_retries + ): + raise rate_limit_exc + await asyncio.sleep(compute_delay(response, attempt)) + attempt += 1 + continue - if response.status_code == 429 and attempt < _MAX_RETRIES: - exc = _parse_error(response) - assert isinstance(exc, AwsysRateLimitError) - delay = exc.retry_after or (_RETRY_BASE_DELAY * (2 ** attempt)) - await asyncio.sleep(delay) + if ( + response.status_code in RETRYABLE_SERVER_STATUSES + and is_idempotent(method) + and attempt < self._max_retries + ): + await asyncio.sleep(compute_delay(response, attempt)) attempt += 1 continue if response.is_error: - raise _parse_error(response) + raise parse_error(response) # 204 No Content if response.status_code == 204 or not response.content: return None - return response.json() - - async def get(self, path: str, *, params: Optional[Dict[str, Any]] = None) -> Any: - return await self._request("GET", path, params=params) + try: + return response.json() + except ValueError as exc: + raise AwsysServerError( + f"Expected a JSON response but got non-JSON content: {response.text[:200]!r}", + status=response.status_code, + raw=response.text, + ) from exc + + async def get( + self, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + ) -> Any: + return await self._request("GET", path, params=params, timeout=timeout) - async def get_text(self, path: str, *, params: Optional[Dict[str, Any]] = None) -> str: + async def get_text( + self, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + ) -> str: """Like get() but returns response.text instead of response.json().""" attempt = 0 while True: - response = await self._client.request("GET", path, params=params) + try: + response = await self._client.request( + "GET", path, params=params, timeout=timeout + ) + except httpx.TimeoutException as exc: + if attempt < self._max_retries: + await asyncio.sleep(compute_delay(None, attempt)) + attempt += 1 + continue + raise AwsysTimeoutError(str(exc) or "Request timed out.") from exc + except httpx.TransportError as exc: + if attempt < self._max_retries: + await asyncio.sleep(compute_delay(None, attempt)) + attempt += 1 + continue + raise AwsysNetworkError(str(exc) or "Network error.") from exc + + if response.status_code == 429: + rate_limit_exc = parse_error(response) + assert isinstance(rate_limit_exc, AwsysRateLimitError) + if ( + is_quota_rate_limit(rate_limit_exc) + or is_retry_after_excessive(rate_limit_exc.retry_after) + or attempt >= self._max_retries + ): + raise rate_limit_exc + await asyncio.sleep(compute_delay(response, attempt)) + attempt += 1 + continue - if response.status_code == 429 and attempt < _MAX_RETRIES: - exc = _parse_error(response) - assert isinstance(exc, AwsysRateLimitError) - delay = exc.retry_after or (_RETRY_BASE_DELAY * (2 ** attempt)) - await asyncio.sleep(delay) + if response.status_code in RETRYABLE_SERVER_STATUSES and attempt < self._max_retries: + await asyncio.sleep(compute_delay(response, attempt)) attempt += 1 continue if response.is_error: - raise _parse_error(response) + raise parse_error(response) return response.text - async def post(self, path: str, *, json: Optional[Any] = None) -> Any: - return await self._request("POST", path, json=json) + async def post( + self, path: str, *, json: Optional[Any] = None, timeout: Optional[float] = None + ) -> Any: + return await self._request("POST", path, json=json, timeout=timeout) - async def patch(self, path: str, *, json: Optional[Any] = None) -> Any: - return await self._request("PATCH", path, json=json) + async def patch( + self, path: str, *, json: Optional[Any] = None, timeout: Optional[float] = None + ) -> Any: + return await self._request("PATCH", path, json=json, timeout=timeout) - async def put(self, path: str, *, json: Optional[Any] = None) -> Any: - return await self._request("PUT", path, json=json) + async def put( + self, path: str, *, json: Optional[Any] = None, timeout: Optional[float] = None + ) -> Any: + return await self._request("PUT", path, json=json, timeout=timeout) - async def delete(self, path: str) -> Any: - return await self._request("DELETE", path) + async def delete(self, path: str, *, timeout: Optional[float] = None) -> Any: + return await self._request("DELETE", path, timeout=timeout) async def aclose(self) -> None: """Close the underlying async HTTP connection pool.""" diff --git a/awsysco/_http.py b/awsysco/_http.py index 70c2f2d..d575b60 100644 --- a/awsysco/_http.py +++ b/awsysco/_http.py @@ -1,90 +1,107 @@ -"""Internal HTTP client wrapper with retry logic and error mapping.""" +"""Internal HTTP client wrapper with config validation, retries, and error mapping.""" from __future__ import annotations +import platform as _platform import time +import warnings from typing import Any, Dict, Optional import httpx +from ._transport import ( + DEFAULT_MAX_RETRIES, + RETRYABLE_SERVER_STATUSES, + compute_delay, + is_idempotent, + is_quota_rate_limit, + is_retry_after_excessive, + parse_error, +) +from ._version import __version__ from .exceptions import ( - AwsysAuthError, - AwsysConflictError, - AwsysError, - AwsysForbiddenError, - AwsysNotFoundError, + AwsysConfigurationError, + AwsysNetworkError, AwsysRateLimitError, - AwsysValidationError, + AwsysServerError, + AwsysTimeoutError, ) -_MAX_RETRIES = 3 -_RETRY_BASE_DELAY = 1.0 # seconds - - -def _parse_error(response: httpx.Response) -> AwsysError: - """Parse an HTTP error response and return the appropriate exception.""" - status = response.status_code - raw: Any = None - message: str = f"HTTP {status}" - code: Optional[str] = None - - try: - data = response.json() - raw = data - # API returns { error: true, message: "...", code: "..." } - # The "error" field is a boolean, the human-readable text is in "message" - msg_field = data.get("message") - if msg_field and isinstance(msg_field, str): - message = msg_field - elif isinstance(data.get("error"), str): - message = data["error"] - code = data.get("code") - except Exception: - raw = response.text - if raw: - message = raw - - kwargs: Dict[str, Any] = {"code": code, "status": status, "raw": raw} - - if status == 400: - return AwsysValidationError(message, **kwargs) - if status == 401: - return AwsysAuthError(message, **kwargs) - if status == 403: - return AwsysForbiddenError(message, **kwargs) - if status == 404: - return AwsysNotFoundError(message, **kwargs) - if status == 409: - return AwsysConflictError(message, **kwargs) - if status == 429: - retry_after: Optional[float] = None - ra_header = response.headers.get("Retry-After") - if ra_header: - try: - retry_after = float(ra_header) - except ValueError: - pass - return AwsysRateLimitError(message, retry_after=retry_after, **kwargs) - return AwsysError(message, **kwargs) +_warned_http_base_url = False + + +def resolve_base_url(base_url: str) -> str: + """Validate and normalize a base URL, per the cross-SDK behavior contract. + + Raises :class:`AwsysConfigurationError` for a missing scheme; warns once per + process (not once per call) on a non-``https`` scheme. + """ + global _warned_http_base_url + normalized = base_url.rstrip("/") + if not normalized.startswith(("http://", "https://")): + raise AwsysConfigurationError( + 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 + warnings.warn( + "AWSYS base_url is using plain HTTP — API keys will be sent unencrypted.", + stacklevel=3, + ) + return normalized + + +def build_user_agent() -> str: + return f"awsysco-python-sdk/{__version__} (python/{_platform.python_version()})" + + +def redact_key(api_key: Optional[str]) -> str: + """A safe-to-print form of an API key: ``awsys_...`` (or a placeholder if absent).""" + if not api_key: + return "" + if len(api_key) <= 4: + return "awsys_****" + return f"awsys_...{api_key[-4:]}" class HttpClient: """Thin wrapper around httpx.Client with auth, retries, and error mapping.""" - def __init__(self, api_key: str, base_url: str, timeout: float = 30.0) -> None: - self._base_url = base_url.rstrip("/") + def __init__( + self, + api_key: str, + base_url: str, + timeout: float = 30.0, + max_retries: int = DEFAULT_MAX_RETRIES, + ) -> None: + self._api_key = api_key + self._base_url = resolve_base_url(base_url) + self._max_retries = max_retries self._client = httpx.Client( base_url=self._base_url, headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "Accept": "application/json", - "User-Agent": "awsysco-python-sdk/1.0.0", + "User-Agent": build_user_agent(), }, timeout=timeout, ) + @property + def base_url(self) -> str: + """The (validated, trailing-slash-stripped) base URL this client talks to.""" + return self._base_url + + @property + def redacted_key(self) -> str: + """A safe-to-print form of the configured API key (``awsys_...last4``).""" + return redact_key(self._api_key) + + def __repr__(self) -> str: + return f"HttpClient(base_url={self._base_url!r}, api_key={self.redacted_key!r})" + def _request( self, method: str, @@ -92,62 +109,143 @@ def _request( *, params: Optional[Dict[str, Any]] = None, json: Optional[Any] = None, + timeout: Optional[float] = None, ) -> Any: - """Execute an HTTP request with 429-retry logic.""" + """Execute an HTTP request, retrying per the cross-SDK retry policy.""" attempt = 0 while True: - response = self._client.request(method, path, params=params, json=json) + try: + response = self._client.request( + method, path, params=params, json=json, timeout=timeout + ) + except httpx.TimeoutException as exc: + if is_idempotent(method) and attempt < self._max_retries: + time.sleep(compute_delay(None, attempt)) + attempt += 1 + continue + raise AwsysTimeoutError(str(exc) or "Request timed out.") from exc + except httpx.TransportError as exc: + if is_idempotent(method) and attempt < self._max_retries: + time.sleep(compute_delay(None, attempt)) + attempt += 1 + continue + raise AwsysNetworkError(str(exc) or "Network error.") from exc + + if response.status_code == 429: + rate_limit_exc = parse_error(response) + assert isinstance(rate_limit_exc, AwsysRateLimitError) + if ( + is_quota_rate_limit(rate_limit_exc) + or is_retry_after_excessive(rate_limit_exc.retry_after) + or attempt >= self._max_retries + ): + raise rate_limit_exc + time.sleep(compute_delay(response, attempt)) + attempt += 1 + continue - if response.status_code == 429 and attempt < _MAX_RETRIES: - exc = _parse_error(response) - assert isinstance(exc, AwsysRateLimitError) - delay = exc.retry_after or (_RETRY_BASE_DELAY * (2 ** attempt)) - time.sleep(delay) + if ( + response.status_code in RETRYABLE_SERVER_STATUSES + and is_idempotent(method) + and attempt < self._max_retries + ): + time.sleep(compute_delay(response, attempt)) attempt += 1 continue if response.is_error: - raise _parse_error(response) + raise parse_error(response) # 204 No Content if response.status_code == 204 or not response.content: return None - return response.json() + try: + return response.json() + except ValueError as exc: + # A 2xx with a non-JSON body (e.g. an interstitial HTML page) is a + # platform-side anomaly — surface it as a typed SDK error, never a + # raw JSON-decode exception. + raise AwsysServerError( + f"Expected a JSON response but got non-JSON content: {response.text[:200]!r}", + status=response.status_code, + raw=response.text, + ) from exc - def get(self, path: str, *, params: Optional[Dict[str, Any]] = None) -> Any: - return self._request("GET", path, params=params) + def get( + self, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + ) -> Any: + return self._request("GET", path, params=params, timeout=timeout) - def get_text(self, path: str, *, params: Optional[Dict[str, Any]] = None) -> str: + def get_text( + self, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + ) -> str: """Like get() but returns response.text instead of response.json().""" attempt = 0 while True: - response = self._client.request("GET", path, params=params) + try: + response = self._client.request("GET", path, params=params, timeout=timeout) + except httpx.TimeoutException as exc: + if attempt < self._max_retries: + time.sleep(compute_delay(None, attempt)) + attempt += 1 + continue + raise AwsysTimeoutError(str(exc) or "Request timed out.") from exc + except httpx.TransportError as exc: + if attempt < self._max_retries: + time.sleep(compute_delay(None, attempt)) + attempt += 1 + continue + raise AwsysNetworkError(str(exc) or "Network error.") from exc - if response.status_code == 429 and attempt < _MAX_RETRIES: - exc = _parse_error(response) - assert isinstance(exc, AwsysRateLimitError) - delay = exc.retry_after or (_RETRY_BASE_DELAY * (2 ** attempt)) - time.sleep(delay) + if response.status_code == 429: + rate_limit_exc = parse_error(response) + assert isinstance(rate_limit_exc, AwsysRateLimitError) + if ( + is_quota_rate_limit(rate_limit_exc) + or is_retry_after_excessive(rate_limit_exc.retry_after) + or attempt >= self._max_retries + ): + raise rate_limit_exc + time.sleep(compute_delay(response, attempt)) + attempt += 1 + continue + + if response.status_code in RETRYABLE_SERVER_STATUSES and attempt < self._max_retries: + time.sleep(compute_delay(response, attempt)) attempt += 1 continue if response.is_error: - raise _parse_error(response) + raise parse_error(response) return response.text - def post(self, path: str, *, json: Optional[Any] = None) -> Any: - return self._request("POST", path, json=json) + def post( + self, path: str, *, json: Optional[Any] = None, timeout: Optional[float] = None + ) -> Any: + return self._request("POST", path, json=json, timeout=timeout) - def patch(self, path: str, *, json: Optional[Any] = None) -> Any: - return self._request("PATCH", path, json=json) + def patch( + self, path: str, *, json: Optional[Any] = None, timeout: Optional[float] = None + ) -> Any: + return self._request("PATCH", path, json=json, timeout=timeout) - def put(self, path: str, *, json: Optional[Any] = None) -> Any: - return self._request("PUT", path, json=json) + def put( + self, path: str, *, json: Optional[Any] = None, timeout: Optional[float] = None + ) -> Any: + return self._request("PUT", path, json=json, timeout=timeout) - def delete(self, path: str) -> Any: - return self._request("DELETE", path) + def delete(self, path: str, *, timeout: Optional[float] = None) -> Any: + return self._request("DELETE", path, timeout=timeout) def close(self) -> None: self._client.close() diff --git a/awsysco/_transport.py b/awsysco/_transport.py new file mode 100644 index 0000000..7ebb3eb --- /dev/null +++ b/awsysco/_transport.py @@ -0,0 +1,151 @@ +"""Shared error parsing and retry/backoff logic used by both the sync and async transports. + +Kept in one module so the two transports (``_http.py`` / ``_async_http.py``) cannot drift — +previously each had its own byte-for-byte copy of this logic. +""" + +from __future__ import annotations + +import math +import random +from email.utils import parsedate_to_datetime +from time import time as _now +from typing import Any, Dict, Optional + +import httpx + +from .exceptions import ( + AwsysAuthError, + AwsysConflictError, + AwsysError, + AwsysForbiddenError, + AwsysNotFoundError, + AwsysRateLimitError, + AwsysServerError, + AwsysValidationError, +) + +DEFAULT_MAX_RETRIES = 3 +_RETRY_BASE_DELAY = 1.0 # seconds +_RETRY_MAX_DELAY = 30.0 # seconds + +# Quota-class 429s cannot be helped by waiting a few seconds — never retry these. +QUOTA_ERROR_CODES = {"HOURLY_LIMIT_EXCEEDED", "MONTHLY_LIMIT_EXCEEDED", "DAILY_LIMIT_EXCEEDED"} + +RETRYABLE_SERVER_STATUSES = {502, 503, 504} +IDEMPOTENT_METHODS = {"GET", "PUT", "DELETE"} + + +def is_idempotent(method: str) -> bool: + """Whether ``method`` is safe to retry without an idempotency key.""" + return method.upper() in IDEMPOTENT_METHODS + + +def _parse_retry_after(value: Optional[str]) -> Optional[float]: + """Parse a ``Retry-After`` header value — either delta-seconds or an HTTP-date.""" + if not value: + return None + try: + return max(0.0, float(value)) + except ValueError: + pass + try: + dt = parsedate_to_datetime(value) + return max(0.0, dt.timestamp() - _now()) + except (TypeError, ValueError): + return None + + +def parse_error(response: "httpx.Response") -> AwsysError: + """Parse an HTTP error response into the matching :class:`AwsysError` subclass. + + Tolerates every error-body shape observed on the platform: + ``{error: true, code, message}``, ``{error: "", code}`` (the string is the + message), ``{error: true, code}`` with no message (synthesized from ``code``), + ``{success: false, message, code}``, and non-JSON bodies (falls back to the response + text, then to the HTTP status line). + """ + status = response.status_code + raw: Any = None + message: Optional[str] = None + code: Optional[str] = None + + try: + data = response.json() + except Exception: + data = None + + if isinstance(data, dict): + raw = data + code = data.get("code") if isinstance(data.get("code"), str) else None + error_field = data.get("error") + msg_field = data.get("message") + if isinstance(msg_field, str) and msg_field: + message = msg_field + elif isinstance(error_field, str) and error_field: + # e.g. agentlink.js: {error: "", code} + message = error_field + elif code: + # {error: true, code} with no message — synthesize a readable message + message = code.replace("_", " ").capitalize() + else: + text = response.text + raw = text or None + if text: + message = text + + if not message: + message = f"HTTP {status} {response.reason_phrase}".strip() + + kwargs: Dict[str, Any] = {"code": code, "status": status, "raw": raw} + + if status in (400, 422): + return AwsysValidationError(message, **kwargs) + if status == 401: + return AwsysAuthError(message, **kwargs) + if status == 403: + return AwsysForbiddenError(message, **kwargs) + if status == 404: + return AwsysNotFoundError(message, **kwargs) + if status == 409: + return AwsysConflictError(message, **kwargs) + if status == 429: + resets_at = data.get("resetsAt") if isinstance(data, dict) else None + retry_after = _parse_retry_after(response.headers.get("Retry-After")) + return AwsysRateLimitError( + message, retry_after=retry_after, resets_at=resets_at, **kwargs + ) + if 500 <= status < 600: + return AwsysServerError(message, **kwargs) + + return AwsysError(message, **kwargs) + + +def is_quota_rate_limit(exc: AwsysRateLimitError) -> bool: + """Whether a 429 is a quota exhaustion (never worth retrying) vs. a transient IP limit.""" + return (exc.code in QUOTA_ERROR_CODES) or exc.resets_at is not None + + +def is_retry_after_excessive(retry_after: Optional[float]) -> bool: + """Whether a parsed ``Retry-After`` value is too large (or non-finite) to wait out. + + A `Retry-After` beyond the retry cap (or ``inf``/``nan``) means retrying can't + plausibly help within this call — raise immediately instead of sleeping. + """ + if retry_after is None: + return False + return (not math.isfinite(retry_after)) or retry_after > _RETRY_MAX_DELAY + + +def compute_delay(response: Optional["httpx.Response"], attempt: int) -> float: + """Backoff delay for retry ``attempt`` (0-indexed), with full jitter. + + Uses the ``Retry-After`` header when present, otherwise ``1s * 2^attempt`` capped at + 30s. Full jitter: the actual sleep is a random value in ``[0, computed_delay]``. + """ + retry_after = _parse_retry_after(response.headers.get("Retry-After")) if response is not None else None + if retry_after is not None: + base = retry_after + else: + base = min(_RETRY_BASE_DELAY * (2**attempt), _RETRY_MAX_DELAY) + return random.uniform(0, base) diff --git a/awsysco/_version.py b/awsysco/_version.py new file mode 100644 index 0000000..8b1fcf7 --- /dev/null +++ b/awsysco/_version.py @@ -0,0 +1,8 @@ +"""Single source of truth for the installed package version. + +Kept separate from ``__init__.py`` so internal modules (e.g. the transports, for the +User-Agent header) can import it without triggering a circular import through the +package's public re-exports. +""" + +__version__ = "1.4.0" diff --git a/awsysco/async_resources/analytics.py b/awsysco/async_resources/analytics.py index e379d5c..510f2e8 100644 --- a/awsysco/async_resources/analytics.py +++ b/awsysco/async_resources/analytics.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import List, Optional +from typing import Any, Dict, List, Optional from .._async_http import AsyncHttpClient from ..models import AggregateAnalytics, ClickEvent, LinkStats @@ -13,7 +13,7 @@ def __init__(self, http: AsyncHttpClient) -> None: self._http = http async def get_stats(self, short_path: str, *, period: Optional[str] = None) -> LinkStats: - params = {} + params: Dict[str, Any] = {} if period is not None: params["period"] = period data = await self._http.get( @@ -25,7 +25,7 @@ async def get_stats(self, short_path: str, *, period: Optional[str] = None) -> L async def get_aggregate_stats( self, short_path: str, *, period: Optional[str] = None ) -> AggregateAnalytics: - params = {} + params: Dict[str, Any] = {} if period is not None: params["period"] = period data = await self._http.get( @@ -34,12 +34,18 @@ async def get_aggregate_stats( ) return AggregateAnalytics.model_validate(data) - async def get_recent_clicks(self, *, limit: Optional[int] = None) -> List[ClickEvent]: - params = {} + async def get_recent_clicks( + self, *, limit: Optional[int] = None, since: Optional[str] = None + ) -> List[ClickEvent]: + """Requires the "Live Globe" feature flag; disabled accounts get a 403 + ``FEATURE_DISABLED`` (surfaces as ``AwsysForbiddenError``).""" + params: Dict[str, Any] = {} if limit is not None: params["limit"] = limit + if since is not None: + params["since"] = since data = await self._http.get( - "/api/user/recent-clicks", + "/api/user/clicks/recent", params=params if params else None, ) if isinstance(data, list): diff --git a/awsysco/async_resources/bulk.py b/awsysco/async_resources/bulk.py index a3fa55c..ac545cc 100644 --- a/awsysco/async_resources/bulk.py +++ b/awsysco/async_resources/bulk.py @@ -7,27 +7,38 @@ from .._async_http import AsyncHttpClient from ..models import BulkResult +# Maps either the snake_case or camelCase key a caller might use to the wire key. +_KEY_ALIASES: Dict[str, str] = { + "custom_slug": "customSlug", + "customSlug": "customSlug", + "expires_at": "expiresAt", + "expiresAt": "expiresAt", + "max_clicks": "maxClicks", + "maxClicks": "maxClicks", +} + + +def _normalize_bulk_item(item: Dict[str, Any]) -> Dict[str, Any]: + """Map a caller-supplied link dict (snake_case or camelCase) to the wire shape.""" + entry: Dict[str, Any] = {"url": item["url"]} + for key, value in item.items(): + wire_key = _KEY_ALIASES.get(key) + if wire_key is not None: + entry[wire_key] = value + return entry + class AsyncBulkResource: def __init__(self, http: AsyncHttpClient) -> None: self._http = http async def create(self, urls: List[Dict[str, Any]]) -> BulkResult: - payload: List[Dict[str, Any]] = [] - for item in urls: - entry: Dict[str, Any] = {"url": item["url"]} - if "custom_slug" in item: - entry["customSlug"] = item["custom_slug"] - if "customSlug" in item: - entry["customSlug"] = item["customSlug"] - if "expires_at" in item: - entry["expiresAt"] = item["expires_at"] - if "expiresAt" in item: - entry["expiresAt"] = item["expiresAt"] - if "max_clicks" in item: - entry["maxClicks"] = item["max_clicks"] - if "maxClicks" in item: - entry["maxClicks"] = item["maxClicks"] - payload.append(entry) + payload = [_normalize_bulk_item(item) for item in urls] data = await self._http.post("/api/v1/bulk", json={"urls": payload}) + # Normalise: API sometimes wraps counts under a "summary" key + if isinstance(data, dict) and "summary" in data and "created" not in data: + summary = data["summary"] + data = dict(data) + data.setdefault("created", summary.get("created")) + data.setdefault("failed", summary.get("failed")) return BulkResult.model_validate(data) diff --git a/awsysco/async_resources/custom_domains.py b/awsysco/async_resources/custom_domains.py index 9ea55f2..f58818a 100644 --- a/awsysco/async_resources/custom_domains.py +++ b/awsysco/async_resources/custom_domains.py @@ -2,9 +2,11 @@ from __future__ import annotations +import warnings from typing import Any, Dict, Optional from .._async_http import AsyncHttpClient +from ..exceptions import AwsysForbiddenError from ..models import CustomDomain @@ -22,15 +24,36 @@ async def verify(self, domain: str) -> dict: return await self._http.get(f"/api/user/domains/{domain}/verify") or {} async def activate(self, domain: str) -> CustomDomain: - data = await self._http.post(f"/api/user/domains/{domain}/activate") - return CustomDomain.model_validate(data) + """Deprecated: Firebase-only route, unreachable with an API key. See the sync + ``CustomDomainsResource.activate`` docstring; removed in the next major version.""" + warnings.warn( + "custom_domains.activate() is deprecated: this route is Firebase-only and " + "cannot be called with an API key. Activate the domain from the AWSYS " + "dashboard instead. This method will be removed in the next major version.", + DeprecationWarning, + stacklevel=2, + ) + raise AwsysForbiddenError( + f"Cannot activate domain {domain!r} with an API key — " + "POST /api/user/domains/:domain/activate requires Firebase auth. " + "Activate the domain from the AWSYS dashboard instead." + ) - async def update(self, domain: str, *, is_default: Optional[bool] = None, not_found_html: Optional[str] = None) -> CustomDomain: + async def update( + self, + domain: str, + *, + is_default: Optional[bool] = None, + not_found_html: Optional[str] = None, + default_redirect: Optional[str] = None, + ) -> CustomDomain: body: Dict[str, Any] = {} if is_default is not None: body["isDefault"] = is_default if not_found_html is not None: body["notFoundHtml"] = not_found_html + if default_redirect is not None: + body["defaultRedirect"] = default_redirect data = await self._http.patch(f"/api/user/domains/{domain}", json=body) return CustomDomain.model_validate(data) diff --git a/awsysco/async_resources/folders.py b/awsysco/async_resources/folders.py index 9a4bc44..5bc56fd 100644 --- a/awsysco/async_resources/folders.py +++ b/awsysco/async_resources/folders.py @@ -29,7 +29,9 @@ async def update(self, folder_id: str, *, name: Optional[str] = None, color: Opt body["name"] = name if color is not None: body["color"] = color - data = await self._http.patch(f"/api/v1/folders/{folder_id}", json=body) + # No /api/v1 alias exists for this route on the platform — only the + # unversioned path works (confirmed live: the v1 path 404s). + data = await self._http.patch(f"/api/folders/{folder_id}", json=body) return Folder.model_validate(data) async def delete(self, folder_id: str) -> None: diff --git a/awsysco/async_resources/imports.py b/awsysco/async_resources/imports.py index a4e7c2c..458a046 100644 --- a/awsysco/async_resources/imports.py +++ b/awsysco/async_resources/imports.py @@ -4,7 +4,7 @@ import asyncio import time -from typing import List, Optional +from typing import Any, Dict, List, Optional from urllib.parse import quote from .._async_http import AsyncHttpClient @@ -24,18 +24,17 @@ async def start( provider: str, access_token: str, target_namespace: Optional[str] = None, + scope_filter: Optional[str] = None, scan_only: Optional[bool] = None, ) -> ImportJob: - """Start a new provider link-import job. - - The request body uses snake_case keys (``provider``, ``access_token``, - optional ``target_namespace`` / ``scan_only``). - """ - body = {"provider": provider, "access_token": access_token} + """Start a new provider link-import job.""" + body: Dict[str, Any] = {"provider": provider, "accessToken": access_token} if target_namespace is not None: - body["target_namespace"] = target_namespace + body["targetNamespace"] = target_namespace + if scope_filter is not None: + body["scopeFilter"] = scope_filter if scan_only is not None: - body["scan_only"] = scan_only + body["scanOnly"] = scan_only data = await self._http.post("/api/v1/imports", json=body) return ImportJob.model_validate(data) @@ -53,7 +52,7 @@ async def cancel(self, job_id: str) -> ImportJob: async def list(self, *, limit: Optional[int] = None) -> List[ImportJob]: """List import jobs for the authenticated user.""" - params = {} + params: Dict[str, Any] = {} if limit is not None: params["limit"] = limit data = await self._http.get( @@ -63,6 +62,16 @@ async def list(self, *, limit: Optional[int] = None) -> List[ImportJob]: items = data.get("jobs", []) if isinstance(data, dict) else (data or []) return [ImportJob.model_validate(item) for item in items] + async def get_redirect_map_csv(self, job_id: str) -> str: + """Download the redirect map for a completed import job, as CSV.""" + encoded = quote(job_id, safe="") + return await self._http.get_text(f"/api/v1/imports/{encoded}/redirect-map.csv") + + async def get_redirect_map_json(self, job_id: str) -> Any: + """Download the redirect map for a completed import job, as JSON.""" + encoded = quote(job_id, safe="") + return await self._http.get(f"/api/v1/imports/{encoded}/redirect-map.json") + async def wait_for_completion( self, job_id: str, diff --git a/awsysco/async_resources/links.py b/awsysco/async_resources/links.py index 02ce8ae..f42b88e 100644 --- a/awsysco/async_resources/links.py +++ b/awsysco/async_resources/links.py @@ -2,10 +2,20 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional +from typing import Any, AsyncIterator, Dict, List, Optional, Union +from urllib.parse import quote from .._async_http import AsyncHttpClient -from ..models import Link, LinkList +from ..models import GeoRestriction, Link, LinkList, OgMeta, RoutingRule + +_MAX_PAGE_SIZE = 100 + + +def _to_dict(value: Optional[Union[Dict[str, Any], Any]]) -> Optional[Dict[str, Any]]: + """Accept either a plain dict or one of the typed request models.""" + if value is None or isinstance(value, dict): + return value + return value.model_dump(by_alias=True, exclude_none=True) class AsyncLinksResource: @@ -19,9 +29,9 @@ async def create( custom_slug: Optional[str] = None, expires_at: Optional[str] = None, max_clicks: Optional[int] = None, - routing_rules: Optional[List[Dict[str, str]]] = None, - og_meta: Optional[Dict[str, str]] = None, - geo_restriction: Optional[Dict[str, List[str]]] = None, + routing_rules: Optional[List[Union[Dict[str, str], RoutingRule]]] = None, + og_meta: Optional[Union[Dict[str, str], OgMeta]] = None, + geo_restriction: Optional[Union[Dict[str, List[str]], GeoRestriction]] = None, password: Optional[str] = None, pass_ad_click_ids: Optional[bool] = None, folder_id: Optional[str] = None, @@ -35,11 +45,11 @@ async def create( if max_clicks is not None: body["maxClicks"] = max_clicks if routing_rules is not None: - body["routingRules"] = routing_rules + body["routingRules"] = [_to_dict(rule) for rule in routing_rules] if og_meta is not None: - body["ogMeta"] = og_meta + body["ogMeta"] = _to_dict(og_meta) if geo_restriction is not None: - body["geoRestriction"] = geo_restriction + body["geoRestriction"] = _to_dict(geo_restriction) if password is not None: body["password"] = password if pass_ad_click_ids is not None: @@ -52,9 +62,28 @@ async def create( return Link.model_validate(data) async def list(self, *, limit: int = 20, offset: int = 0) -> LinkList: + limit = min(limit, _MAX_PAGE_SIZE) data = await self._http.get("/api/v1/links", params={"limit": limit, "offset": offset}) return LinkList.model_validate(data) + async def list_all(self, *, limit: int = 100) -> AsyncIterator[Link]: + """Async-iterate over every link, auto-paginating with ``limit``/``offset``. + + Stops when the platform reports ``has_more=False``, or a page comes back + shorter than ``limit`` (including empty). + """ + limit = min(limit, _MAX_PAGE_SIZE) + offset = 0 + while True: + page = await self.list(limit=limit, offset=offset) + for link in page.links: + yield link + if page.has_more is False: + return + if len(page.links) < limit: + return + offset += limit + async def get(self, short_path: str) -> Link: data = await self._http.get(f"/api/v1/links/{short_path}") return Link.model_validate(data) @@ -66,9 +95,9 @@ async def update( url: Optional[str] = None, expires_at: Optional[str] = None, max_clicks: Optional[int] = None, - routing_rules: Optional[List[Dict[str, str]]] = None, - og_meta: Optional[Dict[str, str]] = None, - geo_restriction: Optional[Dict[str, List[str]]] = None, + routing_rules: Optional[List[Union[Dict[str, str], RoutingRule]]] = None, + og_meta: Optional[Union[Dict[str, str], OgMeta]] = None, + geo_restriction: Optional[Union[Dict[str, List[str]], GeoRestriction]] = None, password: Optional[str] = None, pass_ad_click_ids: Optional[bool] = None, folder_id: Optional[str] = None, @@ -82,11 +111,11 @@ async def update( if max_clicks is not None: body["maxClicks"] = max_clicks if routing_rules is not None: - body["routingRules"] = routing_rules + body["routingRules"] = [_to_dict(rule) for rule in routing_rules] if og_meta is not None: - body["ogMeta"] = og_meta + body["ogMeta"] = _to_dict(og_meta) if geo_restriction is not None: - body["geoRestriction"] = geo_restriction + body["geoRestriction"] = _to_dict(geo_restriction) if password is not None: body["password"] = password if pass_ad_click_ids is not None: @@ -95,7 +124,9 @@ async def update( body["folderId"] = folder_id if tags is not None: body["tags"] = tags - data = await self._http.patch(f"/api/v1/links/{short_path}", json=body) + data = await self._http.patch( + f"/api/v1/links/{quote(short_path, safe='')}", json=body + ) return Link.model_validate(data) async def delete(self, short_path: str) -> None: diff --git a/awsysco/async_resources/profile.py b/awsysco/async_resources/profile.py new file mode 100644 index 0000000..f10700c --- /dev/null +++ b/awsysco/async_resources/profile.py @@ -0,0 +1,45 @@ +"""Profile resource (async) — the authenticated user's account profile.""" + +from __future__ import annotations + +from typing import Any, Dict + +from pydantic.alias_generators import to_camel + +from .._async_http import AsyncHttpClient +from ..models import Profile + + +class AsyncProfileResource: + """Interact with /api/user/profile.""" + + def __init__(self, http: AsyncHttpClient) -> None: + self._http = http + + async def get(self) -> Profile: + """Get the authenticated user's account profile. + + Distinct from :meth:`AsyncMeResource.get` (subscription/feature summary) and + :meth:`AsyncUsageResource.get` (live consumption counters) — this returns account + profile fields (display name, email, etc). + + Returns: + A Profile object. + """ + data = await self._http.get("/api/user/profile") + return Profile.model_validate(data) + + async def update(self, **kwargs: Any) -> Profile: + """Update the authenticated user's account profile. + + Args: + **kwargs: Profile fields to update (e.g. ``display_name``). snake_case + keys are converted to camelCase for the wire; already-camelCase keys + pass through unchanged. + + Returns: + The updated Profile object. + """ + body: Dict[str, Any] = {to_camel(k): v for k, v in kwargs.items()} + data = await self._http.patch("/api/user/profile", json=body) + return Profile.model_validate(data) diff --git a/awsysco/async_resources/qr.py b/awsysco/async_resources/qr.py index b91cd35..7426fb2 100644 --- a/awsysco/async_resources/qr.py +++ b/awsysco/async_resources/qr.py @@ -12,7 +12,7 @@ class AsyncQRResource: def __init__(self, http: AsyncHttpClient) -> None: self._http = http - self._base_url = http._base_url + self._base_url = http.base_url def get_url(self, short_code: str, *, size: int = 300, color: str = "000000", bg_color: str = "FFFFFF") -> str: params = urlencode({"size": size, "color": color, "bgColor": bg_color}) diff --git a/awsysco/async_resources/tags.py b/awsysco/async_resources/tags.py index d354c68..9a3b82b 100644 --- a/awsysco/async_resources/tags.py +++ b/awsysco/async_resources/tags.py @@ -13,7 +13,7 @@ def __init__(self, http: AsyncHttpClient) -> None: async def add(self, short_path: str, tag: str) -> dict: encoded = quote(short_path, safe="") - return await self._http.post(f"/api/link/{encoded}/tags", json={"tag": tag}) or {} + return await self._http.post(f"/api/link/{encoded}/tags", json={"tags": [tag]}) or {} async def remove(self, short_path: str, tag: str) -> dict: encoded = quote(short_path, safe="") diff --git a/awsysco/async_resources/webhooks.py b/awsysco/async_resources/webhooks.py index 7d59d95..d6bb9cd 100644 --- a/awsysco/async_resources/webhooks.py +++ b/awsysco/async_resources/webhooks.py @@ -16,7 +16,7 @@ async def list_event_types(self) -> dict: return await self._http.get("/api/webhooks/event-types") or {} async def list(self) -> dict: - return await self._http.get("/api/webhooks") or {} + return await self._http.get("/api/v1/webhooks") or {} async def create(self, url: str, events: List[str], *, name: Optional[str] = None, secret: Optional[str] = None) -> Webhook: body: Dict[str, Any] = {"url": url, "events": events} @@ -24,10 +24,11 @@ async def create(self, url: str, events: List[str], *, name: Optional[str] = Non body["name"] = name if secret is not None: body["secret"] = secret - data = await self._http.post("/api/webhooks", json=body) + data = await self._http.post("/api/v1/webhooks", json=body) return Webhook.model_validate(data) async def update(self, webhook_id: str, **kwargs: Any) -> Webhook: + # No /api/v1 alias exists for this route on the platform. body: Dict[str, Any] = {} key_map = {"url": "url", "events": "events", "name": "name", "secret": "secret", "enabled": "enabled"} for k, v in kwargs.items(): @@ -36,7 +37,7 @@ async def update(self, webhook_id: str, **kwargs: Any) -> Webhook: return Webhook.model_validate(data) async def delete(self, webhook_id: str) -> dict: - return await self._http.delete(f"/api/webhooks/{webhook_id}") or {} + return await self._http.delete(f"/api/v1/webhooks/{webhook_id}") or {} async def test(self, webhook_id: str, event_type: str) -> dict: - return await self._http.post(f"/api/webhooks/{webhook_id}/test", json={"eventType": event_type}) or {} + return await self._http.post(f"/api/v1/webhooks/{webhook_id}/test", json={"eventType": event_type}) or {} diff --git a/awsysco/client.py b/awsysco/client.py index 548dc3a..70f8681 100644 --- a/awsysco/client.py +++ b/awsysco/client.py @@ -2,10 +2,13 @@ from __future__ import annotations +import os +import warnings from typing import Optional from ._async_http import AsyncHttpClient from ._http import HttpClient +from ._transport import DEFAULT_MAX_RETRIES from .async_resources.affiliate import AsyncAffiliateResource from .async_resources.agentlink import AsyncAgentlinkResource from .async_resources.analytics import AsyncAnalyticsResource @@ -17,6 +20,7 @@ from .async_resources.links import AsyncLinksResource from .async_resources.me import AsyncMeResource from .async_resources.namespace import AsyncNamespaceResource +from .async_resources.profile import AsyncProfileResource from .async_resources.qr import AsyncQRResource from .async_resources.saved_views import AsyncSavedViewsResource from .async_resources.tags import AsyncTagsResource @@ -25,6 +29,7 @@ from .async_resources.utm_templates import AsyncUtmTemplatesResource from .async_resources.web2app import AsyncWeb2AppResource from .async_resources.webhooks import AsyncWebhooksResource +from .exceptions import AwsysConfigurationError from .resources.affiliate import AffiliateResource from .resources.agentlink import AgentlinkResource from .resources.analytics import AnalyticsResource @@ -36,6 +41,7 @@ from .resources.links import LinksResource from .resources.me import MeResource from .resources.namespace import NamespaceResource +from .resources.profile import ProfileResource from .resources.qr import QRResource from .resources.saved_views import SavedViewsResource from .resources.tags import TagsResource @@ -48,6 +54,33 @@ _DEFAULT_BASE_URL = "https://awsys.co" +_warned_non_awsys_key = False + + +def _resolve_api_key(api_key: Optional[str]) -> str: + global _warned_non_awsys_key + resolved = api_key if api_key is not None else os.environ.get("AWSYS_API_KEY") + if not resolved: + raise AwsysConfigurationError( + "No API key provided. Pass api_key=... or set the AWSYS_API_KEY " + "environment variable." + ) + if not resolved.startswith("awsys_") and not _warned_non_awsys_key: + _warned_non_awsys_key = True + warnings.warn( + "This does not look like an AWSYS API key (expected it to start with " + "'awsys_').", + stacklevel=3, + ) + return resolved + + +def _resolve_base_url(base_url: Optional[str]) -> str: + if base_url is not None: + return base_url + return os.environ.get("AWSYS_BASE_URL", _DEFAULT_BASE_URL) + + class Client: """Top-level synchronous client for the AWSYS.CO API. @@ -62,19 +95,33 @@ class Client: print(link.short_url) Args: - api_key: Your AWSYS API key (starts with ``awsys_``). - base_url: API base URL. Defaults to ``https://awsys.co``. - timeout: HTTP request timeout in seconds (default 30). + api_key: Your AWSYS API key (starts with ``awsys_``). Falls back to the + ``AWSYS_API_KEY`` environment variable; raises :class:`AwsysConfigurationError` + if neither is set. + base_url: API base URL. Falls back to the ``AWSYS_BASE_URL`` environment variable, + then ``https://awsys.co``. Must start with ``http://`` or ``https://``. + timeout: HTTP request timeout in seconds (default 30). Overridable per call via + each resource method's underlying transport. + max_retries: Maximum retry attempts for 429s and (for idempotent methods) + 502/503/504/transport errors. ``0`` disables retries. """ def __init__( self, - api_key: str, + api_key: Optional[str] = None, *, - base_url: str = _DEFAULT_BASE_URL, + base_url: Optional[str] = None, timeout: float = 30.0, + max_retries: int = DEFAULT_MAX_RETRIES, ) -> None: - self._http = HttpClient(api_key=api_key, base_url=base_url, timeout=timeout) + resolved_key = _resolve_api_key(api_key) + resolved_url = _resolve_base_url(base_url) + self._http = HttpClient( + api_key=resolved_key, + base_url=resolved_url, + timeout=timeout, + max_retries=max_retries, + ) # Core resources self.links = LinksResource(self._http) @@ -102,11 +149,15 @@ def __init__( self.usage = UsageResource(self._http) self.web2app = Web2AppResource(self._http) self.imports = ImportsResource(self._http) + self.profile = ProfileResource(self._http) def close(self) -> None: """Close the underlying HTTP connection pool.""" self._http.close() + def __repr__(self) -> str: + return f"Client(base_url={self._http.base_url!r}, api_key={self._http.redacted_key!r})" + def __enter__(self) -> "Client": return self @@ -126,19 +177,32 @@ class AsyncClient: print(link.short_url) Args: - api_key: Your AWSYS API key (starts with ``awsys_``). - base_url: API base URL. Defaults to ``https://awsys.co``. + api_key: Your AWSYS API key (starts with ``awsys_``). Falls back to the + ``AWSYS_API_KEY`` environment variable; raises :class:`AwsysConfigurationError` + if neither is set. + base_url: API base URL. Falls back to the ``AWSYS_BASE_URL`` environment variable, + then ``https://awsys.co``. Must start with ``http://`` or ``https://``. timeout: HTTP request timeout in seconds (default 30). + max_retries: Maximum retry attempts for 429s and (for idempotent methods) + 502/503/504/transport errors. ``0`` disables retries. """ def __init__( self, - api_key: str, + api_key: Optional[str] = None, *, - base_url: str = _DEFAULT_BASE_URL, + base_url: Optional[str] = None, timeout: float = 30.0, + max_retries: int = DEFAULT_MAX_RETRIES, ) -> None: - self._http = AsyncHttpClient(api_key=api_key, base_url=base_url, timeout=timeout) + resolved_key = _resolve_api_key(api_key) + resolved_url = _resolve_base_url(base_url) + self._http = AsyncHttpClient( + api_key=resolved_key, + base_url=resolved_url, + timeout=timeout, + max_retries=max_retries, + ) # Core resources self.links = AsyncLinksResource(self._http) @@ -166,11 +230,15 @@ def __init__( self.usage = AsyncUsageResource(self._http) self.web2app = AsyncWeb2AppResource(self._http) self.imports = AsyncImportsResource(self._http) + self.profile = AsyncProfileResource(self._http) async def aclose(self) -> None: """Close the underlying async HTTP connection pool.""" await self._http.aclose() + def __repr__(self) -> str: + return f"AsyncClient(base_url={self._http.base_url!r}, api_key={self._http.redacted_key!r})" + async def __aenter__(self) -> "AsyncClient": return self diff --git a/awsysco/exceptions.py b/awsysco/exceptions.py index 8cc7c3f..c1ce587 100644 --- a/awsysco/exceptions.py +++ b/awsysco/exceptions.py @@ -99,8 +99,54 @@ def __init__( message: str = "Rate limit exceeded. Please slow down.", *, retry_after: Optional[float] = None, + resets_at: Optional[str] = None, **kwargs: Any, ) -> None: kwargs.setdefault("status", 429) super().__init__(message, **kwargs) self.retry_after = retry_after + self.resets_at = resets_at + + +class AwsysServerError(AwsysError): + """5xx — the platform reported an internal error.""" + + def __init__( + self, + message: str = "Server error. Please try again later.", + **kwargs: Any, + ) -> None: + super().__init__(message, **kwargs) + + +class AwsysNetworkError(AwsysError): + """A transport-level failure (connection reset/refused, DNS, etc.) with no HTTP response.""" + + def __init__( + self, + message: str = "Network error while contacting the AWSYS API.", + **kwargs: Any, + ) -> None: + super().__init__(message, **kwargs) + + +class AwsysTimeoutError(AwsysNetworkError): + """A request did not complete within the configured timeout.""" + + def __init__( + self, + message: str = "Request to the AWSYS API timed out.", + **kwargs: Any, + ) -> None: + super().__init__(message, **kwargs) + + +class AwsysConfigurationError(AwsysError): + """Invalid SDK configuration (missing API key, bad base URL) — raised before any network call.""" + + def __init__( + self, + message: str = "Invalid AWSYS SDK configuration.", + **kwargs: Any, + ) -> None: + super().__init__(message, **kwargs) diff --git a/awsysco/models.py b/awsysco/models.py index 7b9b7db..4c38e8a 100644 --- a/awsysco/models.py +++ b/awsysco/models.py @@ -2,11 +2,40 @@ from __future__ import annotations +from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Union -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic.alias_generators import to_camel + +def _coerce_firestore_timestamps(data: Any) -> Any: + """Convert any top-level Firestore ``{_seconds,_nanoseconds}``/``{seconds,nanoseconds}`` + value to an ISO-8601 string, in place, before field validation runs. + + Per the cross-SDK contract, timestamp fields must accept both plain ISO-8601 + strings and Firestore's raw timestamp shape. Fields stay typed as ``Optional[str]`` + (upgrading to a native ``datetime`` would be a breaking type change for a minor + release) — an unrecognized shape is left untouched rather than raising, since a + parse failure here must never crash model validation. + """ + if not isinstance(data, dict): + return data + result = dict(data) + for key, value in data.items(): + if not isinstance(value, dict): + continue + seconds = value.get("_seconds", value.get("seconds")) + nanos = value.get("_nanoseconds", value.get("nanoseconds", 0)) + if not isinstance(seconds, (int, float)): + continue + try: + dt = datetime.fromtimestamp(seconds + (nanos or 0) / 1e9, tz=timezone.utc) + result[key] = dt.isoformat().replace("+00:00", "Z") + except (OverflowError, OSError, ValueError): + pass # leave the raw value in place — never crash on a bad timestamp + return result + __all__ = [ "Link", "LinkList", @@ -42,6 +71,7 @@ "UTMBreakdown", "UpgradeForMore", "AggregateAnalytics", + "Profile", ] @@ -54,6 +84,11 @@ class _CamelModel(BaseModel): extra="allow", ) + @model_validator(mode="before") + @classmethod + def _coerce_timestamps(cls, data: Any) -> Any: + return _coerce_firestore_timestamps(data) + # --------------------------------------------------------------------------- # Link models @@ -66,6 +101,8 @@ class Link(_CamelModel): id: Optional[str] = None short_url: Optional[str] = None short_code: Optional[str] = None + full_path: Optional[str] = None + namespace: Optional[str] = None long: Optional[str] = None clicks: Optional[int] = None created: Optional[str] = None @@ -219,12 +256,18 @@ class QRSettings(_CamelModel): class TrustScoreResult(_CamelModel): - """Result of a URL trust/safety scan.""" + """Result of a URL trust/safety scan. + + Wire keys are ``shortCode``/``trustScore``/``trustStatus`` — ``short``/``long`` + are kept as separate (currently unpopulated) fields since the platform doesn't + send them under those names; removing them would be a breaking change. + """ short: Optional[str] = None long: Optional[str] = None - score: Optional[float] = None - status: Optional[str] = None + short_code: Optional[str] = None + score: Optional[float] = Field(default=None, alias="trustScore") + status: Optional[str] = Field(default=None, alias="trustStatus") threats: Optional[List[str]] = None scanned_at: Optional[str] = None @@ -275,17 +318,32 @@ class UtmTemplate(_CamelModel): class Webhook(_CamelModel): - """A registered webhook endpoint.""" + """A registered webhook endpoint. + + Legacy webhook documents on the platform may omit every field but + ``id``/``url``/``events`` (no ``enabled``, ``secret``, etc.) — all other fields + stay ``Optional`` and default to ``None`` rather than a guessed default. + """ id: Optional[str] = None url: Optional[str] = None events: List[str] = Field(default_factory=list) name: Optional[str] = None + secret: Optional[str] = None enabled: Optional[bool] = None created_at: Optional[str] = None updated_at: Optional[str] = None last_triggered: Optional[str] = None failure_count: Optional[int] = None + success_count: Optional[int] = None + + def __repr__(self) -> str: + # `secret` is a webhook signing secret — never include it in reprs/logs. + data = self.model_dump(by_alias=False) + if data.get("secret") is not None: + data["secret"] = "" + fields = ", ".join(f"{k}={v!r}" for k, v in data.items()) + return f"{self.__class__.__name__}({fields})" # --------------------------------------------------------------------------- @@ -327,6 +385,7 @@ class CustomDomain(_CamelModel): is_default: Optional[bool] = None link_count: Optional[int] = None created_at: Optional[str] = None + default_redirect: Optional[str] = None # --------------------------------------------------------------------------- @@ -523,3 +582,22 @@ class AggregateAnalytics(_CamelModel): hour_breakdown: Optional[List[HourClicks]] = None utm_breakdown: Optional[UTMBreakdown] = None upgrade_for_more: Optional[UpgradeForMore] = None + + +# --------------------------------------------------------------------------- +# Profile model — /api/user/profile +# --------------------------------------------------------------------------- + + +class Profile(_CamelModel): + """The authenticated user's account profile. + + Distinct from :class:`MeResponse` (subscription tier/feature summary) and + :class:`UsageStats` (live consumption counters). Unknown fields returned by the + platform are preserved (``extra="allow"`` on the base model). + """ + + uid: Optional[str] = None + email: Optional[str] = None + display_name: Optional[str] = None + created_at: Optional[str] = None diff --git a/awsysco/resources/analytics.py b/awsysco/resources/analytics.py index f0c3c8a..c6ece66 100644 --- a/awsysco/resources/analytics.py +++ b/awsysco/resources/analytics.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import List, Optional +from typing import Any, Dict, List, Optional from .._http import HttpClient from ..models import AggregateAnalytics, ClickEvent, LinkStats @@ -25,7 +25,7 @@ def get_stats(self, short_path: str, *, period: Optional[str] = None) -> LinkSta Returns: A LinkStats object with total_clicks and per-click breakdown. """ - params = {} + params: Dict[str, Any] = {} if period is not None: params["period"] = period data = self._http.get( @@ -53,7 +53,7 @@ def get_aggregate_stats( Returns: An AggregateAnalytics object. """ - params = {} + params: Dict[str, Any] = {} if period is not None: params["period"] = period data = self._http.get( @@ -62,20 +62,29 @@ def get_aggregate_stats( ) return AggregateAnalytics.model_validate(data) - def get_recent_clicks(self, *, limit: Optional[int] = None) -> List[ClickEvent]: + def get_recent_clicks( + self, *, limit: Optional[int] = None, since: Optional[str] = None + ) -> List[ClickEvent]: """Get recent click events across all links for the authenticated user. + Requires the "Live Globe" feature flag to be enabled on the account — if it + isn't, the platform returns 403 with ``code="FEATURE_DISABLED"``, which surfaces + as :class:`~awsysco.exceptions.AwsysForbiddenError`. + Args: - limit: Maximum number of click events to return. + limit: Maximum number of click events to return (platform max 50). + since: Only return clicks after this ISO-8601 timestamp. Returns: A list of ClickEvent objects. """ - params = {} + params: Dict[str, Any] = {} if limit is not None: params["limit"] = limit + if since is not None: + params["since"] = since data = self._http.get( - "/api/user/recent-clicks", + "/api/user/clicks/recent", params=params if params else None, ) if isinstance(data, list): diff --git a/awsysco/resources/bulk.py b/awsysco/resources/bulk.py index b3a2b8b..d003ebb 100644 --- a/awsysco/resources/bulk.py +++ b/awsysco/resources/bulk.py @@ -7,6 +7,26 @@ from .._http import HttpClient from ..models import BulkResult +# Maps either the snake_case or camelCase key a caller might use to the wire key. +_KEY_ALIASES: Dict[str, str] = { + "custom_slug": "customSlug", + "customSlug": "customSlug", + "expires_at": "expiresAt", + "expiresAt": "expiresAt", + "max_clicks": "maxClicks", + "maxClicks": "maxClicks", +} + + +def _normalize_bulk_item(item: Dict[str, Any]) -> Dict[str, Any]: + """Map a caller-supplied link dict (snake_case or camelCase) to the wire shape.""" + entry: Dict[str, Any] = {"url": item["url"]} + for key, value in item.items(): + wire_key = _KEY_ALIASES.get(key) + if wire_key is not None: + entry[wire_key] = value + return entry + class BulkResource: """Interact with /api/v1/bulk.""" @@ -32,23 +52,7 @@ def create(self, urls: List[Dict[str, Any]]) -> BulkResult: Returns: A BulkResult with created/failed counts and per-URL results. """ - # Map snake_case keys to camelCase for the API - payload: List[Dict[str, Any]] = [] - for item in urls: - entry: Dict[str, Any] = {"url": item["url"]} - if "custom_slug" in item: - entry["customSlug"] = item["custom_slug"] - if "customSlug" in item: - entry["customSlug"] = item["customSlug"] - if "expires_at" in item: - entry["expiresAt"] = item["expires_at"] - if "expiresAt" in item: - entry["expiresAt"] = item["expiresAt"] - if "max_clicks" in item: - entry["maxClicks"] = item["max_clicks"] - if "maxClicks" in item: - entry["maxClicks"] = item["maxClicks"] - payload.append(entry) + payload = [_normalize_bulk_item(item) for item in urls] data = self._http.post("/api/v1/bulk", json={"urls": payload}) # Normalise: API sometimes wraps counts under a "summary" key diff --git a/awsysco/resources/custom_domains.py b/awsysco/resources/custom_domains.py index 526c162..d2d49d3 100644 --- a/awsysco/resources/custom_domains.py +++ b/awsysco/resources/custom_domains.py @@ -2,9 +2,11 @@ from __future__ import annotations +import warnings from typing import Any, Dict, Optional from .._http import HttpClient +from ..exceptions import AwsysForbiddenError from ..models import CustomDomain @@ -47,14 +49,30 @@ def verify(self, domain: str) -> dict: def activate(self, domain: str) -> CustomDomain: """Activate a verified domain. + .. deprecated:: + This route (``POST /api/user/domains/:domain/activate``) is Firebase-only + (``requireAuthStrict``) and cannot be called with an API key — it always + 401s. Deprecated in 1.4.0, will be removed in the next major version. Use + the AWSYS dashboard to activate a domain instead. + Args: domain: The domain hostname to activate. - Returns: - The activated CustomDomain object. + Raises: + AwsysForbiddenError: Always — this route is not reachable with an API key. """ - data = self._http.post(f"/api/user/domains/{domain}/activate") - return CustomDomain.model_validate(data) + warnings.warn( + "custom_domains.activate() is deprecated: this route is Firebase-only and " + "cannot be called with an API key. Activate the domain from the AWSYS " + "dashboard instead. This method will be removed in the next major version.", + DeprecationWarning, + stacklevel=2, + ) + raise AwsysForbiddenError( + f"Cannot activate domain {domain!r} with an API key — " + "POST /api/user/domains/:domain/activate requires Firebase auth. " + "Activate the domain from the AWSYS dashboard instead." + ) def update( self, @@ -62,6 +80,7 @@ def update( *, is_default: Optional[bool] = None, not_found_html: Optional[str] = None, + default_redirect: Optional[str] = None, ) -> CustomDomain: """Update custom domain settings. @@ -69,6 +88,8 @@ def update( domain: The domain hostname to update. is_default: Whether this domain should be the default. not_found_html: Custom HTML for 404 pages on this domain. + default_redirect: URL to redirect to when a short path on this domain + isn't found. Returns: The updated CustomDomain object. @@ -78,6 +99,8 @@ def update( body["isDefault"] = is_default if not_found_html is not None: body["notFoundHtml"] = not_found_html + if default_redirect is not None: + body["defaultRedirect"] = default_redirect data = self._http.patch(f"/api/user/domains/{domain}", json=body) return CustomDomain.model_validate(data) diff --git a/awsysco/resources/folders.py b/awsysco/resources/folders.py index ce2ff26..a6b1e0d 100644 --- a/awsysco/resources/folders.py +++ b/awsysco/resources/folders.py @@ -62,7 +62,9 @@ def update( body["name"] = name if color is not None: body["color"] = color - data = self._http.patch(f"/api/v1/folders/{folder_id}", json=body) + # No /api/v1 alias exists for this route on the platform — only the + # unversioned path works (confirmed live: the v1 path 404s). + data = self._http.patch(f"/api/folders/{folder_id}", json=body) return Folder.model_validate(data) def delete(self, folder_id: str) -> None: diff --git a/awsysco/resources/imports.py b/awsysco/resources/imports.py index d2a05ff..47fca76 100644 --- a/awsysco/resources/imports.py +++ b/awsysco/resources/imports.py @@ -3,7 +3,7 @@ from __future__ import annotations import time -from typing import List, Optional +from typing import Any, Dict, List, Optional from urllib.parse import quote from .._http import HttpClient @@ -25,6 +25,7 @@ def start( provider: str, access_token: str, target_namespace: Optional[str] = None, + scope_filter: Optional[str] = None, scan_only: Optional[bool] = None, ) -> ImportJob: """Start a new provider link-import job. @@ -33,16 +34,19 @@ def start( provider: The source provider (e.g. ``'bitly'``, ``'rebrandly'``). access_token: An OAuth/API token for the source provider account. target_namespace: Optional namespace to import links into. + scope_filter: Optional filter restricting which links are imported. scan_only: If ``True``, fetch and report without writing links. Returns: The created ImportJob (initially in a ``pending`` state). """ - body = {"provider": provider, "access_token": access_token} + body: Dict[str, Any] = {"provider": provider, "accessToken": access_token} if target_namespace is not None: - body["target_namespace"] = target_namespace + body["targetNamespace"] = target_namespace + if scope_filter is not None: + body["scopeFilter"] = scope_filter if scan_only is not None: - body["scan_only"] = scan_only + body["scanOnly"] = scan_only data = self._http.post("/api/v1/imports", json=body) return ImportJob.model_validate(data) @@ -81,7 +85,7 @@ def list(self, *, limit: Optional[int] = None) -> List[ImportJob]: Returns: A list of ImportJob objects. """ - params = {} + params: Dict[str, Any] = {} if limit is not None: params["limit"] = limit data = self._http.get( @@ -91,6 +95,30 @@ def list(self, *, limit: Optional[int] = None) -> List[ImportJob]: items = data.get("jobs", []) if isinstance(data, dict) else (data or []) return [ImportJob.model_validate(item) for item in items] + def get_redirect_map_csv(self, job_id: str) -> str: + """Download the redirect map for a completed import job, as CSV. + + Args: + job_id: The import job id. + + Returns: + A CSV-formatted string mapping old → new short paths. + """ + encoded = quote(job_id, safe="") + return self._http.get_text(f"/api/v1/imports/{encoded}/redirect-map.csv") + + def get_redirect_map_json(self, job_id: str) -> Any: + """Download the redirect map for a completed import job, as JSON. + + Args: + job_id: The import job id. + + Returns: + The parsed JSON redirect map (shape is provider-dependent). + """ + encoded = quote(job_id, safe="") + return self._http.get(f"/api/v1/imports/{encoded}/redirect-map.json") + def wait_for_completion( self, job_id: str, diff --git a/awsysco/resources/links.py b/awsysco/resources/links.py index 344e66f..7a68579 100644 --- a/awsysco/resources/links.py +++ b/awsysco/resources/links.py @@ -2,11 +2,21 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional +from typing import Any, Dict, Iterator, List, Optional, Union +from urllib.parse import quote from .._http import HttpClient from ..models import GeoRestriction, Link, LinkList, OgMeta, RoutingRule +_MAX_PAGE_SIZE = 100 + + +def _to_dict(value: Optional[Union[Dict[str, Any], Any]]) -> Optional[Dict[str, Any]]: + """Accept either a plain dict or one of the typed request models.""" + if value is None or isinstance(value, dict): + return value + return value.model_dump(by_alias=True, exclude_none=True) + class LinksResource: """Interact with /api/v1/links.""" @@ -21,9 +31,9 @@ def create( custom_slug: Optional[str] = None, expires_at: Optional[str] = None, max_clicks: Optional[int] = None, - routing_rules: Optional[List[Dict[str, str]]] = None, - og_meta: Optional[Dict[str, str]] = None, - geo_restriction: Optional[Dict[str, List[str]]] = None, + routing_rules: Optional[List[Union[Dict[str, str], RoutingRule]]] = None, + og_meta: Optional[Union[Dict[str, str], OgMeta]] = None, + geo_restriction: Optional[Union[Dict[str, List[str]], GeoRestriction]] = None, password: Optional[str] = None, pass_ad_click_ids: Optional[bool] = None, folder_id: Optional[str] = None, @@ -36,12 +46,14 @@ def create( custom_slug: Optional custom short slug. expires_at: Optional expiry datetime (ISO 8601). max_clicks: Optional maximum click limit. - routing_rules: Optional list of geo-routing rules, each with - ``country`` and ``redirect_url`` keys. - og_meta: Optional Open Graph metadata dict with ``title``, - ``description``, and/or ``image`` keys. - geo_restriction: Optional dict with ``allowed_countries`` and/or - ``blocked_countries`` lists. + routing_rules: Optional list of geo-routing rules — each either a + :class:`~awsysco.models.RoutingRule` or a dict with ``country`` and + ``redirect_url`` keys. + og_meta: Optional Open Graph metadata — an :class:`~awsysco.models.OgMeta` or + a dict with ``title``, ``description``, and/or ``image`` keys. + geo_restriction: Optional geo-restriction settings — a + :class:`~awsysco.models.GeoRestriction` or a dict with + ``allowed_countries``/``blocked_countries`` lists. password: Optional password to protect the link. pass_ad_click_ids: Whether to pass through ad click IDs (gclid etc). folder_id: Optional folder ID to assign the link to. @@ -58,11 +70,11 @@ def create( if max_clicks is not None: body["maxClicks"] = max_clicks if routing_rules is not None: - body["routingRules"] = routing_rules + body["routingRules"] = [_to_dict(rule) for rule in routing_rules] if og_meta is not None: - body["ogMeta"] = og_meta + body["ogMeta"] = _to_dict(og_meta) if geo_restriction is not None: - body["geoRestriction"] = geo_restriction + body["geoRestriction"] = _to_dict(geo_restriction) if password is not None: body["password"] = password if pass_ad_click_ids is not None: @@ -79,15 +91,41 @@ def list(self, *, limit: int = 20, offset: int = 0) -> LinkList: """List links with pagination. Args: - limit: Number of results (default 20). + limit: Number of results (default 20, platform max 100 — clamped + client-side). offset: Pagination offset (default 0). Returns: A LinkList containing links and pagination info. """ + limit = min(limit, _MAX_PAGE_SIZE) data = self._http.get("/api/v1/links", params={"limit": limit, "offset": offset}) return LinkList.model_validate(data) + def list_all(self, *, limit: int = 100) -> Iterator[Link]: + """Iterate over every link, auto-paginating with ``limit``/``offset``. + + Stops when the platform reports ``has_more=False``, or a page comes back + shorter than ``limit`` (including empty), which guards against a missing + ``has_more`` in the response. + + Args: + limit: Page size to request (platform max 100 — clamped client-side). + + Yields: + Each Link across every page. + """ + limit = min(limit, _MAX_PAGE_SIZE) + offset = 0 + while True: + page = self.list(limit=limit, offset=offset) + yield from page.links + if page.has_more is False: + return + if len(page.links) < limit: + return + offset += limit + def get(self, short_path: str) -> Link: """Get a single link by its short path/code. @@ -107,9 +145,9 @@ def update( url: Optional[str] = None, expires_at: Optional[str] = None, max_clicks: Optional[int] = None, - routing_rules: Optional[List[Dict[str, str]]] = None, - og_meta: Optional[Dict[str, str]] = None, - geo_restriction: Optional[Dict[str, List[str]]] = None, + routing_rules: Optional[List[Union[Dict[str, str], RoutingRule]]] = None, + og_meta: Optional[Union[Dict[str, str], OgMeta]] = None, + geo_restriction: Optional[Union[Dict[str, List[str]], GeoRestriction]] = None, password: Optional[str] = None, pass_ad_click_ids: Optional[bool] = None, folder_id: Optional[str] = None, @@ -117,14 +155,18 @@ def update( ) -> Link: """Update a link's settings. + Note: per the platform contract, ``PATCH /api/v1/links/:shortPath`` cannot + address a namespaced link (``prefix/slug``) — the slash is URL-encoded here, + but updating a namespaced link's settings currently requires the platform fix. + Args: short_path: The short code or slug identifying the link. url: New destination URL. expires_at: New expiry datetime (ISO 8601), or None to clear. max_clicks: New maximum click limit, or None to clear. - routing_rules: New list of geo-routing rules. - og_meta: New Open Graph metadata. - geo_restriction: New geo-restriction settings. + routing_rules: New list of geo-routing rules (models or dicts). + og_meta: New Open Graph metadata (a model or dict). + geo_restriction: New geo-restriction settings (a model or dict). password: New password (or empty string to remove). pass_ad_click_ids: Whether to pass through ad click IDs. folder_id: New folder ID. @@ -141,11 +183,11 @@ def update( if max_clicks is not None: body["maxClicks"] = max_clicks if routing_rules is not None: - body["routingRules"] = routing_rules + body["routingRules"] = [_to_dict(rule) for rule in routing_rules] if og_meta is not None: - body["ogMeta"] = og_meta + body["ogMeta"] = _to_dict(og_meta) if geo_restriction is not None: - body["geoRestriction"] = geo_restriction + body["geoRestriction"] = _to_dict(geo_restriction) if password is not None: body["password"] = password if pass_ad_click_ids is not None: @@ -155,7 +197,7 @@ def update( if tags is not None: body["tags"] = tags - data = self._http.patch(f"/api/v1/links/{short_path}", json=body) + data = self._http.patch(f"/api/v1/links/{quote(short_path, safe='')}", json=body) return Link.model_validate(data) def delete(self, short_path: str) -> None: diff --git a/awsysco/resources/profile.py b/awsysco/resources/profile.py new file mode 100644 index 0000000..7f2e5ee --- /dev/null +++ b/awsysco/resources/profile.py @@ -0,0 +1,45 @@ +"""Profile resource — the authenticated user's account profile.""" + +from __future__ import annotations + +from typing import Any, Dict + +from pydantic.alias_generators import to_camel + +from .._http import HttpClient +from ..models import Profile + + +class ProfileResource: + """Interact with /api/user/profile.""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def get(self) -> Profile: + """Get the authenticated user's account profile. + + Distinct from :meth:`MeResource.get` (subscription/feature summary) and + :meth:`UsageResource.get` (live consumption counters) — this returns account + profile fields (display name, email, etc). + + Returns: + A Profile object. + """ + data = self._http.get("/api/user/profile") + return Profile.model_validate(data) + + def update(self, **kwargs: Any) -> Profile: + """Update the authenticated user's account profile. + + Args: + **kwargs: Profile fields to update (e.g. ``display_name``). snake_case + keys are converted to camelCase for the wire; already-camelCase keys + pass through unchanged. + + Returns: + The updated Profile object. + """ + body: Dict[str, Any] = {to_camel(k): v for k, v in kwargs.items()} + data = self._http.patch("/api/user/profile", json=body) + return Profile.model_validate(data) diff --git a/awsysco/resources/qr.py b/awsysco/resources/qr.py index 2c2823c..e4be5c9 100644 --- a/awsysco/resources/qr.py +++ b/awsysco/resources/qr.py @@ -14,7 +14,7 @@ class QRResource: def __init__(self, http: HttpClient) -> None: self._http = http - self._base_url = http._base_url + self._base_url = http.base_url def get_url( self, diff --git a/awsysco/resources/tags.py b/awsysco/resources/tags.py index 4383c07..21cf922 100644 --- a/awsysco/resources/tags.py +++ b/awsysco/resources/tags.py @@ -24,7 +24,7 @@ def add(self, short_path: str, tag: str) -> dict: The API response dict. """ encoded = quote(short_path, safe="") - return self._http.post(f"/api/link/{encoded}/tags", json={"tag": tag}) or {} + return self._http.post(f"/api/link/{encoded}/tags", json={"tags": [tag]}) or {} def remove(self, short_path: str, tag: str) -> dict: """Remove a tag from a link. diff --git a/awsysco/resources/webhooks.py b/awsysco/resources/webhooks.py index f513f7e..129c43c 100644 --- a/awsysco/resources/webhooks.py +++ b/awsysco/resources/webhooks.py @@ -28,7 +28,7 @@ def list(self) -> dict: Returns: API response dict containing webhooks. """ - return self._http.get("/api/webhooks") or {} + return self._http.get("/api/v1/webhooks") or {} def create( self, @@ -55,12 +55,15 @@ def create( body["name"] = name if secret is not None: body["secret"] = secret - data = self._http.post("/api/webhooks", json=body) + data = self._http.post("/api/v1/webhooks", json=body) return Webhook.model_validate(data) def update(self, webhook_id: str, **kwargs: Any) -> Webhook: """Update a webhook's configuration. + Note: unlike the other webhook routes, this one has no ``/api/v1`` alias on + the platform — only the unversioned path works. + Args: webhook_id: The ID of the webhook to update. **kwargs: Fields to update. Supported keys: ``url``, ``events``, @@ -69,7 +72,7 @@ def update(self, webhook_id: str, **kwargs: Any) -> Webhook: Returns: The updated Webhook object. """ - # Map snake_case kwargs to camelCase + # Map snake_case kwargs to the platform's wire keys. body: Dict[str, Any] = {} key_map = { "url": "url", @@ -93,7 +96,7 @@ def delete(self, webhook_id: str) -> dict: Returns: The API response dict. """ - return self._http.delete(f"/api/webhooks/{webhook_id}") or {} + return self._http.delete(f"/api/v1/webhooks/{webhook_id}") or {} def test(self, webhook_id: str, event_type: str) -> dict: """Send a test event to a webhook. @@ -107,7 +110,7 @@ def test(self, webhook_id: str, event_type: str) -> dict: """ return ( self._http.post( - f"/api/webhooks/{webhook_id}/test", + f"/api/v1/webhooks/{webhook_id}/test", json={"eventType": event_type}, ) or {} diff --git a/examples/async_usage.py b/examples/async_usage.py index af55925..de0f05d 100644 --- a/examples/async_usage.py +++ b/examples/async_usage.py @@ -9,7 +9,7 @@ import asyncio import os -from awsysco import AsyncClient, AwsysNotFoundError +from awsysco import AsyncClient api_key = os.environ.get("AWSYS_API_KEY") if not api_key: diff --git a/examples/basic_usage.py b/examples/basic_usage.py index 4af0202..98e73a5 100644 --- a/examples/basic_usage.py +++ b/examples/basic_usage.py @@ -8,7 +8,7 @@ import os -from awsysco import Client, AwsysNotFoundError +from awsysco import Client api_key = os.environ.get("AWSYS_API_KEY") if not api_key: diff --git a/examples/check_syntax.sh b/examples/check_syntax.sh new file mode 100755 index 0000000..6f32172 --- /dev/null +++ b/examples/check_syntax.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Sanity-checks the example scripts compile and import cleanly. +# +# These examples make real network calls against the live API and require a +# real AWSYS_API_KEY (see each file's docstring) — they are intentionally NOT +# executed in CI. This script only verifies they're valid, importable Python, +# so a refactor that breaks their imports (e.g. a renamed model/exception) is +# still caught automatically. +set -euo pipefail + +cd "$(dirname "$0")/.." +python -m py_compile examples/basic_usage.py examples/async_usage.py examples/integration_test.py +echo "examples: syntax OK" diff --git a/examples/integration_test.py b/examples/integration_test.py index 945a3da..18bbcf9 100644 --- a/examples/integration_test.py +++ b/examples/integration_test.py @@ -12,12 +12,10 @@ import os import time -from awsysco import Client, AwsysError, AwsysNotFoundError +from awsysco import Client, AwsysError from awsysco.models import ( - AffiliateProgram, BulkResult, ClickEvent, - CustomDomain, Folder, FolderList, Link, diff --git a/pyproject.toml b/pyproject.toml index 06dfd8f..1834695 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "awsysco" -version = "1.3.0" +version = "1.4.0" description = "Official Python SDK for the AWSYS.CO URL Shortener API" readme = "README.md" requires-python = ">=3.9" @@ -22,12 +22,13 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Internet :: WWW/HTTP", "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - "httpx>=0.27", - "pydantic>=2.0", + "httpx>=0.27,<1", + "pydantic>=2.0,<3", ] [project.optional-dependencies] @@ -36,6 +37,8 @@ dev = [ "pytest-asyncio", "python-dotenv", "pytest-cov", + "ruff", + "mypy", ] [project.urls] @@ -46,6 +49,20 @@ Repository = "https://github.com/AlphaWaveSystems/awsysco-python-sdk" [tool.pytest.ini_options] testpaths = ["tests"] +markers = [ + "integration: requires AWSYS_API_KEY and network access to a live/staging AWSYS API (auto-applied to any test using the `client` fixture; see conftest.py)", +] [tool.coverage.run] source = ["awsysco"] + +[tool.ruff] +target-version = "py39" +line-length = 100 + +[tool.mypy] +python_version = "3.10" +check_untyped_defs = true +warn_redundant_casts = true +warn_unused_ignores = true +no_implicit_optional = true diff --git a/tests/conftest.py b/tests/conftest.py index f564b08..a363a3d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -29,21 +29,31 @@ def client() -> Client: return Client(api_key=api_key, base_url=base_url) -@pytest.fixture(autouse=True) -def skip_on_account_restriction(request): - """Skip integration tests that fail due to staging account restrictions - (e.g. email verification required) rather than code bugs. - """ - yield - # Nothing to do post-yield — we catch before yield via try/except below - - +@pytest.hookimpl(wrapper=True) def pytest_runtest_call(item): - """Hook: convert AwsysForbiddenError 'email verification required' into a skip.""" + """Hook: convert AwsysForbiddenError 'email verification required' into a skip. + + Must be a hookwrapper (``yield`` around the real call) — a plain, non-wrapper + ``pytest_runtest_call(item)`` that itself calls ``item.runtest()`` runs the test + body a SECOND time, since pytest's own internal implementation still runs too. + That was silently doubling every test's side effects (including live API calls + against staging) prior to this fix. + """ try: - item.runtest() + return (yield) except AwsysForbiddenError as exc: msg = str(exc).lower() if any(phrase in msg for phrase in _SKIP_MESSAGES): pytest.skip(f"Staging account restriction: {exc}") raise + + +def pytest_collection_modifyitems(items): + """Auto-apply the `integration` marker to any test using the `client` fixture. + + Keeps individual test files from having to remember `@pytest.mark.integration` — + any test that asks for a live-staging `client` is, by definition, integration. + """ + for item in items: + if "client" in getattr(item, "fixturenames", ()): + item.add_marker(pytest.mark.integration) diff --git a/tests/contracts/sdk-contract.json b/tests/contracts/sdk-contract.json new file mode 100644 index 0000000..0b9e2c5 --- /dev/null +++ b/tests/contracts/sdk-contract.json @@ -0,0 +1,2102 @@ +{ + "$schema": "awsys-sdk-contract/1", + "version": "1.0.6", + "platformBaseline": "2026-09", + "baseUrl": "https://awsys.co", + "auth": { + "header": "Authorization", + "scheme": "Bearer", + "keyPrefix": "awsys_" + }, + "capabilities": [ + { + "id": "create_link", + "capability": "1", + "request": { + "method": "POST", + "path": "/api/v1/links", + "query": {}, + "body": { + "url": "https://example.com/" + } + }, + "response": { + "status": 201, + "body": { + "success": true, + "shortUrl": "https://awsys.co/abc123", + "shortCode": "abc123", + "fullPath": null, + "namespace": null, + "long": "https://example.com/", + "created": "2026-09-01T00:00:00.000Z", + "expiresAt": null, + "maxClicks": null, + "expireFallbackUrl": null, + "trustScore": null, + "trustStatus": "pending", + "threats": [] + } + } + }, + { + "id": "create_link_custom_slug", + "capability": "1", + "request": { + "method": "POST", + "path": "/api/v1/links", + "query": {}, + "body": { + "url": "https://example.com/", + "customSlug": "my-slug", + "expiresAt": "2027-01-01T00:00:00.000Z", + "maxClicks": 10 + } + }, + "response": { + "status": 201, + "body": { + "success": true, + "shortCode": "my-slug", + "shortUrl": "https://awsys.co/my-slug" + } + } + }, + { + "id": "list_links", + "capability": "2", + "request": { + "method": "GET", + "path": "/api/v1/links", + "query": { + "limit": "2", + "offset": "0" + }, + "body": null + }, + "response": { + "status": 200, + "body": { + "links": [ + { + "id": "abc123", + "shortUrl": "https://awsys.co/abc123", + "shortCode": "abc123", + "fullPath": null, + "namespace": null, + "long": "https://example.com/", + "clicks": 0, + "created": "2026-09-01T00:00:00.000Z", + "expiresAt": null, + "maxClicks": null + }, + { + "id": "def456", + "shortUrl": "https://awsys.co/abc123", + "shortCode": "def456", + "fullPath": null, + "namespace": null, + "long": "https://example.com/", + "clicks": 0, + "created": "2026-09-01T00:00:00.000Z", + "expiresAt": null, + "maxClicks": null + } + ], + "pagination": { + "limit": 2, + "offset": 0, + "hasMore": true + } + } + } + }, + { + "id": "list_links_last_page", + "capability": "2", + "request": { + "method": "GET", + "path": "/api/v1/links", + "query": { + "limit": "2", + "offset": "2" + }, + "body": null + }, + "response": { + "status": 200, + "body": { + "links": [ + { + "id": "ghi789", + "shortUrl": "https://awsys.co/abc123", + "shortCode": "ghi789", + "fullPath": null, + "namespace": null, + "long": "https://example.com/", + "clicks": 0, + "created": "2026-09-01T00:00:00.000Z", + "expiresAt": null, + "maxClicks": null + } + ], + "pagination": { + "limit": 2, + "offset": 2, + "hasMore": false + } + } + }, + "note": "iterator must stop; short page" + }, + { + "id": "list_links_missing_hasmore", + "capability": "2", + "request": { + "method": "GET", + "path": "/api/v1/links", + "query": { + "limit": "2", + "offset": "0" + }, + "body": null + }, + "response": { + "status": 200, + "body": { + "links": [] + } + }, + "note": "hasMore absent \u2192 false; iterator yields nothing" + }, + { + "id": "list_links_limit_clamped", + "capability": "2", + "request": { + "method": "GET", + "path": "/api/v1/links", + "query": { + "limit": "100", + "offset": "0" + }, + "body": null + }, + "response": { + "status": 200, + "body": { + "links": [], + "pagination": { + "limit": 100, + "offset": 0, + "hasMore": false + } + } + }, + "note": "caller passed 500; SDK clamps to 100" + }, + { + "id": "get_link", + "capability": "3", + "request": { + "method": "GET", + "path": "/api/v1/links/abc123", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "id": "abc123", + "shortUrl": "https://awsys.co/abc123", + "shortCode": "abc123", + "fullPath": null, + "namespace": null, + "long": "https://example.com/", + "clicks": 0, + "created": "2026-09-01T00:00:00.000Z", + "expiresAt": null, + "maxClicks": null + } + } + }, + { + "id": "get_link_namespaced", + "capability": "3", + "request": { + "method": "GET", + "path": "/api/v1/links/ns/slug", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "id": "abc123", + "shortUrl": "https://awsys.co/abc123", + "shortCode": "slug", + "fullPath": "ns/slug", + "namespace": "ns", + "long": "https://example.com/", + "clicks": 0, + "created": "2026-09-01T00:00:00.000Z", + "expiresAt": null, + "maxClicks": null + } + }, + "note": "slash must NOT be encoded for GET/DELETE wildcard routes" + }, + { + "id": "update_link", + "capability": "4", + "request": { + "method": "PATCH", + "path": "/api/v1/links/abc123", + "query": {}, + "body": { + "maxClicks": 5, + "expiresAt": "2027-01-01T00:00:00.000Z" + } + }, + "response": { + "status": 200, + "body": { + "success": true, + "shortCode": "abc123", + "maxClicks": 5 + } + } + }, + { + "id": "delete_link", + "capability": "5", + "request": { + "method": "DELETE", + "path": "/api/v1/links/abc123", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "success": true, + "message": "Link deleted" + } + } + }, + { + "id": "link_stats", + "capability": "6", + "request": { + "method": "GET", + "path": "/api/v1/links/abc123/stats", + "query": { + "period": "7d" + }, + "body": null + }, + "response": { + "status": 200, + "body": { + "shortCode": "abc123", + "fullPath": null, + "totalClicks": 1, + "clicks": [ + { + "timestamp": "2026-09-01T00:00:00.000Z", + "country": "MX", + "browser": "Chrome", + "os": "macOS", + "referrer": null + } + ] + } + } + }, + { + "id": "aggregate_stats", + "capability": "7", + "request": { + "method": "GET", + "path": "/api/v1/links/abc123/stats/aggregate", + "query": { + "period": "7d" + }, + "body": null + }, + "response": { + "status": 200, + "body": { + "shortCode": "abc123", + "fullPath": null, + "totalClicks": 1, + "byCountry": { + "MX": 1 + }, + "byDay": { + "2026-09-01": 1 + } + } + } + }, + { + "id": "bulk_create", + "capability": "8", + "request": { + "method": "POST", + "path": "/api/v1/bulk", + "query": {}, + "body": { + "urls": [ + { + "url": "https://a.example/" + }, + { + "url": "https://b.example/", + "customSlug": "b" + } + ] + } + }, + "response": { + "status": 200, + "body": { + "success": true, + "summary": { + "total": 2, + "created": 2, + "failed": 0 + }, + "results": [ + { + "url": "https://a.example/", + "shortCode": "x1", + "shortUrl": "https://awsys.co/x1", + "success": true + }, + { + "url": "https://b.example/", + "shortCode": "b", + "shortUrl": "https://awsys.co/b", + "success": true + } + ] + } + } + }, + { + "id": "me", + "capability": "9", + "request": { + "method": "GET", + "path": "/api/v1/me", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "uid": "u1", + "email": "t@example.com", + "subscriptionTier": "pro", + "subscriptionSource": null, + "userPrefix": "op0p", + "isPremium": true, + "features": { + "customSlugs": true + }, + "limits": { + "linksPerDay": 100 + }, + "utmTemplates": [ + { + "id": "t1", + "name": "Launch", + "utmSource": "newsletter", + "utmMedium": "email", + "utmCampaign": "sept" + } + ] + } + } + }, + { + "id": "usage", + "capability": "10", + "request": { + "method": "GET", + "path": "/api/user/stats", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "totalLinks": 222, + "totalClicks": 5, + "linksCreatedThisMonth": 222, + "qrCodesThisMonth": 0, + "folderCount": 0, + "apiCallsThisMonth": 1, + "trackedClicksThisMonth": 5, + "tier": "pro", + "limits": { + "linksPerMonth": 1000, + "monthlyLinks": 1000, + "dailyLinks": 100, + "monthlyTrackedClicks": 10000, + "apiCallsPerMonth": 1000, + "qrCodes": 100, + "folders": 10, + "customSlugs": true + }, + "hasApiKey": true, + "apiKeyCreatedAt": "2026-09-07T03:09:24.886Z", + "userPrefix": "op0p", + "isPremium": true, + "overage": { + "active": false, + "startedAt": null, + "expiresAt": null, + "hoursUntilDrop": null, + "clicksThisCycle": 0, + "spendingLimitCents": null, + "estimatedChargeCents": 0 + } + } + }, + "note": "verified live 2026-09-07 on staging; limits values illustrative, keys exact" + }, + { + "id": "recent_clicks", + "capability": "11", + "request": { + "method": "GET", + "path": "/api/user/clicks/recent", + "query": { + "limit": "10" + }, + "body": null + }, + "response": { + "status": 200, + "body": { + "clicks": [ + { + "shortCode": "abc123", + "timestamp": "2026-09-01T00:00:00.000Z", + "country": "MX" + } + ], + "count": 1 + } + }, + "note": "path is /api/user/clicks/recent (NOT /api/user/recent-clicks); requires features.liveGlobe on the account (403 FEATURE_DISABLED otherwise); verified live envelope {clicks,count}" + }, + { + "id": "profile_get", + "capability": "12", + "request": { + "method": "GET", + "path": "/api/user/profile", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "uid": "u1", + "email": "t@example.com", + "displayName": "T", + "subscriptionTier": "pro" + } + } + }, + { + "id": "profile_update", + "capability": "13", + "request": { + "method": "PATCH", + "path": "/api/user/profile", + "query": {}, + "body": { + "displayName": "New" + } + }, + "response": { + "status": 200, + "body": { + "success": true, + "displayName": "New" + } + }, + "note": "body keys are camelCase (user.js:212 reads displayName)" + }, + { + "id": "qr_url", + "capability": "14", + "request": { + "method": "GET", + "path": "/api/qr/abc123", + "query": { + "size": "300", + "color": "000000", + "bgColor": "ffffff" + }, + "body": null + }, + "response": { + "status": 200, + "body": {} + }, + "note": "client-side URL builder; assert exact URL string" + }, + { + "id": "qr_settings_get", + "capability": "15", + "request": { + "method": "GET", + "path": "/api/link/abc123/qr-settings", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "size": 300, + "color": "#000000", + "bgColor": "#ffffff", + "logo": null + } + } + }, + { + "id": "qr_settings_update", + "capability": "16", + "request": { + "method": "PUT", + "path": "/api/link/abc123/qr-settings", + "query": {}, + "body": { + "color": "#ff0000" + } + }, + "response": { + "status": 200, + "body": { + "success": true, + "color": "#ff0000" + } + } + }, + { + "id": "folders_list", + "capability": "17", + "request": { + "method": "GET", + "path": "/api/v1/folders", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "folders": [ + { + "id": "f1", + "name": "Work", + "color": "#00f", + "linkCount": 2 + } + ], + "limit": 10 + } + }, + "note": "limit is tier quota, not pagination" + }, + { + "id": "folder_create", + "capability": "18", + "request": { + "method": "POST", + "path": "/api/v1/folders", + "query": {}, + "body": { + "name": "Work" + } + }, + "response": { + "status": 201, + "body": { + "id": "f1", + "name": "Work", + "color": null + } + } + }, + { + "id": "folder_update", + "capability": "19", + "request": { + "method": "PATCH", + "path": "/api/folders/f1", + "query": {}, + "body": { + "name": "Work2" + } + }, + "response": { + "status": 200, + "body": { + "id": "f1", + "name": "Work2" + } + } + }, + { + "id": "folder_delete", + "capability": "20", + "request": { + "method": "DELETE", + "path": "/api/v1/folders/f1", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "success": true + } + } + }, + { + "id": "folder_assign", + "capability": "21", + "request": { + "method": "POST", + "path": "/api/v1/links/abc123/folder", + "query": {}, + "body": { + "folderId": "f1" + } + }, + "response": { + "status": 200, + "body": { + "success": true + } + } + }, + { + "id": "folder_remove", + "capability": "22", + "request": { + "method": "POST", + "path": "/api/v1/links/abc123/folder", + "query": {}, + "body": { + "folderId": null + } + }, + "response": { + "status": 200, + "body": { + "success": true + } + }, + "note": "null is a command; must be sent" + }, + { + "id": "tags_add", + "capability": "23", + "request": { + "method": "POST", + "path": "/api/link/abc123/tags", + "query": {}, + "body": { + "tags": [ + "a", + "b" + ] + } + }, + "response": { + "status": 200, + "body": { + "success": true, + "tags": [ + "a", + "b" + ] + } + } + }, + { + "id": "tag_remove", + "capability": "24", + "request": { + "method": "DELETE", + "path": "/api/link/abc123/tags/a", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "success": true, + "tags": [ + "b" + ] + } + } + }, + { + "id": "views_list", + "capability": "25", + "request": { + "method": "GET", + "path": "/api/views", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "views": [ + { + "id": "v1", + "name": "Mine", + "filters": { + "tag": "a" + } + } + ] + } + } + }, + { + "id": "view_create", + "capability": "26", + "request": { + "method": "POST", + "path": "/api/views", + "query": {}, + "body": { + "name": "Mine", + "filters": { + "tag": "a" + } + } + }, + "response": { + "status": 201, + "body": { + "id": "v1", + "name": "Mine", + "filters": { + "tag": "a" + } + } + } + }, + { + "id": "view_update", + "capability": "27", + "request": { + "method": "PATCH", + "path": "/api/views/v1", + "query": {}, + "body": { + "name": "Yours" + } + }, + "response": { + "status": 200, + "body": { + "success": true, + "message": "updated" + } + } + }, + { + "id": "view_delete", + "capability": "28", + "request": { + "method": "DELETE", + "path": "/api/views/v1", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "success": true, + "message": "deleted" + } + } + }, + { + "id": "utm_list_via_me", + "capability": "29", + "request": { + "method": "GET", + "path": "/api/v1/me", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "utmTemplates": [ + { + "id": "t1", + "name": "Launch" + } + ] + } + }, + "note": "list derived from me.utmTemplates" + }, + { + "id": "utm_create", + "capability": "30", + "request": { + "method": "POST", + "path": "/api/user/utm-templates", + "query": {}, + "body": { + "name": "Launch", + "utmSource": "newsletter", + "utmMedium": "email", + "utmCampaign": "sept" + } + }, + "response": { + "status": 200, + "body": { + "id": "t1", + "name": "Launch" + } + } + }, + { + "id": "utm_delete", + "capability": "31", + "request": { + "method": "DELETE", + "path": "/api/user/utm-templates/t1", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "success": true + } + } + }, + { + "id": "webhook_event_types", + "capability": "32", + "request": { + "method": "GET", + "path": "/api/webhooks/event-types", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "eventTypes": [ + "link.created", + "link.clicked" + ] + } + } + }, + { + "id": "webhooks_list", + "capability": "33", + "request": { + "method": "GET", + "path": "/api/v1/webhooks", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "webhooks": [ + { + "id": "w1", + "url": "https://h.example/", + "events": [ + "link.created" + ], + "name": "Unnamed Webhook", + "secret": "whsec_x", + "enabled": true, + "failureCount": 0, + "successCount": 0, + "lastTriggered": null, + "createdAt": "2026-09-01T00:00:00.000Z", + "updatedAt": null + }, + { + "id": "w0", + "url": "https://legacy.example/", + "events": [ + "link.click" + ], + "name": "Legacy", + "createdAt": "2026-06-01T00:00:00.000Z", + "lastDeliveryAt": null, + "lastStatus": null + } + ], + "limit": 5 + } + }, + "note": "serializeWebhook spreads the doc (services/webhooks.js:82); legacy docs (seen live on staging) lack enabled/secret \u2014 SDK models must treat every field except id/url/events as optional" + }, + { + "id": "webhook_create", + "capability": "34", + "request": { + "method": "POST", + "path": "/api/v1/webhooks", + "query": {}, + "body": { + "url": "https://h.example/", + "events": [ + "link.created" + ] + } + }, + "response": { + "status": 201, + "body": { + "id": "w1", + "url": "https://h.example/", + "events": [ + "link.created" + ], + "name": "Unnamed Webhook", + "secret": "whsec_x", + "enabled": true, + "failureCount": 0, + "successCount": 0, + "lastTriggered": null, + "createdAt": "2026-09-01T00:00:00.000Z", + "updatedAt": null + } + } + }, + { + "id": "webhook_update", + "capability": "35", + "request": { + "method": "PATCH", + "path": "/api/webhooks/w1", + "query": {}, + "body": { + "enabled": false + } + }, + "response": { + "status": 200, + "body": { + "id": "w1", + "url": "https://h.example/", + "events": [ + "link.created" + ], + "name": "Unnamed Webhook", + "secret": "whsec_x", + "enabled": false, + "failureCount": 0, + "successCount": 0, + "lastTriggered": null, + "createdAt": "2026-09-01T00:00:00.000Z", + "updatedAt": "2026-09-02T00:00:00.000Z" + } + }, + "note": "wire field is `enabled` (services/webhooks.js:122,150), never `active`" + }, + { + "id": "webhook_delete", + "capability": "36", + "request": { + "method": "DELETE", + "path": "/api/v1/webhooks/w1", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "success": true, + "message": "deleted" + } + } + }, + { + "id": "webhook_test", + "capability": "37", + "request": { + "method": "POST", + "path": "/api/v1/webhooks/w1/test", + "query": {}, + "body": { + "eventType": "link.created" + } + }, + "response": { + "status": 200, + "body": { + "success": true, + "statusCode": 200, + "durationMs": 42 + } + } + }, + { + "id": "domains_list", + "capability": "38", + "request": { + "method": "GET", + "path": "/api/user/domains", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "domains": [ + { + "domain": "go.example.com", + "status": "pending", + "verified": false + } + ] + } + } + }, + { + "id": "domain_add", + "capability": "39", + "request": { + "method": "POST", + "path": "/api/user/domains", + "query": {}, + "body": { + "domain": "go.example.com" + } + }, + "response": { + "status": 200, + "body": { + "domain": "go.example.com", + "status": "pending", + "dnsRecords": [ + { + "type": "TXT", + "name": "_awsys", + "value": "x" + } + ] + } + } + }, + { + "id": "domain_verify", + "capability": "40", + "request": { + "method": "GET", + "path": "/api/user/domains/go.example.com/verify", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "domain": "go.example.com", + "verified": true, + "status": "verified" + } + } + }, + { + "id": "domain_activate_deprecated", + "capability": "41", + "request": { + "method": "POST", + "path": "/api/user/domains/go.example.com/activate", + "query": {}, + "body": null + }, + "response": { + "status": 401, + "body": { + "error": true, + "code": "UNAUTHORIZED", + "message": "Firebase auth required" + } + }, + "expect_error": "AuthorizationError", + "note": "SDK must not call; raise deprecation + AuthorizationError" + }, + { + "id": "domain_update", + "capability": "42", + "request": { + "method": "PATCH", + "path": "/api/user/domains/go.example.com", + "query": {}, + "body": { + "defaultRedirect": "https://example.com/" + } + }, + "response": { + "status": 200, + "body": { + "domain": "go.example.com", + "defaultRedirect": "https://example.com/" + } + } + }, + { + "id": "domain_remove", + "capability": "43", + "request": { + "method": "DELETE", + "path": "/api/user/domains/go.example.com", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "success": true + } + } + }, + { + "id": "domain_check", + "capability": "44", + "request": { + "method": "GET", + "path": "/api/domains/check/go.example.com", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "hostname": "go.example.com", + "available": true + } + } + }, + { + "id": "namespace_get", + "capability": "45", + "request": { + "method": "GET", + "path": "/api/user/namespace", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "hasAccess": true, + "namespace": "acme", + "tier": "pro", + "upgradeRequired": false + } + } + }, + { + "id": "namespace_check", + "capability": "46", + "request": { + "method": "GET", + "path": "/api/namespace/check/acme", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "namespace": "acme", + "available": false + } + } + }, + { + "id": "namespace_claim", + "capability": "47", + "request": { + "method": "POST", + "path": "/api/user/namespace", + "query": {}, + "body": { + "namespace": "acme" + } + }, + "response": { + "status": 200, + "body": { + "success": true, + "namespace": "acme" + } + } + }, + { + "id": "namespace_release", + "capability": "48", + "request": { + "method": "DELETE", + "path": "/api/user/namespace", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "success": true, + "message": "released" + } + } + }, + { + "id": "affiliate_program_create", + "capability": "49", + "request": { + "method": "POST", + "path": "/api/affiliate/programs", + "query": {}, + "body": { + "name": "P", + "commissionRate": 10 + } + }, + "response": { + "status": 200, + "body": { + "id": "p1", + "name": "P", + "commissionRate": 10 + } + } + }, + { + "id": "affiliate_programs_list", + "capability": "50", + "request": { + "method": "GET", + "path": "/api/affiliate/programs", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "programs": [ + { + "id": "p1", + "name": "P" + } + ] + } + } + }, + { + "id": "affiliate_program_get", + "capability": "51", + "request": { + "method": "GET", + "path": "/api/affiliate/programs/p1", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "id": "p1", + "name": "P" + } + } + }, + { + "id": "affiliate_program_update", + "capability": "52", + "request": { + "method": "PATCH", + "path": "/api/affiliate/programs/p1", + "query": {}, + "body": { + "name": "P2" + } + }, + "response": { + "status": 200, + "body": { + "id": "p1", + "name": "P2" + } + } + }, + { + "id": "affiliate_program_stats", + "capability": "53", + "request": { + "method": "GET", + "path": "/api/affiliate/programs/p1/stats", + "query": { + "period": "30d" + }, + "body": null + }, + "response": { + "status": 200, + "body": { + "clicks": 10, + "conversions": 1 + } + } + }, + { + "id": "affiliate_partners_list", + "capability": "54", + "request": { + "method": "GET", + "path": "/api/affiliate/programs/p1/partners", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "partners": [ + { + "id": "pt1", + "status": "pending" + } + ] + } + } + }, + { + "id": "affiliate_partner_status", + "capability": "55", + "request": { + "method": "PATCH", + "path": "/api/affiliate/programs/p1/partners/pt1", + "query": {}, + "body": { + "status": "approved" + } + }, + "response": { + "status": 200, + "body": { + "id": "pt1", + "status": "approved" + } + } + }, + { + "id": "affiliate_discover", + "capability": "56", + "request": { + "method": "GET", + "path": "/api/affiliate/discover", + "query": { + "limit": "20" + }, + "body": null + }, + "response": { + "status": 200, + "body": { + "programs": [ + { + "id": "p9", + "name": "Other" + } + ] + } + } + }, + { + "id": "affiliate_join", + "capability": "57", + "request": { + "method": "POST", + "path": "/api/affiliate/join/p9", + "query": {}, + "body": { + "partnerCode": "CODE" + } + }, + "response": { + "status": 200, + "body": { + "id": "ps1", + "programId": "p9", + "status": "pending" + } + } + }, + { + "id": "affiliate_partnerships_list", + "capability": "58", + "request": { + "method": "GET", + "path": "/api/affiliate/partnerships", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "partnerships": [ + { + "id": "ps1", + "programId": "p9" + } + ] + } + } + }, + { + "id": "affiliate_partnership_stats", + "capability": "59", + "request": { + "method": "GET", + "path": "/api/affiliate/partnerships/ps1/stats", + "query": { + "period": "30d" + }, + "body": null + }, + "response": { + "status": 200, + "body": { + "clicks": 3 + } + } + }, + { + "id": "affiliate_leave", + "capability": "60", + "request": { + "method": "DELETE", + "path": "/api/affiliate/partnerships/ps1", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "success": true + } + } + }, + { + "id": "affiliate_limits", + "capability": "61", + "request": { + "method": "GET", + "path": "/api/affiliate/limits", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "programs": { + "used": 1, + "limit": 3 + }, + "partnerships": { + "used": 1, + "limit": 10 + } + } + } + }, + { + "id": "agentlink_link_stats", + "capability": "62", + "request": { + "method": "GET", + "path": "/api/agentlink/links/abc123/stats", + "query": { + "period": "30" + }, + "body": null + }, + "response": { + "status": 200, + "body": { + "shortCode": "abc123", + "agentClicks": 2, + "clicks": [] + } + } + }, + { + "id": "agentlink_account_stats", + "capability": "63", + "request": { + "method": "GET", + "path": "/api/agentlink/account/stats", + "query": { + "period": "30" + }, + "body": null + }, + "response": { + "status": 200, + "body": { + "agentClicks": 2, + "byAgent": { + "claude": 2 + } + } + } + }, + { + "id": "agentlink_subscribe", + "capability": "64", + "request": { + "method": "POST", + "path": "/api/agentlink/subscribe", + "query": {}, + "body": { + "email": "t@example.com" + } + }, + "response": { + "status": 200, + "body": { + "success": true + } + }, + "auth": "none" + }, + { + "id": "web2app_consume", + "capability": "65", + "request": { + "method": "GET", + "path": "/api/v1/web2app/tok123", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "success": true, + "linkId": "abc123", + "utmParams": { + "utm_source": "x" + }, + "routingRule": null, + "country": "MX", + "clickedAt": "2026-09-01T00:00:00.000Z" + } + } + }, + { + "id": "import_start", + "capability": "66", + "request": { + "method": "POST", + "path": "/api/v1/imports", + "query": {}, + "body": { + "provider": "bitly", + "accessToken": "bitly_token", + "scanOnly": true + } + }, + "response": { + "status": 201, + "body": { + "id": "j1", + "provider": "bitly", + "status": "queued", + "scanOnly": true, + "created": "2026-09-01T00:00:00.000Z" + } + }, + "note": "accessToken must be redacted from any SDK logging/errors" + }, + { + "id": "imports_list", + "capability": "67", + "request": { + "method": "GET", + "path": "/api/v1/imports", + "query": { + "limit": "20" + }, + "body": null + }, + "response": { + "status": 200, + "body": { + "jobs": [ + { + "id": "j1", + "status": "completed" + } + ] + } + } + }, + { + "id": "import_get", + "capability": "68", + "request": { + "method": "GET", + "path": "/api/v1/imports/j1", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "id": "j1", + "status": "completed", + "summary": { + "total": 5, + "imported": 5, + "failed": 0 + } + } + } + }, + { + "id": "import_cancel", + "capability": "69", + "request": { + "method": "DELETE", + "path": "/api/v1/imports/j1", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "id": "j1", + "status": "cancelled" + } + } + }, + { + "id": "import_redirect_map_csv", + "capability": "70", + "request": { + "method": "GET", + "path": "/api/v1/imports/j1/redirect-map.csv", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": "old_url,new_url\nhttps://bit.ly/x,https://awsys.co/x\n" + }, + "content_type": "text/csv" + }, + { + "id": "import_redirect_map_json", + "capability": "71", + "request": { + "method": "GET", + "path": "/api/v1/imports/j1/redirect-map.json", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "mappings": [ + { + "from": "https://bit.ly/x", + "to": "https://awsys.co/x" + } + ] + } + } + }, + { + "id": "export_links_csv", + "capability": "72", + "request": { + "method": "GET", + "path": "/api/export/links", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": "shortCode,long,clicks\nabc123,https://example.com/,0\n" + }, + "content_type": "text/csv" + }, + { + "id": "export_link_stats_csv", + "capability": "73", + "request": { + "method": "GET", + "path": "/api/export/stats/abc123", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": "timestamp,country\n2026-09-01T00:00:00.000Z,MX\n" + }, + "content_type": "text/csv" + }, + { + "id": "trust_scan", + "capability": "74", + "request": { + "method": "GET", + "path": "/api/link-scan/abc123", + "query": {}, + "body": null + }, + "response": { + "status": 200, + "body": { + "shortCode": "abc123", + "trustScore": 95, + "trustStatus": "safe", + "threats": [] + } + }, + "auth": "optional", + "note": "optionalAuth: send the key or destination is stripped for non-owners (links.js:550+)" + } + ], + "errors": [ + { + "id": "err_401_invalid_key", + "status": 401, + "body": { + "error": true, + "code": "UNAUTHORIZED", + "message": "Invalid or unauthorized API key." + }, + "expect_error": "AuthenticationError" + }, + { + "id": "err_401_missing_key", + "status": 401, + "body": { + "error": true, + "message": "Valid API key required", + "code": "API_KEY_REQUIRED" + }, + "expect_error": "AuthenticationError" + }, + { + "id": "err_403_tier_string_error", + "status": 403, + "body": { + "error": "AgentLink analytics require Pro or higher", + "code": "TIER_INSUFFICIENT" + }, + "expect_error": "AuthorizationError", + "note": "error is a string, no message \u2192 message = that string" + }, + { + "id": "err_403_email", + "status": 403, + "body": { + "error": true, + "code": "EMAIL_NOT_VERIFIED", + "message": "Verify your email" + }, + "expect_error": "AuthorizationError" + }, + { + "id": "err_400_missing_url", + "status": 400, + "body": { + "error": true, + "message": "URL required", + "code": "MISSING_URL" + }, + "expect_error": "ValidationError" + }, + { + "id": "err_404_no_message", + "status": 404, + "body": { + "error": true, + "code": "IMPORT_JOB_NOT_FOUND" + }, + "expect_error": "NotFoundError", + "note": "message absent \u2192 synthesize from code" + }, + { + "id": "err_404_no_code", + "status": 404, + "body": { + "error": true, + "message": "Link not found" + }, + "expect_error": "NotFoundError" + }, + { + "id": "err_409", + "status": 409, + "body": { + "error": true, + "code": "SLUG_TAKEN", + "message": "Custom slug already in use" + }, + "expect_error": "ConflictError" + }, + { + "id": "err_429_hourly", + "status": 429, + "body": { + "error": true, + "code": "HOURLY_LIMIT_EXCEEDED", + "message": "Hourly API limit (50) exceeded.", + "resetsAt": "2026-09-06-04:00 UTC" + }, + "expect_error": "RateLimitError", + "retry": false, + "note": "quota 429: do not retry" + }, + { + "id": "err_429_monthly", + "status": 429, + "body": { + "error": true, + "code": "MONTHLY_LIMIT_EXCEEDED", + "message": "Monthly API limit exceeded" + }, + "expect_error": "RateLimitError", + "retry": false + }, + { + "id": "err_429_daily", + "status": 429, + "body": { + "error": true, + "message": "Daily limit reached", + "code": "DAILY_LIMIT_EXCEEDED" + }, + "expect_error": "RateLimitError", + "retry": false + }, + { + "id": "err_429_retry_after", + "status": 429, + "body": { + "error": true, + "message": "Too many requests" + }, + "expect_error": "RateLimitError", + "headers": { + "Retry-After": "2" + }, + "retry": true, + "note": "IP limiter: retry after 2s, succeed on 2nd attempt \u2192 no error surfaced" + }, + { + "id": "err_429_exhausted", + "status": 429, + "body": { + "error": true, + "message": "Too many requests" + }, + "expect_error": "RateLimitError", + "retry": true, + "attempts": 4, + "note": "4 attempts then RateLimitError" + }, + { + "id": "err_500", + "status": 500, + "body": { + "error": true, + "message": "Internal error" + }, + "expect_error": "ServerError", + "retry": false + }, + { + "id": "err_503_get_retried", + "status": 503, + "body": { + "error": true, + "message": "unavailable" + }, + "expect_error": "ServerError", + "method": "GET", + "retry": true, + "note": "GET retried; succeed on 2nd attempt" + }, + { + "id": "err_503_post_not_retried", + "status": 503, + "body": { + "error": true, + "message": "unavailable" + }, + "expect_error": "ServerError", + "method": "POST", + "retry": false + }, + { + "id": "err_success_false_shape", + "status": 429, + "body": { + "success": false, + "message": "Too many attempts", + "code": "RATE_LIMITED" + }, + "expect_error": "RateLimitError" + }, + { + "id": "err_non_json", + "status": 502, + "body": "Bad Gateway", + "expect_error": "ServerError", + "note": "message falls back to status text" + }, + { + "id": "err_timeout", + "status": null, + "body": null, + "expect_error": "TimeoutError", + "note": "no response within timeout \u2192 TimeoutError (NetworkError subclass)" + }, + { + "id": "err_network", + "status": null, + "body": null, + "expect_error": "NetworkError", + "note": "connection refused" + }, + { + "id": "err_403_feature_disabled", + "status": 403, + "body": { + "error": true, + "message": "Live Globe feature not enabled", + "code": "FEATURE_DISABLED" + }, + "expect_error": "AuthorizationError", + "note": "recent clicks behind feature flag; not a path error" + }, + { + "id": "err_422_validation", + "status": 422, + "body": { + "error": true, + "code": "VALIDATION_FAILED", + "message": "expiresAt must be in the future" + }, + "expect_error": "ValidationError", + "note": "422 maps to ValidationError and reports status 422" + }, + { + "id": "err_429_resets_at_only", + "status": 429, + "body": { + "error": true, + "message": "Limit exceeded", + "resetsAt": "2026-10-01T00:00:00Z" + }, + "expect_error": "RateLimitError", + "retry": false, + "note": "resetsAt present with no quota code \u2192 quota-class, never retried" + }, + { + "id": "err_429_retry_after_oversized", + "status": 429, + "body": { + "error": true, + "message": "Too many requests" + }, + "headers": { + "Retry-After": "86400" + }, + "expect_error": "RateLimitError", + "retry": false, + "note": "Retry-After above the 30s cap \u2192 raise immediately with retry_after=86400, do not sleep" + }, + { + "id": "err_2xx_malformed_json", + "status": 200, + "body": "interstitial", + "expect_error": "SDKError", + "note": "non-JSON 2xx body must surface as an SDK error (ServerError acceptable), never a raw parse exception" + }, + { + "id": "err_user_cancel", + "status": null, + "body": null, + "expect_error": "CancelledError", + "note": "caller-initiated cancellation (signal/ctx) must not be reported as TimeoutError; idiomatic: TS distinct error or rethrown AbortError, Python asyncio.CancelledError passthrough, Go ctx.Err()" + } + ], + "behaviors": [ + { + "id": "redaction", + "assert": "repr/str/toString/%v of client, config and every error never contains the API key or import accessToken" + }, + { + "id": "user_agent", + "assert": "User-Agent matches ^awsysco-(python|ts|go)-sdk/\\d+\\.\\d+\\.\\d+ and version equals package version" + }, + { + "id": "auth_header", + "assert": "Authorization: Bearer on every authenticated request; absent on auth=none scenarios" + }, + { + "id": "base_url_override", + "assert": "baseUrl https://staging.awsys.co/ \u2192 requests go to https://staging.awsys.co/api/\u2026; 'ftp://x' or 'awsys.co' \u2192 ConfigurationError" + }, + { + "id": "missing_api_key", + "assert": "no key and no AWSYS_API_KEY \u2192 ConfigurationError before any request" + }, + { + "id": "unknown_fields_preserved", + "assert": "extra JSON fields in responses do not raise and are accessible" + }, + { + "id": "timestamp_variants", + "assert": "ISO string and {_seconds,_nanoseconds} both parse; garbage keeps raw string" + }, + { + "id": "iterator_links", + "assert": "list_all over scenarios list_links \u2192 list_links_last_page yields 3 links with 2 requests" + }, + { + "id": "body_read_within_timeout", + "assert": "timeout covers headers AND body read; a server that sends headers then stalls the body raises TimeoutError" + }, + { + "id": "config_warnings", + "assert": "key not starting with awsys_ and non-https base URL each emit one warning-level log line" + }, + { + "id": "release_tag_matches_version", + "assert": "publish/release workflow fails if the git tag does not equal v" + } + ] +} \ No newline at end of file diff --git a/tests/test_affiliate.py b/tests/test_affiliate.py index 02b6b26..3a4195a 100644 --- a/tests/test_affiliate.py +++ b/tests/test_affiliate.py @@ -4,7 +4,6 @@ from unittest.mock import MagicMock -import pytest from awsysco.models import AffiliateProgram from awsysco.resources.affiliate import AffiliateResource diff --git a/tests/test_agentlink.py b/tests/test_agentlink.py index 3c5816a..b2c6a66 100644 --- a/tests/test_agentlink.py +++ b/tests/test_agentlink.py @@ -4,7 +4,6 @@ from unittest.mock import MagicMock -import pytest from awsysco.resources.agentlink import AgentlinkResource diff --git a/tests/test_analytics.py b/tests/test_analytics.py index 91c333f..4f36c67 100644 --- a/tests/test_analytics.py +++ b/tests/test_analytics.py @@ -64,7 +64,7 @@ def test_get_recent_clicks_calls_endpoint(self): resource._http.get.return_value = [] resource.get_recent_clicks() resource._http.get.assert_called_once_with( - "/api/user/recent-clicks", params=None + "/api/user/clicks/recent", params=None ) def test_get_recent_clicks_with_limit(self): @@ -72,7 +72,16 @@ def test_get_recent_clicks_with_limit(self): resource._http.get.return_value = [] resource.get_recent_clicks(limit=10) resource._http.get.assert_called_once_with( - "/api/user/recent-clicks", params={"limit": 10} + "/api/user/clicks/recent", params={"limit": 10} + ) + + def test_get_recent_clicks_with_since(self): + resource = _make_resource() + resource._http.get.return_value = [] + resource.get_recent_clicks(limit=10, since="2026-01-01T00:00:00Z") + resource._http.get.assert_called_once_with( + "/api/user/clicks/recent", + params={"limit": 10, "since": "2026-01-01T00:00:00Z"}, ) def test_get_recent_clicks_returns_list(self): @@ -84,10 +93,12 @@ def test_get_recent_clicks_returns_list(self): assert isinstance(result, list) assert all(isinstance(c, ClickEvent) for c in result) - def test_get_recent_clicks_handles_wrapped_response(self): + def test_get_recent_clicks_handles_clicks_count_envelope(self): + """Live shape confirmed on staging: {"clicks": [...], "count": N}.""" resource = _make_resource() resource._http.get.return_value = { - "clicks": [{"timestamp": "2026-01-01T00:00:00Z"}] + "clicks": [{"timestamp": "2026-01-01T00:00:00Z"}], + "count": 1, } result = resource.get_recent_clicks() assert len(result) == 1 diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 36b8883..8d7c741 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -2,7 +2,6 @@ from __future__ import annotations -import pytest from awsysco import AsyncClient diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..fcd872e --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,142 @@ +"""Unit tests for client configuration: env fallback, validation, redaction, User-Agent.""" + +from __future__ import annotations + +import re +import warnings + +import pytest + +import awsysco +import awsysco._http +import awsysco.client +from awsysco import AsyncClient, Client +from awsysco.exceptions import AwsysConfigurationError + + +@pytest.fixture(autouse=True) +def _reset_one_shot_warning_flags(monkeypatch): + """The non-awsys-key and plain-http warnings are one-shot per process — + reset the module-level flags around every test so each test's expectations + don't depend on collection order.""" + monkeypatch.setattr(awsysco.client, "_warned_non_awsys_key", False) + monkeypatch.setattr(awsysco._http, "_warned_http_base_url", False) + + +class TestApiKeyResolution: + def test_explicit_api_key_used(self): + client = Client(api_key="awsys_explicit") + assert client._http.redacted_key.endswith("icit") + + def test_env_fallback(self, monkeypatch): + monkeypatch.setenv("AWSYS_API_KEY", "awsys_from_env") + client = Client() + assert client._http.redacted_key.endswith("_env") + + def test_missing_key_raises_configuration_error(self, monkeypatch): + monkeypatch.delenv("AWSYS_API_KEY", raising=False) + with pytest.raises(AwsysConfigurationError): + Client() + + def test_non_awsys_prefixed_key_warns(self, monkeypatch): + monkeypatch.delenv("AWSYS_API_KEY", raising=False) + with pytest.warns(UserWarning, match="does not look like"): + Client(api_key="sk-not-an-awsys-key") + + def test_non_awsys_prefixed_key_warns_only_once_per_process(self, monkeypatch): + monkeypatch.delenv("AWSYS_API_KEY", raising=False) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + Client(api_key="sk-not-an-awsys-key") + Client(api_key="sk-also-not-awsys") + assert len(caught) == 1 + + +class TestBaseUrlResolution: + def test_env_fallback(self, monkeypatch): + monkeypatch.setenv("AWSYS_BASE_URL", "https://staging.awsys.co") + client = Client(api_key="awsys_x") + assert client._http.base_url == "https://staging.awsys.co" + + def test_default_base_url(self, monkeypatch): + monkeypatch.delenv("AWSYS_BASE_URL", raising=False) + client = Client(api_key="awsys_x") + assert client._http.base_url == "https://awsys.co" + + def test_strips_trailing_slash(self): + client = Client(api_key="awsys_x", base_url="https://awsys.co/") + assert client._http.base_url == "https://awsys.co" + + def test_rejects_missing_scheme(self): + with pytest.raises(AwsysConfigurationError): + Client(api_key="awsys_x", base_url="awsys.co") + + def test_warns_on_plain_http(self): + with pytest.warns(UserWarning, match="unencrypted"): + Client(api_key="awsys_x", base_url="http://awsys.co") + + def test_warns_on_plain_http_only_once_per_process(self): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + Client(api_key="awsys_x", base_url="http://awsys.co") + Client(api_key="awsys_x", base_url="http://also-awsys.co") + assert len(caught) == 1 + + +class TestRedaction: + def test_client_repr_never_contains_full_key(self): + client = Client(api_key="awsys_supersecretvalue") + assert "supersecretvalue" not in repr(client) + assert "alue" in repr(client) + + def test_async_client_repr_never_contains_full_key(self): + client = AsyncClient(api_key="awsys_supersecretvalue") + assert "supersecretvalue" not in repr(client) + + def test_http_client_repr_never_contains_full_key(self): + client = Client(api_key="awsys_supersecretvalue") + assert "supersecretvalue" not in repr(client._http) + + +class TestUserAgent: + def test_user_agent_matches_contract_pattern(self): + client = Client(api_key="awsys_x") + ua = client._http._client.headers["User-Agent"] + assert re.match(r"^awsysco-python-sdk/\d+\.\d+\.\d+", ua) + + def test_user_agent_version_matches_package_version(self): + client = Client(api_key="awsys_x") + ua = client._http._client.headers["User-Agent"] + assert awsysco.__version__ in ua + + def test_pyproject_version_matches_dunder_version(self): + import pathlib + + pyproject = pathlib.Path(__file__).resolve().parents[1] / "pyproject.toml" + text = pyproject.read_text() + match = re.search(r'(?m)^version\s*=\s*"([^"]+)"', text) + assert match is not None, "could not find version in pyproject.toml" + assert match.group(1) == awsysco.__version__ + + +class TestPerCallTimeout: + def test_timeout_override_is_threaded_through(self, monkeypatch): + client = Client(api_key="awsys_x") + captured = {} + + def fake_request(method, path, **kwargs): + captured.update(kwargs) + + class _Resp: + status_code = 200 + content = b"{}" + is_error = False + + def json(self): + return {} + + return _Resp() + + monkeypatch.setattr(client._http._client, "request", fake_request) + client._http.get("/api/v1/me", timeout=5.0) + assert captured["timeout"] == 5.0 diff --git a/tests/test_contract.py b/tests/test_contract.py new file mode 100644 index 0000000..b8e8e6b --- /dev/null +++ b/tests/test_contract.py @@ -0,0 +1,877 @@ +"""Contract-fixture test suite. + +Drives every capability/error/behavior scenario in ``tests/contracts/sdk-contract.json`` +(vendored from the platform repo) against the SDK. Per Gate 3: a scenario with no +registered handler FAILS the collection-time check below rather than being silently +skipped, so a contract update that adds a new scenario cannot go unnoticed. +""" + +from __future__ import annotations + +import json +import pathlib +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from awsysco._transport import parse_error +from awsysco.exceptions import ( + AwsysAuthError, + AwsysConflictError, + AwsysError, + AwsysForbiddenError, + AwsysNetworkError, + AwsysNotFoundError, + AwsysRateLimitError, + AwsysServerError, + AwsysTimeoutError, + AwsysValidationError, +) +from awsysco.resources.affiliate import AffiliateResource +from awsysco.resources.agentlink import AgentlinkResource +from awsysco.resources.analytics import AnalyticsResource +from awsysco.resources.bulk import BulkResource +from awsysco.resources.custom_domains import CustomDomainsResource +from awsysco.resources.data_export import DataExportResource +from awsysco.resources.folders import FoldersResource +from awsysco.resources.imports import ImportsResource +from awsysco.resources.links import LinksResource +from awsysco.resources.me import MeResource +from awsysco.resources.namespace import NamespaceResource +from awsysco.resources.profile import ProfileResource +from awsysco.resources.qr import QRResource +from awsysco.resources.saved_views import SavedViewsResource +from awsysco.resources.tags import TagsResource +from awsysco.resources.trust_score import TrustScoreResource +from awsysco.resources.usage import UsageResource +from awsysco.resources.utm_templates import UtmTemplatesResource +from awsysco.resources.web2app import Web2AppResource +from awsysco.resources.webhooks import WebhooksResource + +_CONTRACT_PATH = pathlib.Path(__file__).parent / "contracts" / "sdk-contract.json" +_CONTRACT = json.loads(_CONTRACT_PATH.read_text()) + +# Diff against the platform's own copy when it's checked out alongside this repo +# (CI won't have it — skip cleanly there). +_PLATFORM_CONTRACT_PATH = ( + pathlib.Path(__file__).parents[3] / "awsys-shortener" / "contracts" / "sdk-contract.json" +) + + +def test_vendored_contract_matches_platform_copy(): + if not _PLATFORM_CONTRACT_PATH.exists(): + pytest.skip("platform repo not checked out alongside this one") + platform_contract = json.loads(_PLATFORM_CONTRACT_PATH.read_text()) + assert _CONTRACT == platform_contract, ( + "tests/contracts/sdk-contract.json is stale — re-vendor from " + f"{_PLATFORM_CONTRACT_PATH}" + ) + + +# --------------------------------------------------------------------------- +# Resource harness +# --------------------------------------------------------------------------- + + +def _build_resources() -> SimpleNamespace: + http = MagicMock() + http.base_url = "https://awsys.co" + return SimpleNamespace( + http=http, + links=LinksResource(http), + analytics=AnalyticsResource(http), + qr=QRResource(http), + folders=FoldersResource(http), + bulk=BulkResource(http), + me=MeResource(http), + tags=TagsResource(http), + trust_score=TrustScoreResource(http), + data_export=DataExportResource(http), + namespace=NamespaceResource(http), + utm_templates=UtmTemplatesResource(http), + webhooks=WebhooksResource(http), + saved_views=SavedViewsResource(http), + custom_domains=CustomDomainsResource(http), + agentlink=AgentlinkResource(http), + affiliate=AffiliateResource(http), + usage=UsageResource(http), + web2app=Web2AppResource(http), + imports=ImportsResource(http), + profile=ProfileResource(http), + ) + + +def _set_json(http: MagicMock, method: str, body) -> None: + getattr(http, method).return_value = body + + +def _set_text(http: MagicMock, method: str, text: str) -> None: + getattr(http, method).return_value = text + + +# --------------------------------------------------------------------------- +# Capability handlers — one per fixture id. Each: (1) primes the mock transport +# with the fixture's response body, (2) calls the SDK method with matching +# arguments, (3) asserts the transport was called with the fixture's exact +# method/path/query/body, (4) sanity-checks the parsed result. +# --------------------------------------------------------------------------- + +CapabilityHandler = "Callable[[SimpleNamespace, dict], None]" + + +def _h_create_link(r, e): + _set_json(r.http, "post", e["response"]["body"]) + result = r.links.create(e["request"]["body"]["url"]) + r.http.post.assert_called_once_with("/api/v1/links", json=e["request"]["body"]) + assert result.short_code == e["response"]["body"]["shortCode"] + + +def _h_create_link_custom_slug(r, e): + _set_json(r.http, "post", e["response"]["body"]) + body = e["request"]["body"] + result = r.links.create( + body["url"], custom_slug=body["customSlug"], expires_at=body["expiresAt"], max_clicks=body["maxClicks"] + ) + r.http.post.assert_called_once_with("/api/v1/links", json=body) + assert result.short_code == e["response"]["body"]["shortCode"] + + +def _h_list_links(r, e): + _set_json(r.http, "get", e["response"]["body"]) + q = e["request"]["query"] + result = r.links.list(limit=int(q["limit"]), offset=int(q["offset"])) + r.http.get.assert_called_once_with( + "/api/v1/links", params={"limit": int(q["limit"]), "offset": int(q["offset"])} + ) + assert len(result.links) == len(e["response"]["body"]["links"]) + + +def _h_list_links_limit_clamped(r, e): + _set_json(r.http, "get", e["response"]["body"]) + r.links.list(limit=500, offset=0) + r.http.get.assert_called_once_with("/api/v1/links", params={"limit": 100, "offset": 0}) + + +def _h_get_link(r, e): + _set_json(r.http, "get", e["response"]["body"]) + short = e["request"]["path"].rsplit("/", 1)[-1] + result = r.links.get(short) + r.http.get.assert_called_once_with(e["request"]["path"]) + assert result.id == e["response"]["body"]["id"] + + +def _h_get_link_namespaced(r, e): + _set_json(r.http, "get", e["response"]["body"]) + short = e["request"]["path"].split("/api/v1/links/", 1)[1] + result = r.links.get(short) + # note: slash must NOT be encoded for GET wildcard routes + r.http.get.assert_called_once_with(e["request"]["path"]) + assert result.full_path == "ns/slug" + + +def _h_update_link(r, e): + _set_json(r.http, "patch", e["response"]["body"]) + short = e["request"]["path"].rsplit("/", 1)[-1] + body = e["request"]["body"] + r.links.update(short, max_clicks=body["maxClicks"], expires_at=body["expiresAt"]) + r.http.patch.assert_called_once_with(e["request"]["path"], json=body) + + +def _h_delete_link(r, e): + _set_json(r.http, "delete", e["response"]["body"]) + short = e["request"]["path"].rsplit("/", 1)[-1] + r.links.delete(short) + r.http.delete.assert_called_once_with(e["request"]["path"]) + + +def _h_link_stats(r, e): + _set_json(r.http, "get", e["response"]["body"]) + short = e["request"]["path"].split("/")[4] + result = r.analytics.get_stats(short, period=e["request"]["query"]["period"]) + r.http.get.assert_called_once_with(e["request"]["path"], params=e["request"]["query"]) + assert result.total_clicks == e["response"]["body"]["totalClicks"] + + +def _h_aggregate_stats(r, e): + _set_json(r.http, "get", e["response"]["body"]) + short = e["request"]["path"].split("/")[4] + result = r.analytics.get_aggregate_stats(short, period=e["request"]["query"]["period"]) + r.http.get.assert_called_once_with(e["request"]["path"], params=e["request"]["query"]) + assert result.total_clicks == e["response"]["body"]["totalClicks"] + + +def _h_bulk_create(r, e): + _set_json(r.http, "post", e["response"]["body"]) + urls = e["request"]["body"]["urls"] + result = r.bulk.create(urls) + r.http.post.assert_called_once_with("/api/v1/bulk", json={"urls": urls}) + assert result.created == e["response"]["body"]["summary"]["created"] + + +def _h_me(r, e): + _set_json(r.http, "get", e["response"]["body"]) + result = r.me.get() + r.http.get.assert_called_once_with("/api/v1/me") + assert result.email == e["response"]["body"]["email"] + + +def _h_usage(r, e): + _set_json(r.http, "get", e["response"]["body"]) + r.usage.get() + r.http.get.assert_called_once_with("/api/user/stats") + + +def _h_recent_clicks(r, e): + _set_json(r.http, "get", e["response"]["body"]) + q = e["request"]["query"] + result = r.analytics.get_recent_clicks(limit=int(q["limit"])) + r.http.get.assert_called_once_with("/api/user/clicks/recent", params={"limit": int(q["limit"])}) + assert len(result) == len(e["response"]["body"]["clicks"]) + + +def _h_profile_get(r, e): + _set_json(r.http, "get", e["response"]["body"]) + result = r.profile.get() + r.http.get.assert_called_once_with("/api/user/profile") + assert result.email == e["response"]["body"]["email"] + + +def _h_profile_update(r, e): + _set_json(r.http, "patch", e["response"]["body"]) + r.profile.update(display_name=e["request"]["body"]["displayName"]) + r.http.patch.assert_called_once_with("/api/user/profile", json=e["request"]["body"]) + + +def _h_qr_url(r, e): + q = e["request"]["query"] + url = r.qr.get_url("abc123", size=int(q["size"]), color=q["color"], bg_color=q["bgColor"]) + assert url == "https://awsys.co/api/qr/abc123?size=300&color=000000&bgColor=ffffff" + + +def _h_qr_settings_get(r, e): + _set_json(r.http, "get", e["response"]["body"]) + r.qr.get_settings("abc123") + r.http.get.assert_called_once_with(e["request"]["path"]) + + +def _h_qr_settings_update(r, e): + _set_json(r.http, "put", e["response"]["body"]) + r.qr.update_settings("abc123", e["request"]["body"]) + r.http.put.assert_called_once_with(e["request"]["path"], json=e["request"]["body"]) + + +def _h_folders_list(r, e): + _set_json(r.http, "get", e["response"]["body"]) + result = r.folders.list() + r.http.get.assert_called_once_with("/api/v1/folders") + assert len(result.folders) == len(e["response"]["body"]["folders"]) + + +def _h_folder_create(r, e): + _set_json(r.http, "post", e["response"]["body"]) + r.folders.create(e["request"]["body"]["name"]) + r.http.post.assert_called_once_with("/api/v1/folders", json=e["request"]["body"]) + + +def _h_folder_update(r, e): + _set_json(r.http, "patch", e["response"]["body"]) + r.folders.update("f1", name=e["request"]["body"]["name"]) + r.http.patch.assert_called_once_with(e["request"]["path"], json=e["request"]["body"]) + + +def _h_folder_delete(r, e): + _set_json(r.http, "delete", e["response"]["body"]) + r.folders.delete("f1") + r.http.delete.assert_called_once_with(e["request"]["path"]) + + +def _h_folder_assign(r, e): + _set_json(r.http, "post", e["response"]["body"]) + r.folders.assign_link("abc123", e["request"]["body"]["folderId"]) + r.http.post.assert_called_once_with(e["request"]["path"], json=e["request"]["body"]) + + +def _h_folder_remove(r, e): + _set_json(r.http, "post", e["response"]["body"]) + r.folders.remove_link("abc123") + r.http.post.assert_called_once_with(e["request"]["path"], json={"folderId": None}) + + +def _h_tags_add(r, e): + _set_json(r.http, "post", e["response"]["body"]) + tag = e["request"]["body"]["tags"][0] + r.tags.add("abc123", tag) + r.http.post.assert_called_once_with(e["request"]["path"], json={"tags": [tag]}) + + +def _h_tag_remove(r, e): + _set_json(r.http, "delete", e["response"]["body"]) + r.tags.remove("abc123", "a") + r.http.delete.assert_called_once_with(e["request"]["path"]) + + +def _h_views_list(r, e): + _set_json(r.http, "get", e["response"]["body"]) + result = r.saved_views.list() + r.http.get.assert_called_once_with("/api/views") + assert len(result) == len(e["response"]["body"]["views"]) + + +def _h_view_create(r, e): + _set_json(r.http, "post", e["response"]["body"]) + body = e["request"]["body"] + r.saved_views.create(body["name"], body["filters"]) + r.http.post.assert_called_once_with("/api/views", json=body) + + +def _h_view_update(r, e): + _set_json(r.http, "patch", e["response"]["body"]) + r.saved_views.update("v1", name=e["request"]["body"]["name"]) + r.http.patch.assert_called_once_with(e["request"]["path"], json=e["request"]["body"]) + + +def _h_view_delete(r, e): + _set_json(r.http, "delete", e["response"]["body"]) + r.saved_views.delete("v1") + r.http.delete.assert_called_once_with(e["request"]["path"]) + + +def _h_utm_list_via_me(r, e): + _set_json(r.http, "get", e["response"]["body"]) + result = r.utm_templates.list() + r.http.get.assert_called_once_with("/api/v1/me") + assert len(result) == len(e["response"]["body"]["utmTemplates"]) + + +def _h_utm_create(r, e): + _set_json(r.http, "post", e["response"]["body"]) + body = e["request"]["body"] + r.utm_templates.create(body["name"], body["utmSource"], body["utmMedium"], body["utmCampaign"]) + called_body = r.http.post.call_args[1]["json"] + assert called_body["name"] == body["name"] + + +def _h_utm_delete(r, e): + _set_json(r.http, "delete", e["response"]["body"]) + r.utm_templates.delete("t1") + r.http.delete.assert_called_once_with(e["request"]["path"]) + + +def _h_webhook_event_types(r, e): + _set_json(r.http, "get", e["response"]["body"]) + r.webhooks.list_event_types() + r.http.get.assert_called_once_with("/api/webhooks/event-types") + + +def _h_webhooks_list(r, e): + _set_json(r.http, "get", e["response"]["body"]) + r.webhooks.list() + r.http.get.assert_called_once_with("/api/v1/webhooks") + + +def _h_webhook_create(r, e): + _set_json(r.http, "post", e["response"]["body"]) + body = e["request"]["body"] + r.webhooks.create(body["url"], body["events"]) + r.http.post.assert_called_once_with("/api/v1/webhooks", json=body) + + +def _h_webhook_update(r, e): + _set_json(r.http, "patch", e["response"]["body"]) + r.webhooks.update("w1", enabled=e["request"]["body"]["enabled"]) + r.http.patch.assert_called_once_with(e["request"]["path"], json=e["request"]["body"]) + + +def _h_webhook_delete(r, e): + _set_json(r.http, "delete", e["response"]["body"]) + r.webhooks.delete("w1") + r.http.delete.assert_called_once_with(e["request"]["path"]) + + +def _h_webhook_test(r, e): + _set_json(r.http, "post", e["response"]["body"]) + r.webhooks.test("w1", e["request"]["body"]["eventType"]) + r.http.post.assert_called_once_with(e["request"]["path"], json=e["request"]["body"]) + + +def _h_domains_list(r, e): + _set_json(r.http, "get", e["response"]["body"]) + r.custom_domains.list() + r.http.get.assert_called_once_with("/api/user/domains") + + +def _h_domain_add(r, e): + _set_json(r.http, "post", e["response"]["body"]) + r.custom_domains.add(e["request"]["body"]["domain"]) + r.http.post.assert_called_once_with("/api/user/domains", json=e["request"]["body"]) + + +def _h_domain_verify(r, e): + _set_json(r.http, "get", e["response"]["body"]) + r.custom_domains.verify("go.example.com") + r.http.get.assert_called_once_with(e["request"]["path"]) + + +def _h_domain_activate_deprecated(r, e): + with pytest.deprecated_call(): + with pytest.raises(AwsysForbiddenError): + r.custom_domains.activate("go.example.com") + r.http.post.assert_not_called() + + +def _h_domain_update(r, e): + _set_json(r.http, "patch", e["response"]["body"]) + body = e["request"]["body"] + result = r.custom_domains.update("go.example.com", default_redirect=body["defaultRedirect"]) + r.http.patch.assert_called_once_with(e["request"]["path"], json=body) + assert result.default_redirect == body["defaultRedirect"] + + +def _h_domain_remove(r, e): + _set_json(r.http, "delete", e["response"]["body"]) + r.custom_domains.remove("go.example.com") + r.http.delete.assert_called_once_with(e["request"]["path"]) + + +def _h_domain_check(r, e): + _set_json(r.http, "get", e["response"]["body"]) + r.custom_domains.check("go.example.com") + r.http.get.assert_called_once_with(e["request"]["path"]) + + +def _h_namespace_get(r, e): + _set_json(r.http, "get", e["response"]["body"]) + r.namespace.get() + r.http.get.assert_called_once_with("/api/user/namespace") + + +def _h_namespace_check(r, e): + _set_json(r.http, "get", e["response"]["body"]) + r.namespace.check("acme") + r.http.get.assert_called_once_with(e["request"]["path"]) + + +def _h_namespace_claim(r, e): + _set_json(r.http, "post", e["response"]["body"]) + r.namespace.claim("acme") + r.http.post.assert_called_once_with("/api/user/namespace", json=e["request"]["body"]) + + +def _h_namespace_release(r, e): + _set_json(r.http, "delete", e["response"]["body"]) + r.namespace.release() + r.http.delete.assert_called_once_with("/api/user/namespace") + + +def _h_affiliate_program_create(r, e): + _set_json(r.http, "post", e["response"]["body"]) + r.affiliate.create_program("P", "cpa_return", cpa_rate=10) + assert r.http.post.call_args[0][0] == "/api/affiliate/programs" + + +def _h_affiliate_programs_list(r, e): + _set_json(r.http, "get", e["response"]["body"]) + r.affiliate.list_programs() + r.http.get.assert_called_once_with("/api/affiliate/programs") + + +def _h_affiliate_program_get(r, e): + _set_json(r.http, "get", e["response"]["body"]) + r.affiliate.get_program("p1") + r.http.get.assert_called_once_with(e["request"]["path"]) + + +def _h_affiliate_program_update(r, e): + _set_json(r.http, "patch", e["response"]["body"]) + r.affiliate.update_program("p1", name="P2") + r.http.patch.assert_called_once_with(e["request"]["path"], json={"name": "P2"}) + + +def _h_affiliate_program_stats(r, e): + _set_json(r.http, "get", e["response"]["body"]) + r.affiliate.get_program_stats("p1", period=e["request"]["query"]["period"]) + r.http.get.assert_called_once_with(e["request"]["path"], params=e["request"]["query"]) + + +def _h_affiliate_partners_list(r, e): + _set_json(r.http, "get", e["response"]["body"]) + r.affiliate.list_partners("p1") + r.http.get.assert_called_once_with(e["request"]["path"]) + + +def _h_affiliate_partner_status(r, e): + _set_json(r.http, "patch", e["response"]["body"]) + r.affiliate.update_partner_status("p1", "pt1", e["request"]["body"]["status"]) + r.http.patch.assert_called_once_with(e["request"]["path"], json=e["request"]["body"]) + + +def _h_affiliate_discover(r, e): + _set_json(r.http, "get", e["response"]["body"]) + r.affiliate.discover(limit=int(e["request"]["query"]["limit"])) + r.http.get.assert_called_once_with( + "/api/affiliate/discover", params={"limit": int(e["request"]["query"]["limit"])} + ) + + +def _h_affiliate_join(r, e): + _set_json(r.http, "post", e["response"]["body"]) + r.affiliate.join("p9", partner_code=e["request"]["body"]["partnerCode"]) + r.http.post.assert_called_once_with(e["request"]["path"], json=e["request"]["body"]) + + +def _h_affiliate_partnerships_list(r, e): + _set_json(r.http, "get", e["response"]["body"]) + r.affiliate.list_partnerships() + r.http.get.assert_called_once_with("/api/affiliate/partnerships") + + +def _h_affiliate_partnership_stats(r, e): + _set_json(r.http, "get", e["response"]["body"]) + r.affiliate.get_partnership_stats("ps1", period=e["request"]["query"]["period"]) + r.http.get.assert_called_once_with(e["request"]["path"], params=e["request"]["query"]) + + +def _h_affiliate_leave(r, e): + _set_json(r.http, "delete", e["response"]["body"]) + r.affiliate.leave_program("ps1") + r.http.delete.assert_called_once_with(e["request"]["path"]) + + +def _h_affiliate_limits(r, e): + _set_json(r.http, "get", e["response"]["body"]) + r.affiliate.get_limits() + r.http.get.assert_called_once_with("/api/affiliate/limits") + + +def _h_agentlink_link_stats(r, e): + _set_json(r.http, "get", e["response"]["body"]) + r.agentlink.get_link_stats("abc123", period_days=int(e["request"]["query"]["period"])) + r.http.get.assert_called_once_with( + e["request"]["path"], params={"period": int(e["request"]["query"]["period"])} + ) + + +def _h_agentlink_account_stats(r, e): + _set_json(r.http, "get", e["response"]["body"]) + r.agentlink.get_account_stats(period_days=int(e["request"]["query"]["period"])) + r.http.get.assert_called_once_with( + "/api/agentlink/account/stats", params={"period": int(e["request"]["query"]["period"])} + ) + + +def _h_agentlink_subscribe(r, e): + _set_json(r.http, "post", e["response"]["body"]) + r.agentlink.subscribe(e["request"]["body"]["email"]) + r.http.post.assert_called_once_with("/api/agentlink/subscribe", json=e["request"]["body"]) + + +def _h_web2app_consume(r, e): + _set_json(r.http, "get", e["response"]["body"]) + r.web2app.consume_session("tok123") + r.http.get.assert_called_once_with(e["request"]["path"]) + + +def _h_import_start(r, e): + _set_json(r.http, "post", e["response"]["body"]) + body = e["request"]["body"] + r.imports.start(provider=body["provider"], access_token=body["accessToken"], scan_only=body.get("scanOnly")) + r.http.post.assert_called_once_with("/api/v1/imports", json=body) + + +def _h_imports_list(r, e): + _set_json(r.http, "get", e["response"]["body"]) + r.imports.list(limit=int(e["request"]["query"]["limit"])) + r.http.get.assert_called_once_with( + "/api/v1/imports", params={"limit": int(e["request"]["query"]["limit"])} + ) + + +def _h_import_get(r, e): + _set_json(r.http, "get", e["response"]["body"]) + r.imports.get_status("j1") + r.http.get.assert_called_once_with(e["request"]["path"]) + + +def _h_import_cancel(r, e): + _set_json(r.http, "delete", e["response"]["body"]) + r.imports.cancel("j1") + r.http.delete.assert_called_once_with(e["request"]["path"]) + + +def _h_import_redirect_map_csv(r, e): + _set_text(r.http, "get_text", e["response"]["body"]) + result = r.imports.get_redirect_map_csv("j1") + r.http.get_text.assert_called_once_with(e["request"]["path"]) + assert result == e["response"]["body"] + + +def _h_import_redirect_map_json(r, e): + _set_json(r.http, "get", e["response"]["body"]) + result = r.imports.get_redirect_map_json("j1") + r.http.get.assert_called_once_with(e["request"]["path"]) + assert result == e["response"]["body"] + + +def _h_export_links_csv(r, e): + _set_text(r.http, "get_text", e["response"]["body"]) + result = r.data_export.export_links() + r.http.get_text.assert_called_once_with("/api/export/links") + assert result == e["response"]["body"] + + +def _h_export_link_stats_csv(r, e): + _set_text(r.http, "get_text", e["response"]["body"]) + result = r.data_export.export_link_stats("abc123") + r.http.get_text.assert_called_once_with(e["request"]["path"]) + assert result == e["response"]["body"] + + +def _h_trust_scan(r, e): + _set_json(r.http, "get", e["response"]["body"]) + result = r.trust_score.scan("abc123") + r.http.get.assert_called_once_with(e["request"]["path"]) + assert result.score == e["response"]["body"]["trustScore"] + + +CAPABILITY_HANDLERS = { + "create_link": _h_create_link, + "create_link_custom_slug": _h_create_link_custom_slug, + "list_links": _h_list_links, + "list_links_last_page": _h_list_links, + "list_links_missing_hasmore": _h_list_links, + "list_links_limit_clamped": _h_list_links_limit_clamped, + "get_link": _h_get_link, + "get_link_namespaced": _h_get_link_namespaced, + "update_link": _h_update_link, + "delete_link": _h_delete_link, + "link_stats": _h_link_stats, + "aggregate_stats": _h_aggregate_stats, + "bulk_create": _h_bulk_create, + "me": _h_me, + "usage": _h_usage, + "recent_clicks": _h_recent_clicks, + "profile_get": _h_profile_get, + "profile_update": _h_profile_update, + "qr_url": _h_qr_url, + "qr_settings_get": _h_qr_settings_get, + "qr_settings_update": _h_qr_settings_update, + "folders_list": _h_folders_list, + "folder_create": _h_folder_create, + "folder_update": _h_folder_update, + "folder_delete": _h_folder_delete, + "folder_assign": _h_folder_assign, + "folder_remove": _h_folder_remove, + "tags_add": _h_tags_add, + "tag_remove": _h_tag_remove, + "views_list": _h_views_list, + "view_create": _h_view_create, + "view_update": _h_view_update, + "view_delete": _h_view_delete, + "utm_list_via_me": _h_utm_list_via_me, + "utm_create": _h_utm_create, + "utm_delete": _h_utm_delete, + "webhook_event_types": _h_webhook_event_types, + "webhooks_list": _h_webhooks_list, + "webhook_create": _h_webhook_create, + "webhook_update": _h_webhook_update, + "webhook_delete": _h_webhook_delete, + "webhook_test": _h_webhook_test, + "domains_list": _h_domains_list, + "domain_add": _h_domain_add, + "domain_verify": _h_domain_verify, + "domain_activate_deprecated": _h_domain_activate_deprecated, + "domain_update": _h_domain_update, + "domain_remove": _h_domain_remove, + "domain_check": _h_domain_check, + "namespace_get": _h_namespace_get, + "namespace_check": _h_namespace_check, + "namespace_claim": _h_namespace_claim, + "namespace_release": _h_namespace_release, + "affiliate_program_create": _h_affiliate_program_create, + "affiliate_programs_list": _h_affiliate_programs_list, + "affiliate_program_get": _h_affiliate_program_get, + "affiliate_program_update": _h_affiliate_program_update, + "affiliate_program_stats": _h_affiliate_program_stats, + "affiliate_partners_list": _h_affiliate_partners_list, + "affiliate_partner_status": _h_affiliate_partner_status, + "affiliate_discover": _h_affiliate_discover, + "affiliate_join": _h_affiliate_join, + "affiliate_partnerships_list": _h_affiliate_partnerships_list, + "affiliate_partnership_stats": _h_affiliate_partnership_stats, + "affiliate_leave": _h_affiliate_leave, + "affiliate_limits": _h_affiliate_limits, + "agentlink_link_stats": _h_agentlink_link_stats, + "agentlink_account_stats": _h_agentlink_account_stats, + "agentlink_subscribe": _h_agentlink_subscribe, + "web2app_consume": _h_web2app_consume, + "import_start": _h_import_start, + "imports_list": _h_imports_list, + "import_get": _h_import_get, + "import_cancel": _h_import_cancel, + "import_redirect_map_csv": _h_import_redirect_map_csv, + "import_redirect_map_json": _h_import_redirect_map_json, + "export_links_csv": _h_export_links_csv, + "export_link_stats_csv": _h_export_link_stats_csv, + "trust_scan": _h_trust_scan, +} + + +@pytest.mark.parametrize("entry", _CONTRACT["capabilities"], ids=lambda e: e["id"]) +def test_capability_scenario(entry): + if entry["id"] not in CAPABILITY_HANDLERS: + pytest.fail( + f"No contract handler registered for capability {entry['id']!r} — " + "add one to CAPABILITY_HANDLERS in tests/test_contract.py (Gate 3: " + "unmapped scenarios must fail, not skip)." + ) + resources = _build_resources() + CAPABILITY_HANDLERS[entry["id"]](resources, entry) + + +# --------------------------------------------------------------------------- +# Error scenarios — generic: parse_error() handles every body shape uniformly, +# so most scenarios need no per-id handler. The few that assert retry behavior +# reuse the retry-loop coverage in test_transport.py; here we only check the +# status → exception class mapping (and, for feature-disabled, the .code). +# --------------------------------------------------------------------------- + +_ERROR_CLASS_MAP = { + "AuthenticationError": AwsysAuthError, + "AuthorizationError": AwsysForbiddenError, + "ValidationError": AwsysValidationError, + "NotFoundError": AwsysNotFoundError, + "ConflictError": AwsysConflictError, + "RateLimitError": AwsysRateLimitError, + "ServerError": AwsysServerError, + "TimeoutError": AwsysTimeoutError, + "NetworkError": AwsysNetworkError, +} + +# Scenarios with status=None describe transport-level failures (no HTTP response at +# all) — those are exercised directly against HttpClient in test_transport.py +# (TestSyncRetryLoop.test_timeout_*, test_transport_error_on_post_not_retried), not +# via parse_error (which requires a response object). Retry-behavior scenarios are +# likewise covered end-to-end there. We still assert every id maps to a known +# expect_error class so a new, unrecognized error type in the contract fails loudly. +_TRANSPORT_LEVEL_IDS = {"err_timeout", "err_network"} +_RETRY_BEHAVIOR_IDS = { + "err_429_retry_after", + "err_429_exhausted", + "err_503_get_retried", + "err_503_post_not_retried", +} +# expect_error is not an AwsysError subclass name for these — each is a distinct, +# already-covered-elsewhere case handled specially rather than through parse_error. +_SPECIAL_CASE_ERROR_COVERAGE = { + # non-JSON 2xx body → typed SDKError (AwsysServerError here), not a raw + # JSONDecodeError — exercised against the real HttpClient/AsyncHttpClient + # request path (parse_error is never reached; the transport's own try/except + # around response.json() is what's under test). + "err_2xx_malformed_json": ( + "tests/test_transport.py::TestAdditionalContractRequirements::" + "test_non_json_2xx_body_raises_typed_error_not_raw_decode_error (+ _async)" + ), + # caller-initiated cancellation must pass through unmodified, never become + # AwsysTimeoutError — asyncio.CancelledError is a BaseException, so it's never + # caught by the `except httpx.TimeoutException`/`except httpx.TransportError` + # clauses in the first place; the test proves that stays true. + "err_user_cancel": ( + "tests/test_transport.py::TestAdditionalContractRequirements::" + "test_cancelled_error_passes_through_unmodified" + ), +} + + +class _FakeResponse: + def __init__(self, status_code, body, headers=None): + self.status_code = status_code + self._body = body + self.headers = headers or {} + self.is_error = status_code >= 400 + + def json(self): + if not isinstance(self._body, dict): + raise ValueError("not json") + return self._body + + @property + def text(self): + return self._body if isinstance(self._body, str) else "" + + @property + def reason_phrase(self): + import http.client + + return http.client.responses.get(self.status_code, "") + + +@pytest.mark.parametrize("entry", _CONTRACT["errors"], ids=lambda e: e["id"]) +def test_error_scenario(entry): + if entry["id"] in _SPECIAL_CASE_ERROR_COVERAGE: + return # covered elsewhere — see _SPECIAL_CASE_ERROR_COVERAGE for the pointer + + if entry["expect_error"] not in _ERROR_CLASS_MAP: + pytest.fail(f"Unrecognized expect_error {entry['expect_error']!r} for {entry['id']!r}") + expected_cls = _ERROR_CLASS_MAP[entry["expect_error"]] + + if entry["id"] in _TRANSPORT_LEVEL_IDS or entry["id"] in _RETRY_BEHAVIOR_IDS: + # Covered end-to-end (with a mocked clock) in test_transport.py. + assert issubclass(expected_cls, AwsysError) + return + + resp = _FakeResponse(entry["status"], entry["body"], headers=entry.get("headers")) + exc = parse_error(resp) + assert isinstance(exc, expected_cls), f"{entry['id']}: expected {expected_cls}, got {type(exc)}" + if entry["id"] == "err_403_feature_disabled": + assert exc.code == "FEATURE_DISABLED" + + +# --------------------------------------------------------------------------- +# Behaviors — cross-cutting assertions already covered elsewhere; this table +# just proves every declared behavior id has a home, per Gate 3. +# --------------------------------------------------------------------------- + +_BEHAVIOR_COVERAGE = { + "redaction": "tests/test_config.py::TestRedaction", + "user_agent": "tests/test_config.py::TestUserAgent", + "auth_header": "tests/test_config.py (Authorization header set at HttpClient construction)", + "retry_policy": "tests/test_transport.py::TestSyncRetryLoop / TestAsyncRetryLoop", + "pagination": "tests/test_links.py::TestLinksListAll", + "error_body_tolerance": "tests/test_transport.py::TestParseErrorBodyShapes", + "base_url_validation": "tests/test_config.py::TestBaseUrlResolution", + "timeout_override": "tests/test_config.py::TestPerCallTimeout", + "base_url_override": "tests/test_config.py::TestBaseUrlResolution", + "missing_api_key": "tests/test_config.py::TestApiKeyResolution::test_missing_key_raises_configuration_error", + "unknown_fields_preserved": "tests/test_models.py::TestUnknownFieldsPreserved", + "timestamp_variants": "tests/test_models.py::TestTimestampCoercion", + "iterator_links": "test_iterator_links_behavior (below)", + "body_read_within_timeout": "tests/test_transport.py::TestTimeoutCoversBodyRead", + "config_warnings": ( + "tests/test_config.py::TestApiKeyResolution::test_non_awsys_prefixed_key_warns(_only_once...) " + "/ TestBaseUrlResolution::test_warns_on_plain_http(_only_once...)" + ), + "release_tag_matches_version": ".github/workflows/publish.yml (Verify tag matches package version step)", +} + + +def test_iterator_links_behavior(): + """behaviors.iterator_links: list_all() over list_links → list_links_last_page + yields 3 links across exactly 2 requests.""" + page1 = next(c for c in _CONTRACT["capabilities"] if c["id"] == "list_links") + page2 = next(c for c in _CONTRACT["capabilities"] if c["id"] == "list_links_last_page") + r = _build_resources() + r.http.get.side_effect = [page1["response"]["body"], page2["response"]["body"]] + results = list(r.links.list_all(limit=2)) + assert len(results) == 3 + assert r.http.get.call_count == 2 + + +@pytest.mark.parametrize("entry", _CONTRACT["behaviors"], ids=lambda e: e["id"]) +def test_behavior_scenario_is_covered(entry): + if entry["id"] not in _BEHAVIOR_COVERAGE: + pytest.fail( + f"No coverage note registered for behavior {entry['id']!r} — add one to " + "_BEHAVIOR_COVERAGE in tests/test_contract.py, pointing at the test(s) " + "that actually exercise it (Gate 3: unmapped scenarios must fail)." + ) diff --git a/tests/test_custom_domains.py b/tests/test_custom_domains.py index 261221b..d87179b 100644 --- a/tests/test_custom_domains.py +++ b/tests/test_custom_domains.py @@ -6,6 +6,7 @@ import pytest +from awsysco.exceptions import AwsysForbiddenError from awsysco.models import CustomDomain from awsysco.resources.custom_domains import CustomDomainsResource @@ -50,18 +51,13 @@ def test_verify_calls_correct_endpoint(self): "/api/user/domains/links.example.com/verify" ) - def test_activate_calls_correct_endpoint(self): + def test_activate_is_deprecated_and_forbidden(self): + """activate() is Firebase-only (ADR-006) — always raises, never hits the network.""" resource = _make_resource() - resource.activate("links.example.com") - resource._http.post.assert_called_once_with( - "/api/user/domains/links.example.com/activate" - ) - - def test_activate_returns_custom_domain(self): - resource = _make_resource() - result = resource.activate("links.example.com") - assert isinstance(result, CustomDomain) - assert result.domain == "links.example.com" + with pytest.deprecated_call(): + with pytest.raises(AwsysForbiddenError): + resource.activate("links.example.com") + resource._http.post.assert_not_called() def test_update_sends_is_default(self): resource = _make_resource() diff --git a/tests/test_data_export.py b/tests/test_data_export.py index 39a3100..863a9c7 100644 --- a/tests/test_data_export.py +++ b/tests/test_data_export.py @@ -4,7 +4,6 @@ from unittest.mock import MagicMock -import pytest from awsysco.resources.data_export import DataExportResource diff --git a/tests/test_folders.py b/tests/test_folders.py index 5c1e35f..e5bab18 100644 --- a/tests/test_folders.py +++ b/tests/test_folders.py @@ -34,10 +34,13 @@ def _make_resource(): class TestFoldersUnit: def test_update_calls_patch_endpoint(self): + # No /api/v1 alias exists for this route on the platform — only the + # unversioned path works (confirmed live against staging; see ADR-011 + # in docs/sdk-decision-log.md). resource = _make_resource() resource.update("folder1", name="Renamed") resource._http.patch.assert_called_once_with( - "/api/v1/folders/folder1", json={"name": "Renamed"} + "/api/folders/folder1", json={"name": "Renamed"} ) def test_update_sends_color(self): @@ -130,3 +133,15 @@ def test_delete_folder(self, client: Client) -> None: folder = client.folders.create(_folder_name()) assert folder.id is not None client.folders.delete(folder.id) + + def test_update_folder_live(self, client: Client) -> None: + """Regression test for ADR-011: update() must hit the unversioned route.""" + folder = client.folders.create(_folder_name()) + assert folder.id is not None + + updated = client.folders.update(folder.id, name="Renamed Live", color="#123456") + assert isinstance(updated, Folder) + assert updated.name == "Renamed Live" + assert updated.color == "#123456" + + client.folders.delete(folder.id) diff --git a/tests/test_imports.py b/tests/test_imports.py index 5e5eebb..038d67d 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -44,21 +44,23 @@ def _make_resource(): class TestImportsSync: - def test_start_posts_snake_case_body(self): + def test_start_posts_camel_case_body(self): resource = _make_resource() resource.start( provider="bitly", access_token="tok_123", target_namespace="acme", + scope_filter="tag:promo", scan_only=True, ) resource._http.post.assert_called_once_with( "/api/v1/imports", json={ "provider": "bitly", - "access_token": "tok_123", - "target_namespace": "acme", - "scan_only": True, + "accessToken": "tok_123", + "targetNamespace": "acme", + "scopeFilter": "tag:promo", + "scanOnly": True, }, ) @@ -67,7 +69,7 @@ def test_start_omits_none_optionals(self): resource.start(provider="bitly", access_token="tok_123") resource._http.post.assert_called_once_with( "/api/v1/imports", - json={"provider": "bitly", "access_token": "tok_123"}, + json={"provider": "bitly", "accessToken": "tok_123"}, ) def test_start_returns_import_job(self): @@ -130,6 +132,24 @@ def test_wait_for_completion_times_out(self): with pytest.raises(TimeoutError): resource.wait_for_completion(_JOB_ID, poll_interval=0.0, timeout=0.0) + def test_get_redirect_map_csv_calls_endpoint(self): + resource = _make_resource() + resource._http.get_text = MagicMock(return_value="old,new\nabc,xyz\n") + result = resource.get_redirect_map_csv(_JOB_ID) + resource._http.get_text.assert_called_once_with( + f"/api/v1/imports/{_JOB_ID}/redirect-map.csv" + ) + assert result == "old,new\nabc,xyz\n" + + def test_get_redirect_map_json_calls_endpoint(self): + resource = _make_resource() + resource._http.get.return_value = {"abc": "xyz"} + result = resource.get_redirect_map_json(_JOB_ID) + resource._http.get.assert_called_once_with( + f"/api/v1/imports/{_JOB_ID}/redirect-map.json" + ) + assert result == {"abc": "xyz"} + def _make_async_resource(): http = MagicMock() @@ -140,7 +160,7 @@ def _make_async_resource(): class TestImportsAsync: - def test_start_posts_snake_case_body(self): + def test_start_posts_camel_case_body(self): resource = _make_async_resource() asyncio.run( resource.start( @@ -151,8 +171,8 @@ def test_start_posts_snake_case_body(self): "/api/v1/imports", json={ "provider": "bitly", - "access_token": "tok_123", - "scan_only": True, + "accessToken": "tok_123", + "scanOnly": True, }, ) @@ -203,3 +223,21 @@ def test_wait_for_completion_times_out(self): _JOB_ID, poll_interval=0.0, timeout=0.0 ) ) + + def test_get_redirect_map_csv_calls_endpoint(self): + resource = _make_async_resource() + resource._http.get_text = AsyncMock(return_value="old,new\nabc,xyz\n") + result = asyncio.run(resource.get_redirect_map_csv(_JOB_ID)) + resource._http.get_text.assert_awaited_once_with( + f"/api/v1/imports/{_JOB_ID}/redirect-map.csv" + ) + assert result == "old,new\nabc,xyz\n" + + def test_get_redirect_map_json_calls_endpoint(self): + resource = _make_async_resource() + resource._http.get = AsyncMock(return_value={"abc": "xyz"}) + result = asyncio.run(resource.get_redirect_map_json(_JOB_ID)) + resource._http.get.assert_awaited_once_with( + f"/api/v1/imports/{_JOB_ID}/redirect-map.json" + ) + assert result == {"abc": "xyz"} diff --git a/tests/test_links.py b/tests/test_links.py index 68d6ea7..0b3be14 100644 --- a/tests/test_links.py +++ b/tests/test_links.py @@ -8,7 +8,7 @@ import pytest from awsysco import Client, AwsysNotFoundError -from awsysco.models import Link, LinkList +from awsysco.models import GeoRestriction, Link, LinkList, OgMeta, RoutingRule from awsysco.resources.links import LinksResource @@ -117,6 +117,71 @@ def test_update_omits_none_fields(self): assert "tags" not in body assert body["maxClicks"] == 50 + def test_update_encodes_namespaced_short_path(self): + resource = _make_resource() + resource.update("acme/abc", max_clicks=50) + path = resource._http.patch.call_args[0][0] + assert path == "/api/v1/links/acme%2Fabc" + + def test_create_accepts_typed_models(self): + resource = _make_resource() + resource.create( + "https://example.com", + routing_rules=[RoutingRule(country="US", redirect_url="https://us.example.com")], + og_meta=OgMeta(title="My Title"), + geo_restriction=GeoRestriction(allowed_countries=["US"]), + ) + body = resource._http.post.call_args[1]["json"] + assert body["routingRules"] == [ + {"country": "US", "redirectUrl": "https://us.example.com"} + ] + assert body["ogMeta"] == {"title": "My Title"} + assert body["geoRestriction"] == {"allowedCountries": ["US"]} + + def test_create_still_accepts_plain_dicts(self): + resource = _make_resource() + resource.create( + "https://example.com", + og_meta={"title": "Dict Title"}, + ) + body = resource._http.post.call_args[1]["json"] + assert body["ogMeta"] == {"title": "Dict Title"} + + +class TestLinksListAll: + def test_list_all_stops_on_has_more_false(self): + resource = _make_resource() + resource._http.get.side_effect = [ + {"links": [_LINK_DATA, _LINK_DATA], "hasMore": True}, + {"links": [_LINK_DATA], "hasMore": False}, + ] + results = list(resource.list_all(limit=2)) + assert len(results) == 3 + assert resource._http.get.call_count == 2 + + def test_list_all_stops_on_short_page_without_has_more(self): + resource = _make_resource() + resource._http.get.side_effect = [ + {"links": [_LINK_DATA, _LINK_DATA]}, + {"links": [_LINK_DATA]}, + ] + results = list(resource.list_all(limit=2)) + assert len(results) == 3 + assert resource._http.get.call_count == 2 + + def test_list_all_stops_on_empty_first_page(self): + resource = _make_resource() + resource._http.get.return_value = {"links": []} + results = list(resource.list_all(limit=20)) + assert results == [] + assert resource._http.get.call_count == 1 + + def test_list_all_clamps_limit_to_100(self): + resource = _make_resource() + resource._http.get.return_value = {"links": []} + list(resource.list_all(limit=500)) + assert resource._http.get.call_args[1]["params"]["limit"] == 100 + # --------------------------------------------------------------------------- # Integration tests — require AWSYS_API_KEY diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..4775cc7 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,44 @@ +"""Unit tests for cross-cutting model behavior: timestamp coercion, extra fields.""" + +from __future__ import annotations + +from awsysco.models import Folder, Link + + +class TestTimestampCoercion: + def test_iso_string_passes_through(self): + link = Link.model_validate({"id": "x", "created": "2026-01-01T00:00:00Z"}) + assert link.created == "2026-01-01T00:00:00Z" + + def test_firestore_seconds_nanos_converted_to_iso(self): + folder = Folder.model_validate( + {"id": "f1", "createdAt": {"_seconds": 1735689600, "_nanoseconds": 0}} + ) + assert folder.created_at == "2025-01-01T00:00:00Z" + + def test_firestore_alt_key_names_converted(self): + folder = Folder.model_validate( + {"id": "f1", "createdAt": {"seconds": 1735689600, "nanoseconds": 0}} + ) + assert folder.created_at == "2025-01-01T00:00:00Z" + + def test_garbage_timestamp_keeps_raw_value_no_crash(self): + folder = Folder.model_validate({"id": "f1", "createdAt": "not-a-timestamp"}) + assert folder.created_at == "not-a-timestamp" + + def test_dict_without_seconds_key_is_not_coerced(self): + # Guards against over-eager coercion of a genuinely-unrelated dict value + # (features/limits-style fields, which are typed to accept a dict). + from awsysco.models import MeResponse + + me = MeResponse.model_validate({"uid": "u1", "features": {"foo": "bar"}}) + assert me.features == {"foo": "bar"} + + +class TestUnknownFieldsPreserved: + def test_extra_fields_do_not_raise(self): + link = Link.model_validate( + {"id": "x", "shortCode": "abc", "someBrandNewField": {"nested": True}} + ) + assert link.id == "x" + assert link.model_extra["someBrandNewField"] == {"nested": True} diff --git a/tests/test_namespace.py b/tests/test_namespace.py index 6f41cc5..0975836 100644 --- a/tests/test_namespace.py +++ b/tests/test_namespace.py @@ -4,7 +4,6 @@ from unittest.mock import MagicMock -import pytest from awsysco.models import NamespaceCheckResult, NamespaceInfo from awsysco.resources.namespace import NamespaceResource diff --git a/tests/test_profile.py b/tests/test_profile.py new file mode 100644 index 0000000..e6415cb --- /dev/null +++ b/tests/test_profile.py @@ -0,0 +1,77 @@ +"""Unit tests for the Profile resource (sync + async). Fully mocked.""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +from awsysco.async_resources.profile import AsyncProfileResource +from awsysco.models import Profile +from awsysco.resources.profile import ProfileResource + +_PROFILE_DATA = { + "uid": "user_42", + "email": "dev@example.com", + "displayName": "Dev User", + "createdAt": "2026-01-01T00:00:00Z", +} + + +def _make_resource(): + http = MagicMock() + http.get.return_value = _PROFILE_DATA + http.patch.return_value = _PROFILE_DATA + return ProfileResource(http) + + +class TestProfileSync: + def test_get_calls_endpoint(self): + resource = _make_resource() + resource.get() + resource._http.get.assert_called_once_with("/api/user/profile") + + def test_get_returns_profile(self): + resource = _make_resource() + result = resource.get() + assert isinstance(result, Profile) + assert result.email == "dev@example.com" + assert result.display_name == "Dev User" + + def test_update_calls_endpoint(self): + resource = _make_resource() + resource.update(display_name="New Name") + resource._http.patch.assert_called_once_with( + "/api/user/profile", json={"displayName": "New Name"} + ) + + def test_update_returns_profile(self): + resource = _make_resource() + result = resource.update(display_name="New Name") + assert isinstance(result, Profile) + + +def _make_async_resource(): + http = MagicMock() + http.get = AsyncMock(return_value=_PROFILE_DATA) + http.patch = AsyncMock(return_value=_PROFILE_DATA) + return AsyncProfileResource(http) + + +class TestProfileAsync: + def test_get_calls_endpoint(self): + resource = _make_async_resource() + asyncio.run(resource.get()) + resource._http.get.assert_awaited_once_with("/api/user/profile") + + def test_get_returns_profile(self): + resource = _make_async_resource() + result = asyncio.run(resource.get()) + assert isinstance(result, Profile) + assert result.uid == "user_42" + + def test_update_calls_endpoint(self): + resource = _make_async_resource() + asyncio.run(resource.update(display_name="New Name")) + resource._http.patch.assert_awaited_once_with( + "/api/user/profile", json={"displayName": "New Name"} + ) diff --git a/tests/test_qr.py b/tests/test_qr.py index 9088e55..0a5d0d0 100644 --- a/tests/test_qr.py +++ b/tests/test_qr.py @@ -1,6 +1,5 @@ """Unit tests for the QR resource — no HTTP calls made.""" -import pytest from awsysco import Client diff --git a/tests/test_saved_views.py b/tests/test_saved_views.py index 9269e78..901a28f 100644 --- a/tests/test_saved_views.py +++ b/tests/test_saved_views.py @@ -4,7 +4,6 @@ from unittest.mock import MagicMock -import pytest from awsysco.models import SavedView from awsysco.resources.saved_views import SavedViewsResource diff --git a/tests/test_tags.py b/tests/test_tags.py index a6c1bdd..12d4380 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -2,9 +2,8 @@ from __future__ import annotations -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock -import pytest from awsysco.resources.tags import TagsResource @@ -18,14 +17,15 @@ def _make_resource(return_value=None): class TestTagsAdd: def test_add_calls_correct_endpoint(self): + # Platform expects {"tags": [...]}, plural array — not {"tag": "..."}. resource = _make_resource() resource.add("abc123", "promo") resource._http.post.assert_called_once_with( - "/api/link/abc123/tags", json={"tag": "promo"} + "/api/link/abc123/tags", json={"tags": ["promo"]} ) def test_add_returns_dict(self): - resource = _make_resource({"tag": "promo", "ok": True}) + resource = _make_resource({"tags": ["promo"], "ok": True}) result = resource.add("abc123", "promo") assert isinstance(result, dict) @@ -33,7 +33,7 @@ def test_add_encodes_short_path(self): resource = _make_resource() resource.add("ns/slug", "test") resource._http.post.assert_called_once_with( - "/api/link/ns%2Fslug/tags", json={"tag": "test"} + "/api/link/ns%2Fslug/tags", json={"tags": ["test"]} ) def test_add_returns_empty_dict_on_none(self): diff --git a/tests/test_transport.py b/tests/test_transport.py new file mode 100644 index 0000000..2e66622 --- /dev/null +++ b/tests/test_transport.py @@ -0,0 +1,409 @@ +"""Unit tests for the shared error-parsing and retry/backoff logic in _transport.py, +and the retry loop wiring in the sync/async HTTP clients (mocked clock, no real sleeps). +""" + +from __future__ import annotations + +import asyncio +import http.client +from unittest.mock import MagicMock + +import httpx +import pytest + +from awsysco._async_http import AsyncHttpClient +from awsysco._http import HttpClient +from awsysco._transport import ( + compute_delay, + is_idempotent, + is_quota_rate_limit, + is_retry_after_excessive, + parse_error, +) +from awsysco.exceptions import ( + AwsysAuthError, + AwsysConflictError, + AwsysForbiddenError, + AwsysNetworkError, + AwsysNotFoundError, + AwsysRateLimitError, + AwsysServerError, + AwsysTimeoutError, + AwsysValidationError, +) + + +class _FakeResponse: + def __init__(self, status_code, *, json_body=None, text_body=None, headers=None): + self.status_code = status_code + self._json_body = json_body + self._text_body = text_body if text_body is not None else "" + self.headers = headers or {} + self.is_error = status_code >= 400 + self.reason_phrase = http.client.responses.get(status_code, "") + + def json(self): + if self._json_body is None: + raise ValueError("no JSON body") + return self._json_body + + @property + def text(self): + return self._text_body + + @property + def content(self): + if self._json_body is not None: + return b"{}" # non-empty stand-in; parse_error/success paths use .json()/.text, not this + return self._text_body.encode() if self._text_body else b"" + + +# --------------------------------------------------------------------------- +# parse_error — the four documented body shapes + non-JSON + status mapping +# --------------------------------------------------------------------------- + + +class TestParseErrorBodyShapes: + def test_shape_error_true_code_message(self): + resp = _FakeResponse(401, json_body={"error": True, "code": "UNAUTHORIZED", "message": "Invalid key."}) + exc = parse_error(resp) + assert isinstance(exc, AwsysAuthError) + assert exc.message == "Invalid key." + assert exc.code == "UNAUTHORIZED" + + def test_shape_error_string_no_message(self): + resp = _FakeResponse( + 403, json_body={"error": "AgentLink analytics require Pro or higher", "code": "TIER_INSUFFICIENT"} + ) + exc = parse_error(resp) + assert isinstance(exc, AwsysForbiddenError) + assert exc.message == "AgentLink analytics require Pro or higher" + assert exc.code == "TIER_INSUFFICIENT" + + def test_shape_error_true_code_no_message_synthesizes(self): + resp = _FakeResponse(404, json_body={"error": True, "code": "IMPORT_JOB_NOT_FOUND"}) + exc = parse_error(resp) + assert isinstance(exc, AwsysNotFoundError) + assert exc.message == "Import job not found" + assert exc.code == "IMPORT_JOB_NOT_FOUND" + + def test_shape_success_false(self): + resp = _FakeResponse(400, json_body={"success": False, "message": "Bad password", "code": "BAD_PASSWORD"}) + exc = parse_error(resp) + assert isinstance(exc, AwsysValidationError) + assert exc.message == "Bad password" + + def test_non_json_body_falls_back_to_text(self): + resp = _FakeResponse(404, text_body="Cannot PATCH /api/v1/folders/x") + exc = parse_error(resp) + assert isinstance(exc, AwsysNotFoundError) + assert exc.message == "Cannot PATCH /api/v1/folders/x" + + def test_no_body_falls_back_to_status_line(self): + resp = _FakeResponse(409) + exc = parse_error(resp) + assert isinstance(exc, AwsysConflictError) + assert "409" in exc.message + + def test_5xx_maps_to_server_error(self): + resp = _FakeResponse(502, json_body={"error": True, "message": "Bad gateway"}) + exc = parse_error(resp) + assert isinstance(exc, AwsysServerError) + assert exc.status == 502 + + def test_raw_never_includes_request_headers(self): + resp = _FakeResponse(400, json_body={"error": True, "message": "x"}) + exc = parse_error(resp) + assert "Authorization" not in str(exc.raw) + + +class TestRateLimitParsing: + def test_quota_error_carries_code_and_resets_at(self): + resp = _FakeResponse( + 429, + json_body={ + "error": True, + "code": "HOURLY_LIMIT_EXCEEDED", + "message": "Hourly limit exceeded", + "resetsAt": "2026-09-06T23:00:00Z", + }, + ) + exc = parse_error(resp) + assert isinstance(exc, AwsysRateLimitError) + assert exc.code == "HOURLY_LIMIT_EXCEEDED" + assert exc.resets_at == "2026-09-06T23:00:00Z" + assert is_quota_rate_limit(exc) is True + + def test_ip_rate_limit_is_not_quota(self): + resp = _FakeResponse( + 429, + json_body={"error": True, "message": "Too many requests"}, + headers={"Retry-After": "2"}, + ) + exc = parse_error(resp) + assert is_quota_rate_limit(exc) is False + assert exc.retry_after == 2.0 + + def test_retry_after_http_date(self): + from datetime import datetime, timedelta, timezone + from email.utils import format_datetime + + future = datetime.now(timezone.utc) + timedelta(seconds=10) + resp = _FakeResponse( + 429, json_body={"error": True, "message": "x"}, headers={"Retry-After": format_datetime(future)} + ) + exc = parse_error(resp) + assert exc.retry_after is not None + assert 0 <= exc.retry_after <= 15 + + +# --------------------------------------------------------------------------- +# Retry/backoff primitives +# --------------------------------------------------------------------------- + + +class TestIsIdempotent: + @pytest.mark.parametrize("method", ["GET", "get", "PUT", "DELETE"]) + def test_idempotent_methods(self, method): + assert is_idempotent(method) is True + + @pytest.mark.parametrize("method", ["POST", "PATCH"]) + def test_non_idempotent_methods(self, method): + assert is_idempotent(method) is False + + +class TestComputeDelay: + def test_full_jitter_bounds(self): + for attempt in range(4): + delay = compute_delay(None, attempt) + assert 0 <= delay <= min(1.0 * (2**attempt), 30.0) + + def test_capped_at_30s(self): + delay = compute_delay(None, attempt=10) + assert delay <= 30.0 + + def test_uses_retry_after_header(self): + resp = _FakeResponse(429, headers={"Retry-After": "5"}) + delay = compute_delay(resp, attempt=0) + assert 0 <= delay <= 5.0 + + +# --------------------------------------------------------------------------- +# Retry-loop wiring (mocked httpx.Client/AsyncClient, mocked sleep — no real waits) +# --------------------------------------------------------------------------- + + +class TestSyncRetryLoop: + def _client_with_responses(self, responses, monkeypatch): + client = HttpClient(api_key="awsys_x", base_url="https://awsys.co", max_retries=3) + mock_request = MagicMock(side_effect=responses) + monkeypatch.setattr(client._client, "request", mock_request) + monkeypatch.setattr("awsysco._http.time.sleep", lambda _: None) + return client, mock_request + + def test_quota_429_is_never_retried(self, monkeypatch): + resp = _FakeResponse(429, json_body={"error": True, "code": "HOURLY_LIMIT_EXCEEDED", "message": "x"}) + client, mock_request = self._client_with_responses([resp], monkeypatch) + with pytest.raises(AwsysRateLimitError): + client.get("/api/v1/links") + assert mock_request.call_count == 1 + + def test_ip_429_is_retried_then_succeeds(self, monkeypatch): + rate_limited = _FakeResponse(429, json_body={"error": True, "message": "slow down"}) + ok = _FakeResponse(200, json_body={"ok": True}) + client, mock_request = self._client_with_responses([rate_limited, ok], monkeypatch) + result = client.get("/api/v1/links") + assert result == {"ok": True} + assert mock_request.call_count == 2 + + def test_502_retried_for_get(self, monkeypatch): + bad_gateway = _FakeResponse(502, json_body={"error": True, "message": "bad gateway"}) + ok = _FakeResponse(200, json_body={"ok": True}) + client, mock_request = self._client_with_responses([bad_gateway, ok], monkeypatch) + result = client.get("/api/v1/links") + assert result == {"ok": True} + assert mock_request.call_count == 2 + + def test_502_not_retried_for_post(self, monkeypatch): + bad_gateway = _FakeResponse(502, json_body={"error": True, "message": "bad gateway"}) + client, mock_request = self._client_with_responses([bad_gateway], monkeypatch) + with pytest.raises(AwsysServerError): + client.post("/api/v1/links", json={"url": "https://example.com"}) + assert mock_request.call_count == 1 + + def test_timeout_wrapped_and_retried_for_get(self, monkeypatch): + client = HttpClient(api_key="awsys_x", base_url="https://awsys.co", max_retries=1) + ok = _FakeResponse(200, json_body={"ok": True}) + mock_request = MagicMock(side_effect=[httpx.ConnectTimeout("timed out"), ok]) + monkeypatch.setattr(client._client, "request", mock_request) + monkeypatch.setattr("awsysco._http.time.sleep", lambda _: None) + result = client.get("/api/v1/links") + assert result == {"ok": True} + + def test_timeout_raises_after_retries_exhausted(self, monkeypatch): + client = HttpClient(api_key="awsys_x", base_url="https://awsys.co", max_retries=0) + mock_request = MagicMock(side_effect=httpx.ConnectTimeout("timed out")) + monkeypatch.setattr(client._client, "request", mock_request) + with pytest.raises(AwsysTimeoutError): + client.get("/api/v1/links") + + def test_transport_error_on_post_not_retried(self, monkeypatch): + client = HttpClient(api_key="awsys_x", base_url="https://awsys.co", max_retries=3) + mock_request = MagicMock(side_effect=httpx.ConnectError("refused")) + monkeypatch.setattr(client._client, "request", mock_request) + with pytest.raises(AwsysNetworkError): + client.post("/api/v1/links", json={"url": "https://example.com"}) + assert mock_request.call_count == 1 + + +class TestAsyncRetryLoop: + def test_quota_429_is_never_retried(self, monkeypatch): + async def _run(): + client = AsyncHttpClient(api_key="awsys_x", base_url="https://awsys.co", max_retries=3) + resp = _FakeResponse(429, json_body={"error": True, "code": "MONTHLY_LIMIT_EXCEEDED", "message": "x"}) + + async def fake_request(*args, **kwargs): + return resp + + monkeypatch.setattr(client._client, "request", fake_request) + monkeypatch.setattr("awsysco._async_http.asyncio.sleep", AsyncNoop()) + with pytest.raises(AwsysRateLimitError): + await client.get("/api/v1/links") + + asyncio.run(_run()) + + def test_502_retried_then_succeeds(self, monkeypatch): + async def _run(): + client = AsyncHttpClient(api_key="awsys_x", base_url="https://awsys.co", max_retries=3) + responses = iter( + [ + _FakeResponse(502, json_body={"error": True, "message": "bad gateway"}), + _FakeResponse(200, json_body={"ok": True}), + ] + ) + + async def fake_request(*args, **kwargs): + return next(responses) + + monkeypatch.setattr(client._client, "request", fake_request) + monkeypatch.setattr("awsysco._async_http.asyncio.sleep", AsyncNoop()) + result = await client.get("/api/v1/links") + assert result == {"ok": True} + + asyncio.run(_run()) + + +class AsyncNoop: + """A callable that returns a completed coroutine — stands in for asyncio.sleep.""" + + async def __call__(self, *args, **kwargs): + return None + + +# --------------------------------------------------------------------------- +# Retry-After capping — a value beyond the retry cap (or non-finite) must raise +# immediately rather than sleep. +# --------------------------------------------------------------------------- + + +class TestRetryAfterCapping: + def test_excessive_retry_after_detected(self): + assert is_retry_after_excessive(31.0) is True + assert is_retry_after_excessive(30.0) is False + assert is_retry_after_excessive(29.9) is False + assert is_retry_after_excessive(None) is False + + def test_non_finite_retry_after_detected(self): + assert is_retry_after_excessive(float("inf")) is True + assert is_retry_after_excessive(float("nan")) is True + + def test_sync_client_raises_immediately_on_excessive_retry_after(self, monkeypatch): + client = HttpClient(api_key="awsys_x", base_url="https://awsys.co", max_retries=3) + resp = _FakeResponse( + 429, json_body={"error": True, "message": "slow down"}, headers={"Retry-After": "999"} + ) + mock_request = MagicMock(return_value=resp) + monkeypatch.setattr(client._client, "request", mock_request) + slept = [] + monkeypatch.setattr("awsysco._http.time.sleep", lambda d: slept.append(d)) + with pytest.raises(AwsysRateLimitError) as exc_info: + client.get("/api/v1/links") + assert mock_request.call_count == 1 # no retry attempted + assert slept == [] # never slept + assert exc_info.value.retry_after == 999.0 # still reported to the caller + + +# --------------------------------------------------------------------------- +# 422 → AwsysValidationError; non-JSON 2xx body → typed SDK error, not a raw +# JSONDecodeError; asyncio.CancelledError passes through unmodified. +# --------------------------------------------------------------------------- + + +class TestAdditionalContractRequirements: + def test_422_maps_to_validation_error(self): + resp = _FakeResponse(422, json_body={"error": True, "code": "VALIDATION_FAILED", "message": "bad"}) + exc = parse_error(resp) + from awsysco.exceptions import AwsysValidationError + + assert isinstance(exc, AwsysValidationError) + assert exc.status == 422 + + def test_non_json_2xx_body_raises_typed_error_not_raw_decode_error(self, monkeypatch): + client = HttpClient(api_key="awsys_x", base_url="https://awsys.co") + resp = _FakeResponse(200, text_body="interstitial") + monkeypatch.setattr(client._client, "request", MagicMock(return_value=resp)) + with pytest.raises(AwsysServerError): + client.get("/api/v1/links") + + def test_non_json_2xx_body_raises_typed_error_async(self, monkeypatch): + async def _run(): + client = AsyncHttpClient(api_key="awsys_x", base_url="https://awsys.co") + resp = _FakeResponse(200, text_body="interstitial") + + async def fake_request(*args, **kwargs): + return resp + + monkeypatch.setattr(client._client, "request", fake_request) + with pytest.raises(AwsysServerError): + await client.get("/api/v1/links") + + asyncio.run(_run()) + + def test_cancelled_error_passes_through_unmodified(self, monkeypatch): + async def _run(): + client = AsyncHttpClient(api_key="awsys_x", base_url="https://awsys.co") + + async def fake_request(*args, **kwargs): + raise asyncio.CancelledError() + + monkeypatch.setattr(client._client, "request", fake_request) + with pytest.raises(asyncio.CancelledError): + await client.get("/api/v1/links") + + asyncio.run(_run()) + + +# --------------------------------------------------------------------------- +# Timeout covers header AND body read — a single float timeout applies to all +# of httpx's connect/read/write/pool phases. +# --------------------------------------------------------------------------- + + +class TestTimeoutCoversBodyRead: + def test_sync_client_timeout_covers_read_phase(self): + client = HttpClient(api_key="awsys_x", base_url="https://awsys.co", timeout=12.5) + assert client._client.timeout == httpx.Timeout(12.5) + assert client._client.timeout.read == 12.5 + + def test_async_client_timeout_covers_read_phase(self): + client = AsyncHttpClient(api_key="awsys_x", base_url="https://awsys.co", timeout=12.5) + assert client._client.timeout == httpx.Timeout(12.5) + assert client._client.timeout.read == 12.5 + + def test_a_stalled_body_read_raises_awsys_timeout_error(self, monkeypatch): + client = HttpClient(api_key="awsys_x", base_url="https://awsys.co", max_retries=0) + monkeypatch.setattr( + client._client, "request", MagicMock(side_effect=httpx.ReadTimeout("body stalled")) + ) + with pytest.raises(AwsysTimeoutError): + client.get("/api/v1/links") diff --git a/tests/test_trust_score.py b/tests/test_trust_score.py index 554fa02..57deb79 100644 --- a/tests/test_trust_score.py +++ b/tests/test_trust_score.py @@ -4,7 +4,6 @@ from unittest.mock import MagicMock -import pytest from awsysco.models import TrustScoreResult from awsysco.resources.trust_score import TrustScoreResource diff --git a/tests/test_utm_templates.py b/tests/test_utm_templates.py index 820bedb..1bad103 100644 --- a/tests/test_utm_templates.py +++ b/tests/test_utm_templates.py @@ -4,7 +4,6 @@ from unittest.mock import MagicMock -import pytest from awsysco.models import UtmTemplate from awsysco.resources.utm_templates import UtmTemplatesResource diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 5539061..47e7e83 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -4,7 +4,6 @@ from unittest.mock import MagicMock -import pytest from awsysco.models import Webhook from awsysco.resources.webhooks import WebhooksResource @@ -19,6 +18,12 @@ "createdAt": "2026-01-01T00:00:00Z", } +_LEGACY_WEBHOOK_DATA = { + "id": "wh0", + "url": "https://legacy.example/hook", + "events": ["link.click"], +} + def _make_resource(): http = MagicMock() @@ -39,7 +44,22 @@ def test_list_event_types_calls_endpoint(self): def test_list_calls_endpoint(self): resource = _make_resource() resource.list() - resource._http.get.assert_called_once_with("/api/webhooks") + resource._http.get.assert_called_once_with("/api/v1/webhooks") + + def test_webhook_enabled_field(self): + resource = _make_resource() + result = resource.create("https://example.com/hook", ["link.created"]) + assert result.enabled is True + + def test_legacy_webhook_missing_fields_default_to_none(self): + """Legacy webhook docs on the platform lack enabled/secret/etc. entirely.""" + http = MagicMock() + http.patch.return_value = _LEGACY_WEBHOOK_DATA + resource = WebhooksResource(http) + result = resource.update("wh0", name="Renamed") + assert result.enabled is None + assert result.secret is None + assert result.failure_count is None def test_create_returns_webhook(self): resource = _make_resource() @@ -69,6 +89,7 @@ def test_create_without_optional_fields(self): assert "secret" not in body def test_update_calls_patch(self): + # No /api/v1 alias exists for update on the platform; wire key is "enabled". resource = _make_resource() resource.update("wh1", enabled=False) resource._http.patch.assert_called_once_with( @@ -83,12 +104,17 @@ def test_update_returns_webhook(self): def test_delete_calls_endpoint(self): resource = _make_resource() resource.delete("wh1") - resource._http.delete.assert_called_once_with("/api/webhooks/wh1") + resource._http.delete.assert_called_once_with("/api/v1/webhooks/wh1") def test_test_calls_correct_endpoint(self): resource = _make_resource() resource._http.post.return_value = {"sent": True} resource.test("wh1", "link.created") resource._http.post.assert_called_once_with( - "/api/webhooks/wh1/test", json={"eventType": "link.created"} + "/api/v1/webhooks/wh1/test", json={"eventType": "link.created"} ) + + def test_repr_never_contains_raw_secret(self): + webhook = Webhook.model_validate({"id": "wh1", "secret": "whsec_supersecret"}) + assert "whsec_supersecret" not in repr(webhook) + assert "" in repr(webhook) From 2f9e7d311f0d6d6aa0db8df2cd78263b819f14b7 Mon Sep 17 00:00:00 2001 From: pbertsch Date: Mon, 7 Sep 2026 07:51:42 -0600 Subject: [PATCH 2/4] fix: pin ruff rule selection to avoid version-drift CI failures 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 Claude-Session: https://claude.ai/code/session_014BLo12Xi5cjtEA5HiNb3y8 --- pyproject.toml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1834695..88abbe0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,8 +37,8 @@ dev = [ "pytest-asyncio", "python-dotenv", "pytest-cov", - "ruff", - "mypy", + "ruff>=0.15,<1", + "mypy>=1.10,<2", ] [project.urls] @@ -60,6 +60,12 @@ source = ["awsysco"] target-version = "py39" line-length = 100 +[tool.ruff.lint] +# Pinned explicitly (rather than relying on ruff's "default" selection) so a ruff +# version upgrade can't silently change what's enforced — e.g. 0.16 started +# flagging import-sort (I001) issues under the bare default that 0.15 didn't. +select = ["E4", "E7", "E9", "F"] + [tool.mypy] python_version = "3.10" check_untyped_defs = true From 559410e5b1920aee9f4c5a1a775295a6b11f01c1 Mon Sep 17 00:00:00 2001 From: pbertsch Date: Mon, 7 Sep 2026 12:30:34 -0600 Subject: [PATCH 3/4] fix: address independent-review findings (retry-after cap, secret leak, pagination) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014BLo12Xi5cjtEA5HiNb3y8 --- .github/workflows/publish.yml | 2 +- CHANGELOG.md | 49 +++++++++++++++++++++++ SECURITY-REVIEW.md | 41 +++++++++++++++---- awsysco/_async_http.py | 8 +++- awsysco/_http.py | 8 +++- awsysco/_transport.py | 32 +++++++++++---- awsysco/async_resources/links.py | 4 +- awsysco/async_resources/qr.py | 2 +- awsysco/models.py | 62 ++++++++++++++++++++++------- awsysco/resources/links.py | 4 +- awsysco/resources/qr.py | 2 +- pyproject.toml | 14 ++++++- tests/contracts/sdk-contract.json | 35 ++++++++++++++++- tests/test_config.py | 44 +++++++++++++++++++-- tests/test_contract.py | 12 ++++++ tests/test_links.py | 17 ++++++++ tests/test_models.py | 65 ++++++++++++++++++++++++++++++- tests/test_qr.py | 2 +- tests/test_transport.py | 38 +++++++++++++++++- tests/test_webhooks.py | 9 ++++- 20 files changed, 402 insertions(+), 48 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 529052f..1e338b2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -38,7 +38,7 @@ jobs: env: TAG_NAME: ${{ github.ref_name }} run: | - PKG_VERSION=$(python3 -c "import re; print(re.search(r'(?m)^version\s*=\s*\"([^\"]+)\"', open('pyproject.toml').read()).group(1))") + PKG_VERSION=$(python3 -c "import re; print(re.search(r'(?m)^__version__\s*=\s*\"([^\"]+)\"', open('awsysco/_version.py').read()).group(1))") if [ "$TAG_NAME" != "v$PKG_VERSION" ]; then echo "Tag '$TAG_NAME' does not match package version 'v$PKG_VERSION' — refusing to publish." exit 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 179a948..fef76d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,55 @@ changes — see below. Firebase-auth-only and always 401s for an API key. Now raises `AwsysForbiddenError` immediately (with a `DeprecationWarning`) without a network round-trip. +- `tests/conftest.py`'s `pytest_runtest_call` hook was a plain function, not a + pytest hookwrapper — since it called `item.runtest()` itself, pytest's own + internal call to the same hookspec ran again alongside it, so **every test in + the suite silently executed twice per run**, including live calls against + staging. Fixed by converting it to a proper `@pytest.hookimpl(wrapper=True)`. +- **`LinkList.has_more`** was always `None` — the platform nests pagination + under `pagination: {limit, offset, hasMore}`, not at the top level, so the + field's auto-generated alias never matched anything real. This silently broke + `links.list_all()`'s primary stop condition; the iterator only ever worked by + accident, via its secondary "short page" length check. A before-validator now + hoists `pagination.hasMore`/`.limit`/`.offset` up before field validation. +- **`links.list_all(limit=0)`** (or a negative limit) could loop forever — the + limit was clamped with `min(limit, 100)` but no lower bound, so a `0`/negative + limit reached the platform as-is and the "page shorter than limit" stop + condition (`len(page.links) < limit`) could never fire. Now clamped to `>=1`. +- **`Webhook.secret` leaked via `str()`/f-strings.** An earlier fix in this same + PR added a custom `__repr__` that masked it, but pydantic generates `__str__` + independently of a subclass's `__repr__` — so `str(webhook)`/`f"{webhook}"`/ + `logging.info("%s", webhook)` still leaked the raw secret. An independent + review caught it before merge. Fixed properly via `Field(repr=False)` (which + backs both representations) plus `__str__ = __repr__`. +- The Firestore-timestamp coercion validator could itself raise: a non-numeric + `nanoseconds` value (e.g. `{"seconds": 1, "nanoseconds": "q"}`) hit an + uncaught `TypeError`, and an out-of-range `seconds` value (huge, deeply + negative, or non-numeric) that failed conversion left the *raw dict* in place, + which then failed downstream field validation (a dict into a `str` field) — + the "never raise" guarantee didn't actually hold for either case. Both are now + caught and the field is stringified on any conversion failure. +- Retry-After capping (raise immediately rather than sleep, for a value beyond + the 30s cap or non-finite) previously only applied to 429s — a retryable 5xx + with an oversized `Retry-After` (e.g. `503` + `Retry-After: 3600`) slept for + the full, uncapped duration instead. Also fixed: a `Retry-After: nan` was + silently clamped to `0.0` by an unconditional `max(0.0, ...)`, which caused it + to sleep almost instantly and retry rather than being recognized as + non-finite and raising immediately. +- `qr.get_url()`'s default `bg_color` was `"FFFFFF"` (uppercase); the platform's + own convention (and the contract fixture) uses lowercase — aligned to + `"ffffff"` for consistency (purely cosmetic; hex color parsing is + case-insensitive either way). +- `pyproject.toml` and `awsysco/_version.py` each held their own copy of the + version string, requiring a manual sync on every release. `pyproject.toml` + now declares `dynamic = ["version"]` and reads it from `_version.py` via + hatchling's `[tool.hatch.version]`, so there's a single source of truth. +- `[tool.ruff.lint]` had no explicit `select`, so `ruff check .` picked up + whatever ruff's bare default was for whatever version got installed — ruff + 0.16 started flagging import-sort (I001) issues that 0.15 (already installed + locally) didn't, breaking the PR's first CI run. Pinned explicitly to the + classic default (`E4`, `E7`, `E9`, `F`) so a future ruff upgrade can't change + what's enforced out from under CI. ### Deprecated - `custom_domains.activate()` — Firebase-only route, unreachable with an API key. diff --git a/SECURITY-REVIEW.md b/SECURITY-REVIEW.md index ed7a1b6..9df7aba 100644 --- a/SECURITY-REVIEW.md +++ b/SECURITY-REVIEW.md @@ -36,19 +36,46 @@ with no validation. **Fixed**: `resolve_base_url()` now rejects anything without an `http://`/`https://` scheme (raises `AwsysConfigurationError`) and warns on plain `http://`. Verified by `tests/test_config.py::TestBaseUrlResolution`. -### Low — Webhook signing secret was not redacted from model reprs (fixed) -`Webhook.secret` (the webhook's HMAC signing secret) was a plain model field with -no redaction — `print(webhook)`/`repr(webhook)`/an uncaught exception embedding the -model would leak it into logs. **Fixed**: `Webhook.__repr__` masks `secret` as -``. Verified by `tests/test_webhooks.py::test_repr_never_contains_raw_secret`. +### Medium — Webhook signing secret leaked via `str()`/f-strings (fixed) +`Webhook.secret` (the webhook's HMAC signing secret) was a plain model field. +An earlier pass in this same review cycle added a custom `__repr__` that masked +it — but pydantic's default `__str__` is generated independently of a subclass's +`__repr__` override, so `str(webhook)`/`f"{webhook}"`/`"%s" % webhook`/logging +calls that implicitly stringify (not `repr()`) still leaked the raw secret. An +independent review of this PR caught it before merge. **Fixed properly**: +`secret` is now declared `Field(repr=False)` (excludes it from pydantic's own +`__repr_args__`, which backs both representations), and `__str__` is explicitly +aliased to the same `__repr__` implementation so there's no second code path to +drift. Verified by `tests/test_webhooks.py::test_repr_never_contains_raw_secret` +and `test_str_never_contains_raw_secret`. ### Low — stale, incorrect User-Agent version string (fixed) Both transports hardcoded `User-Agent: awsysco-python-sdk/1.0.0` regardless of the actual installed version (1.3.0 at the time, now 1.4.0) — not a vulnerability, but a support/telemetry accuracy issue (the platform can't reliably tell which SDK version is making a request from its own logs). **Fixed**: derived from -`awsysco.__version__`; a test (`test_pyproject_version_matches_dunder_version`) -prevents the two from drifting again. +`awsysco.__version__`. The version itself now has a single source of truth +(`awsysco/_version.py`, read by hatchling via `[tool.hatch.version]`) rather than +a second, manually-synced copy in `pyproject.toml` — an independent review of +this PR caught that the original two-copy design could drift; a test +(`test_pyproject_declares_dynamic_version_from_version_py`) guards the config. + +### Informational — `vars()`/`__dict__` introspection still shows the raw key +`repr()`/`str()`/f-string formatting of the client, transport, and every resource +object are covered (verified for `client.links`, `.folders`, `.webhooks`, +`.profile`, `.affiliate`, and both transports). Deliberately calling +`vars(client._http)` or `client._http.__dict__`, however, still returns the raw +`_api_key` — as does reading `client._http._client.headers["Authorization"]` +directly. This is accepted as an inherent limitation rather than a gap to close: +the key must exist unredacted somewhere in the live object graph to actually be +usable for requests (it's literally sitting in the underlying `httpx.Client`'s +real request headers), so no amount of `__repr__`/`__str__` polish changes what's +reachable by someone willing to inspect object internals directly — and anyone +with that level of access to a live client object already has arbitrary code +execution in the process, at which point the credential is exposed regardless. +The redaction guarantee here is specifically about the common *accidental* leak +paths (printing/logging a client or resource object, an exception message, +str-formatting), not about withstanding deliberate introspection. ### Informational — exception `.raw` retains the full response body `AwsysError.raw` intentionally stores the parsed (or raw-text) response body for diff --git a/awsysco/_async_http.py b/awsysco/_async_http.py index 716cee4..b4cc642 100644 --- a/awsysco/_async_http.py +++ b/awsysco/_async_http.py @@ -12,6 +12,7 @@ DEFAULT_MAX_RETRIES, RETRYABLE_SERVER_STATUSES, compute_delay, + get_retry_after, is_idempotent, is_quota_rate_limit, is_retry_after_excessive, @@ -103,6 +104,7 @@ async def _request( response.status_code in RETRYABLE_SERVER_STATUSES and is_idempotent(method) and attempt < self._max_retries + and not is_retry_after_excessive(get_retry_after(response)) ): await asyncio.sleep(compute_delay(response, attempt)) attempt += 1 @@ -173,7 +175,11 @@ async def get_text( attempt += 1 continue - if response.status_code in RETRYABLE_SERVER_STATUSES and attempt < self._max_retries: + if ( + response.status_code in RETRYABLE_SERVER_STATUSES + and attempt < self._max_retries + and not is_retry_after_excessive(get_retry_after(response)) + ): await asyncio.sleep(compute_delay(response, attempt)) attempt += 1 continue diff --git a/awsysco/_http.py b/awsysco/_http.py index d575b60..9890d9d 100644 --- a/awsysco/_http.py +++ b/awsysco/_http.py @@ -13,6 +13,7 @@ DEFAULT_MAX_RETRIES, RETRYABLE_SERVER_STATUSES, compute_delay, + get_retry_after, is_idempotent, is_quota_rate_limit, is_retry_after_excessive, @@ -148,6 +149,7 @@ def _request( response.status_code in RETRYABLE_SERVER_STATUSES and is_idempotent(method) and attempt < self._max_retries + and not is_retry_after_excessive(get_retry_after(response)) ): time.sleep(compute_delay(response, attempt)) attempt += 1 @@ -219,7 +221,11 @@ def get_text( attempt += 1 continue - if response.status_code in RETRYABLE_SERVER_STATUSES and attempt < self._max_retries: + if ( + response.status_code in RETRYABLE_SERVER_STATUSES + and attempt < self._max_retries + and not is_retry_after_excessive(get_retry_after(response)) + ): time.sleep(compute_delay(response, attempt)) attempt += 1 continue diff --git a/awsysco/_transport.py b/awsysco/_transport.py index 7ebb3eb..ff51fe3 100644 --- a/awsysco/_transport.py +++ b/awsysco/_transport.py @@ -42,13 +42,21 @@ def is_idempotent(method: str) -> bool: def _parse_retry_after(value: Optional[str]) -> Optional[float]: - """Parse a ``Retry-After`` header value — either delta-seconds or an HTTP-date.""" + """Parse a ``Retry-After`` header value — either delta-seconds or an HTTP-date. + + A non-finite delta (``nan``/``inf``) is returned as-is, NOT clamped to ``0.0`` — + clamping it would silently turn "don't know how long to wait" into "wait + (almost) no time and retry", when the caller should instead treat it as + excessive (see :func:`is_retry_after_excessive`) and raise immediately. + """ if not value: return None try: - return max(0.0, float(value)) + parsed = float(value) except ValueError: - pass + parsed = None + if parsed is not None: + return parsed if not math.isfinite(parsed) else max(0.0, parsed) try: dt = parsedate_to_datetime(value) return max(0.0, dt.timestamp() - _now()) @@ -56,6 +64,11 @@ def _parse_retry_after(value: Optional[str]) -> Optional[float]: return None +def get_retry_after(response: "httpx.Response") -> Optional[float]: + """Parse the ``Retry-After`` header off a response, if present.""" + return _parse_retry_after(response.headers.get("Retry-After")) + + def parse_error(response: "httpx.Response") -> AwsysError: """Parse an HTTP error response into the matching :class:`AwsysError` subclass. @@ -140,12 +153,15 @@ def is_retry_after_excessive(retry_after: Optional[float]) -> bool: def compute_delay(response: Optional["httpx.Response"], attempt: int) -> float: """Backoff delay for retry ``attempt`` (0-indexed), with full jitter. - Uses the ``Retry-After`` header when present, otherwise ``1s * 2^attempt`` capped at - 30s. Full jitter: the actual sleep is a random value in ``[0, computed_delay]``. + Uses the ``Retry-After`` header when present (capped at 30s — callers are + expected to have already checked :func:`is_retry_after_excessive` and raised + instead of calling this at all when it's excessive; the cap here is a second, + defensive layer), otherwise ``1s * 2^attempt`` capped at 30s. Full jitter: the + actual sleep is a random value in ``[0, computed_delay]``. """ - retry_after = _parse_retry_after(response.headers.get("Retry-After")) if response is not None else None - if retry_after is not None: - base = retry_after + retry_after = get_retry_after(response) if response is not None else None + if retry_after is not None and math.isfinite(retry_after): + base = min(retry_after, _RETRY_MAX_DELAY) else: base = min(_RETRY_BASE_DELAY * (2**attempt), _RETRY_MAX_DELAY) return random.uniform(0, base) diff --git a/awsysco/async_resources/links.py b/awsysco/async_resources/links.py index f42b88e..cc49975 100644 --- a/awsysco/async_resources/links.py +++ b/awsysco/async_resources/links.py @@ -62,7 +62,7 @@ async def create( return Link.model_validate(data) async def list(self, *, limit: int = 20, offset: int = 0) -> LinkList: - limit = min(limit, _MAX_PAGE_SIZE) + limit = max(1, min(limit, _MAX_PAGE_SIZE)) data = await self._http.get("/api/v1/links", params={"limit": limit, "offset": offset}) return LinkList.model_validate(data) @@ -72,7 +72,7 @@ async def list_all(self, *, limit: int = 100) -> AsyncIterator[Link]: Stops when the platform reports ``has_more=False``, or a page comes back shorter than ``limit`` (including empty). """ - limit = min(limit, _MAX_PAGE_SIZE) + limit = max(1, min(limit, _MAX_PAGE_SIZE)) offset = 0 while True: page = await self.list(limit=limit, offset=offset) diff --git a/awsysco/async_resources/qr.py b/awsysco/async_resources/qr.py index 7426fb2..e005ffe 100644 --- a/awsysco/async_resources/qr.py +++ b/awsysco/async_resources/qr.py @@ -14,7 +14,7 @@ def __init__(self, http: AsyncHttpClient) -> None: self._http = http self._base_url = http.base_url - def get_url(self, short_code: str, *, size: int = 300, color: str = "000000", bg_color: str = "FFFFFF") -> str: + def get_url(self, short_code: str, *, size: int = 300, color: str = "000000", bg_color: str = "ffffff") -> str: params = urlencode({"size": size, "color": color, "bgColor": bg_color}) return f"{self._base_url}/api/qr/{short_code}?{params}" diff --git a/awsysco/models.py b/awsysco/models.py index 4c38e8a..1c283ff 100644 --- a/awsysco/models.py +++ b/awsysco/models.py @@ -25,15 +25,29 @@ def _coerce_firestore_timestamps(data: Any) -> Any: for key, value in data.items(): if not isinstance(value, dict): continue + # Only treat this as an *attempted* timestamp if a seconds-like key is + # actually present — a dict with neither key is presumed unrelated (e.g. + # a genuinely dict-typed field like MeResponse.features) and left alone. + if "_seconds" not in value and "seconds" not in value: + continue seconds = value.get("_seconds", value.get("seconds")) nanos = value.get("_nanoseconds", value.get("nanoseconds", 0)) - if not isinstance(seconds, (int, float)): - continue try: - dt = datetime.fromtimestamp(seconds + (nanos or 0) / 1e9, tz=timezone.utc) + if not isinstance(seconds, (int, float)) or isinstance(seconds, bool): + raise TypeError(f"non-numeric seconds: {seconds!r}") + if not isinstance(nanos, (int, float)) or isinstance(nanos, bool): + nanos = 0 + dt = datetime.fromtimestamp(seconds + nanos / 1e9, tz=timezone.utc) result[key] = dt.isoformat().replace("+00:00", "Z") - except (OverflowError, OSError, ValueError): - pass # leave the raw value in place — never crash on a bad timestamp + except (OverflowError, OSError, ValueError, TypeError): + # A shape that declared itself a Firestore timestamp (has a seconds + # key) but doesn't actually convert (huge/negative/non-numeric + # seconds, non-numeric nanoseconds, etc.) must still never crash + # model validation — but leaving the raw dict in place would just + # move the crash downstream into field validation (a dict into an + # Optional[str] field). Stringify it instead so the field always + # gets a string. + result[key] = str(value) return result __all__ = [ @@ -113,11 +127,33 @@ class Link(_CamelModel): class LinkList(_CamelModel): - """Paginated list of links.""" + """Paginated list of links. + + The platform nests pagination info under a ``pagination`` object + (``{links: [...], pagination: {limit, offset, hasMore}}``), not at the top + level — the before-validator below hoists those fields up so ``has_more`` + (and ``limit``/``offset``) actually populate instead of always being ``None``. + """ links: List[Link] = Field(default_factory=list) total: Optional[int] = None has_more: Optional[bool] = None + limit: Optional[int] = None + offset: Optional[int] = None + + @model_validator(mode="before") + @classmethod + def _hoist_pagination(cls, data: Any) -> Any: + if isinstance(data, dict) and isinstance(data.get("pagination"), dict): + # pagination.* is the only source of truth once present — it must + # win over any stray top-level key of the same name, not just fill + # one in if absent. + pagination = data["pagination"] + data = dict(data) + data["hasMore"] = pagination.get("hasMore") + data["limit"] = pagination.get("limit") + data["offset"] = pagination.get("offset") + return data # --------------------------------------------------------------------------- @@ -329,7 +365,10 @@ class Webhook(_CamelModel): url: Optional[str] = None events: List[str] = Field(default_factory=list) name: Optional[str] = None - secret: Optional[str] = None + # `repr=False` excludes this from BOTH __repr__ and __str__ — pydantic's default + # __str__ is backed by the same __repr_args__ machinery as __repr__, so this is + # enough to keep the secret out of str(webhook)/f"{webhook}"/print(webhook) too. + secret: Optional[str] = Field(default=None, repr=False) enabled: Optional[bool] = None created_at: Optional[str] = None updated_at: Optional[str] = None @@ -338,12 +377,9 @@ class Webhook(_CamelModel): success_count: Optional[int] = None def __repr__(self) -> str: - # `secret` is a webhook signing secret — never include it in reprs/logs. - data = self.model_dump(by_alias=False) - if data.get("secret") is not None: - data["secret"] = "" - fields = ", ".join(f"{k}={v!r}" for k, v in data.items()) - return f"{self.__class__.__name__}({fields})" + return f"{self.__class__.__name__}({self.__repr_str__(', ')})" # type: ignore[misc] + + __str__ = __repr__ # --------------------------------------------------------------------------- diff --git a/awsysco/resources/links.py b/awsysco/resources/links.py index 7a68579..517ed52 100644 --- a/awsysco/resources/links.py +++ b/awsysco/resources/links.py @@ -98,7 +98,7 @@ def list(self, *, limit: int = 20, offset: int = 0) -> LinkList: Returns: A LinkList containing links and pagination info. """ - limit = min(limit, _MAX_PAGE_SIZE) + limit = max(1, min(limit, _MAX_PAGE_SIZE)) data = self._http.get("/api/v1/links", params={"limit": limit, "offset": offset}) return LinkList.model_validate(data) @@ -115,7 +115,7 @@ def list_all(self, *, limit: int = 100) -> Iterator[Link]: Yields: Each Link across every page. """ - limit = min(limit, _MAX_PAGE_SIZE) + limit = max(1, min(limit, _MAX_PAGE_SIZE)) offset = 0 while True: page = self.list(limit=limit, offset=offset) diff --git a/awsysco/resources/qr.py b/awsysco/resources/qr.py index e4be5c9..a8ec689 100644 --- a/awsysco/resources/qr.py +++ b/awsysco/resources/qr.py @@ -22,7 +22,7 @@ def get_url( *, size: int = 300, color: str = "000000", - bg_color: str = "FFFFFF", + bg_color: str = "ffffff", ) -> str: """Build the QR code image URL for a short code. diff --git a/pyproject.toml b/pyproject.toml index 88abbe0..2f2b148 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "awsysco" -version = "1.4.0" +dynamic = ["version"] description = "Official Python SDK for the AWSYS.CO URL Shortener API" readme = "README.md" requires-python = ">=3.9" @@ -56,6 +56,12 @@ markers = [ [tool.coverage.run] source = ["awsysco"] +[tool.hatch.version] +# Single source of truth for the version: awsysco/_version.py. hatchling reads +# the `__version__ = "..."` assignment from this file at build time, so there's +# no second, manually-synced copy in this file to drift out of step. +path = "awsysco/_version.py" + [tool.ruff] target-version = "py39" line-length = 100 @@ -67,6 +73,12 @@ line-length = 100 select = ["E4", "E7", "E9", "F"] [tool.mypy] +# Ideally this would match requires-python (3.9). It's pinned to 3.10 instead +# because checking as 3.9 makes mypy follow into a transitive dependency's own +# source (anyio) and fail on a match-statement there (3.10+ syntax) — a false +# positive against our own code, not a real compatibility issue with 3.9 (the +# CI matrix actually runs pytest against a real 3.9 interpreter, which is the +# check that matters for runtime compatibility). python_version = "3.10" check_untyped_defs = true warn_redundant_casts = true diff --git a/tests/contracts/sdk-contract.json b/tests/contracts/sdk-contract.json index 0b9e2c5..25fa765 100644 --- a/tests/contracts/sdk-contract.json +++ b/tests/contracts/sdk-contract.json @@ -1,6 +1,6 @@ { "$schema": "awsys-sdk-contract/1", - "version": "1.0.6", + "version": "1.0.8", "platformBaseline": "2026-09", "baseUrl": "https://awsys.co", "auth": { @@ -2051,6 +2051,21 @@ "body": null, "expect_error": "CancelledError", "note": "caller-initiated cancellation (signal/ctx) must not be reported as TimeoutError; idiomatic: TS distinct error or rethrown AbortError, Python asyncio.CancelledError passthrough, Go ctx.Err()" + }, + { + "id": "err_503_retry_after_oversized", + "status": 503, + "body": { + "error": true, + "message": "unavailable" + }, + "headers": { + "Retry-After": "3600" + }, + "method": "GET", + "expect_error": "ServerError", + "retry": false, + "note": "Retry-After above the 30 s cap on a retryable 5xx \u2192 raise immediately, never sleep" } ], "behaviors": [ @@ -2080,7 +2095,7 @@ }, { "id": "timestamp_variants", - "assert": "ISO string and {_seconds,_nanoseconds} both parse; garbage keeps raw string" + "assert": "ISO string and {_seconds,_nanoseconds} both parse; garbage keeps raw string; normalization is applied automatically to timestamp fields on real responses, not only via an exported helper" }, { "id": "iterator_links", @@ -2097,6 +2112,22 @@ { "id": "release_tag_matches_version", "assert": "publish/release workflow fails if the git tag does not equal v" + }, + { + "id": "iterator_links_limit_zero", + "assert": "list_all/listAll/Iter with limit 0 or negative clamps to \u22651 and terminates" + }, + { + "id": "redaction_str", + "assert": "str()/print/f-string/%s formatting (not only repr/inspect) of client, config, errors AND models carrying secrets (Webhook.secret) never contain the API key or secret; every object reachable from the client (resources, transport/http client) is covered \u2014 TS `private` is erased at runtime and Go %+v follows pointers" + }, + { + "id": "timestamp_never_raises", + "assert": "timestamp normalizer never raises for {seconds:1,nanoseconds:'q'}, {_seconds:1e300}, {seconds:-1e14}, {seconds:[1]} \u2014 field keeps a string form of the raw value" + }, + { + "id": "links_list_has_more_from_pagination", + "assert": "list() exposes hasMore read from pagination.hasMore (True/False), not from a top-level key" } ] } \ No newline at end of file diff --git a/tests/test_config.py b/tests/test_config.py index fcd872e..0785cc7 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -97,6 +97,30 @@ def test_http_client_repr_never_contains_full_key(self): client = Client(api_key="awsys_supersecretvalue") assert "supersecretvalue" not in repr(client._http) + def test_http_client_str_never_contains_full_key(self): + """str() falls back to __repr__ for a plain (non-pydantic) class only if + __str__ isn't independently defined — confirm that's actually true here, + not assumed.""" + client = Client(api_key="awsys_supersecretvalue") + assert "supersecretvalue" not in str(client._http) + assert "supersecretvalue" not in f"{client._http}" + + def test_resource_object_repr_never_contains_full_key(self): + """Every resource attached to the client (client.links, client.folders, …) + must not leak the key either — even though none of them override __repr__, + confirm the default object repr (class + id only) stays that way rather + than assuming it can never regress (e.g. from a future __repr__ that + dumps __dict__).""" + client = Client(api_key="awsys_supersecretvalue") + for name in ("links", "folders", "webhooks", "profile", "affiliate"): + resource = getattr(client, name) + assert "supersecretvalue" not in repr(resource) + assert "supersecretvalue" not in str(resource) + + def test_async_http_client_str_never_contains_full_key(self): + client = AsyncClient(api_key="awsys_supersecretvalue") + assert "supersecretvalue" not in str(client._http) + class TestUserAgent: def test_user_agent_matches_contract_pattern(self): @@ -109,14 +133,26 @@ def test_user_agent_version_matches_package_version(self): ua = client._http._client.headers["User-Agent"] assert awsysco.__version__ in ua - def test_pyproject_version_matches_dunder_version(self): + def test_pyproject_declares_dynamic_version_from_version_py(self): + """The version has a single source of truth (awsysco/_version.py, read by + hatchling via [tool.hatch.version].path) — pyproject.toml must declare + `dynamic = ["version"]` and must NOT also hardcode a static `version =` + under [project], or the two could drift out of sync again.""" import pathlib pyproject = pathlib.Path(__file__).resolve().parents[1] / "pyproject.toml" text = pyproject.read_text() - match = re.search(r'(?m)^version\s*=\s*"([^"]+)"', text) - assert match is not None, "could not find version in pyproject.toml" - assert match.group(1) == awsysco.__version__ + assert re.search(r'(?m)^dynamic\s*=\s*\[.*"version".*\]', text), ( + "pyproject.toml must declare dynamic = [\"version\"]" + ) + project_section = text.split("[project]", 1)[1].split("\n[", 1)[0] + assert not re.search(r'(?m)^version\s*=', project_section), ( + "pyproject.toml must not also hardcode a static version under [project] " + "— that would reintroduce the dual-source-of-truth drift" + ) + # The actual build→wheel-version resolution is verified manually (and via + # the release checklist) with `python -m build` — not re-run here as a + # subprocess on every unit-test invocation, to keep this suite fast. class TestPerCallTimeout: diff --git a/tests/test_contract.py b/tests/test_contract.py index b8e8e6b..1e981c5 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -760,6 +760,7 @@ def test_capability_scenario(entry): "err_429_exhausted", "err_503_get_retried", "err_503_post_not_retried", + "err_503_retry_after_oversized", } # expect_error is not an AwsysError subclass name for these — each is a distinct, # already-covered-elsewhere case handled specially rather than through parse_error. @@ -852,6 +853,17 @@ def test_error_scenario(entry): "/ TestBaseUrlResolution::test_warns_on_plain_http(_only_once...)" ), "release_tag_matches_version": ".github/workflows/publish.yml (Verify tag matches package version step)", + "iterator_links_limit_zero": "tests/test_links.py::TestLinksListAll::test_list_all_clamps_zero_and_negative_limit", + "redaction_str": ( + "tests/test_config.py::TestRedaction (str/f-string cases) / " + "tests/test_webhooks.py::test_str_never_contains_raw_secret" + ), + "timestamp_never_raises": ( + "tests/test_models.py::TestTimestampCoercion (test_non_numeric_nanoseconds_never_raises / " + "test_huge_seconds_never_raises / test_very_negative_seconds_never_raises / " + "test_list_valued_seconds_never_raises)" + ), + "links_list_has_more_from_pagination": "tests/test_models.py::TestLinkListPagination", } diff --git a/tests/test_links.py b/tests/test_links.py index 0b3be14..3137905 100644 --- a/tests/test_links.py +++ b/tests/test_links.py @@ -182,6 +182,23 @@ def test_list_all_clamps_limit_to_100(self): list(resource.list_all(limit=500)) assert resource._http.get.call_args[1]["params"]["limit"] == 100 + def test_list_all_clamps_zero_and_negative_limit(self): + """limit=0 (or negative) must clamp to >=1 and terminate — a bare + min(limit, 100) with no lower bound would send limit=0 to the platform, + and if a page ever came back non-empty, loop forever (`len(page.links) < + 0` is never true, so the short-page stop condition could never fire).""" + resource = _make_resource() + resource._http.get.return_value = {"links": []} + results = list(resource.list_all(limit=0)) + assert resource._http.get.call_args[1]["params"]["limit"] == 1 + assert results == [] + assert resource._http.get.call_count == 1 # terminates after one page + + resource2 = _make_resource() + resource2._http.get.return_value = {"links": []} + list(resource2.list_all(limit=-5)) + assert resource2._http.get.call_args[1]["params"]["limit"] == 1 + # --------------------------------------------------------------------------- # Integration tests — require AWSYS_API_KEY diff --git a/tests/test_models.py b/tests/test_models.py index 4775cc7..4d4e743 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -2,7 +2,7 @@ from __future__ import annotations -from awsysco.models import Folder, Link +from awsysco.models import Folder, Link, LinkList class TestTimestampCoercion: @@ -16,6 +16,21 @@ def test_firestore_seconds_nanos_converted_to_iso(self): ) assert folder.created_at == "2025-01-01T00:00:00Z" + def test_firestore_timestamp_converted_on_a_real_link_response(self): + """The coercion is a base-model validator (applies to every model), but + it must be checked against Link specifically — the primary response type — + not only against a secondary model like Folder.""" + link = Link.model_validate( + { + "id": "abc123", + "shortCode": "abc123", + "created": {"_seconds": 1735689600, "_nanoseconds": 0}, + "expiresAt": {"seconds": 1735776000, "nanoseconds": 500000000}, + } + ) + assert link.created == "2025-01-01T00:00:00Z" + assert link.expires_at == "2025-01-02T00:00:00.500000Z" + def test_firestore_alt_key_names_converted(self): folder = Folder.model_validate( {"id": "f1", "createdAt": {"seconds": 1735689600, "nanoseconds": 0}} @@ -26,6 +41,26 @@ def test_garbage_timestamp_keeps_raw_value_no_crash(self): folder = Folder.model_validate({"id": "f1", "createdAt": "not-a-timestamp"}) assert folder.created_at == "not-a-timestamp" + def test_non_numeric_nanoseconds_never_raises(self): + """A TypeError from `nanos / 1e9` when nanos isn't numeric must not escape — + it must be caught same as the numeric-but-out-of-range cases below.""" + folder = Folder.model_validate( + {"id": "f1", "createdAt": {"seconds": 1, "nanoseconds": "q"}} + ) + assert isinstance(folder.created_at, str) + + def test_huge_seconds_never_raises(self): + folder = Folder.model_validate({"id": "f1", "createdAt": {"_seconds": 1e300}}) + assert isinstance(folder.created_at, str) + + def test_very_negative_seconds_never_raises(self): + folder = Folder.model_validate({"id": "f1", "createdAt": {"seconds": -1e14}}) + assert isinstance(folder.created_at, str) + + def test_list_valued_seconds_never_raises(self): + folder = Folder.model_validate({"id": "f1", "createdAt": {"seconds": [1]}}) + assert isinstance(folder.created_at, str) + def test_dict_without_seconds_key_is_not_coerced(self): # Guards against over-eager coercion of a genuinely-unrelated dict value # (features/limits-style fields, which are typed to accept a dict). @@ -42,3 +77,31 @@ def test_extra_fields_do_not_raise(self): ) assert link.id == "x" assert link.model_extra["someBrandNewField"] == {"nested": True} + + +class TestLinkListPagination: + def test_has_more_read_from_nested_pagination_true(self): + result = LinkList.model_validate( + {"links": [], "pagination": {"limit": 20, "offset": 0, "hasMore": True}} + ) + assert result.has_more is True + assert result.limit == 20 + assert result.offset == 0 + + def test_has_more_read_from_nested_pagination_false(self): + result = LinkList.model_validate( + {"links": [], "pagination": {"limit": 20, "offset": 40, "hasMore": False}} + ) + assert result.has_more is False + + def test_top_level_has_more_key_is_not_used(self): + """A stray top-level hasMore (not how the platform actually responds) must + not be read — pagination.hasMore is the only source of truth.""" + result = LinkList.model_validate( + {"links": [], "hasMore": True, "pagination": {"hasMore": False}} + ) + assert result.has_more is False + + def test_missing_pagination_object_leaves_has_more_none(self): + result = LinkList.model_validate({"links": []}) + assert result.has_more is None diff --git a/tests/test_qr.py b/tests/test_qr.py index 0a5d0d0..5845917 100644 --- a/tests/test_qr.py +++ b/tests/test_qr.py @@ -17,7 +17,7 @@ def test_get_url_default_params(self, client: Client) -> None: url = client.qr.get_url("abc123") assert "size=300" in url assert "color=000000" in url - assert "bgColor=FFFFFF" in url + assert "bgColor=ffffff" in url # lowercase, matching the platform's own convention def test_get_url_custom_size(self, client: Client) -> None: url = client.qr.get_url("abc123", size=400) diff --git a/tests/test_transport.py b/tests/test_transport.py index 2e66622..4167982 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -6,7 +6,7 @@ import asyncio import http.client -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import httpx import pytest @@ -332,6 +332,42 @@ def test_sync_client_raises_immediately_on_excessive_retry_after(self, monkeypat assert slept == [] # never slept assert exc_info.value.retry_after == 999.0 # still reported to the caller + def test_sync_client_raises_immediately_on_excessive_retry_after_503(self, monkeypatch): + """err_503_retry_after_oversized: a retryable 5xx with an oversized + Retry-After must raise immediately too, not just 429 — this path had no + excessiveness check at all before an independent review caught it.""" + client = HttpClient(api_key="awsys_x", base_url="https://awsys.co", max_retries=3) + resp = _FakeResponse( + 503, json_body={"error": True, "message": "unavailable"}, headers={"Retry-After": "3600"} + ) + mock_request = MagicMock(return_value=resp) + monkeypatch.setattr(client._client, "request", mock_request) + slept = [] + monkeypatch.setattr("awsysco._http.time.sleep", lambda d: slept.append(d)) + with pytest.raises(AwsysServerError): + client.get("/api/v1/links") + assert mock_request.call_count == 1 + assert slept == [] + + def test_async_client_raises_immediately_on_excessive_retry_after_503(self, monkeypatch): + async def _run(): + client = AsyncHttpClient(api_key="awsys_x", base_url="https://awsys.co", max_retries=3) + resp = _FakeResponse( + 503, json_body={"error": True, "message": "unavailable"}, headers={"Retry-After": "3600"} + ) + + async def fake_request(*args, **kwargs): + return resp + + monkeypatch.setattr(client._client, "request", fake_request) + sleep_mock = AsyncMock() + monkeypatch.setattr("awsysco._async_http.asyncio.sleep", sleep_mock) + with pytest.raises(AwsysServerError): + await client.get("/api/v1/links") + sleep_mock.assert_not_awaited() + + asyncio.run(_run()) + # --------------------------------------------------------------------------- # 422 → AwsysValidationError; non-JSON 2xx body → typed SDK error, not a raw diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 47e7e83..ba17a66 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -117,4 +117,11 @@ def test_test_calls_correct_endpoint(self): def test_repr_never_contains_raw_secret(self): webhook = Webhook.model_validate({"id": "wh1", "secret": "whsec_supersecret"}) assert "whsec_supersecret" not in repr(webhook) - assert "" in repr(webhook) + + def test_str_never_contains_raw_secret(self): + """str()/f-string formatting has its own code path from repr() in pydantic — + this must be checked independently (a prior fix only covered __repr__).""" + webhook = Webhook.model_validate({"id": "wh1", "secret": "whsec_supersecret"}) + assert "whsec_supersecret" not in str(webhook) + assert "whsec_supersecret" not in f"{webhook}" + assert "whsec_supersecret" not in "%s" % webhook From c788e34a2e55d58ee7a89598eff722fb5b82c22a Mon Sep 17 00:00:00 2001 From: pbertsch Date: Mon, 7 Sep 2026 13:11:14 -0600 Subject: [PATCH 4/4] docs: record retry-loop consolidation as known-limitations debt 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 Claude-Session: https://claude.ai/code/session_014BLo12Xi5cjtEA5HiNb3y8 --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fef76d6..bebe9cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -159,6 +159,20 @@ changes — see below. `repr(AsyncHttpClient)`. - See `SECURITY-REVIEW.md` for the full dependency audit and finding list. +### Known limitations +- The sync/async retry loops (`_http.py`/`_async_http.py`, `_request`/`get_text` + each) share their retry-*decision* logic (`_transport.py`'s `is_idempotent`, + `is_retry_after_excessive`, `get_retry_after`, `compute_delay`) but not the + surrounding loop structure itself — there are still 4 near-identical + `while True: try/except ... continue` blocks. An independent review flagged + this as the source of a prior drift (the `get_text` variants had fallen out + of sync with `_request`'s retry conditions before this pass). The + correctness-relevant duplication is now eliminated (all four call the same + shared helpers), so a repeat of that specific drift shouldn't recur, but a + full structural consolidation (one shared loop, parameterized over + json-vs-text response handling and sync-vs-async) was deferred as a larger, + riskier refactor for marginal further benefit. Tracked as follow-up debt. + ## [1.3.0] — 2026-07-19 Parity resources: `usage`, `web2app`, `imports` (Phase "parity").