From 3f6dc332e28e96175faf4d7c77cfa226a49c0a80 Mon Sep 17 00:00:00 2001 From: pbertsch Date: Sat, 27 Jun 2026 12:08:15 -0600 Subject: [PATCH] feat: add usage and web2app resources (#parity) Add two new resources to reach parity with the MCP server, both sync and async: - usage.get() -> GET /api/user/stats -> UsageStats (live consumption + tier limits + overage; distinct from me.get() static profile) - web2app.consume_session(token) -> GET /api/v1/web2app/{token} -> Web2AppSession (single-use, 24h TTL) New Pydantic v2 models (camelCase aliases): UsageStats, UsageLimits, UsageOverage, Web2AppSession. Limit fields that the API returns as either an int cap or the literal "unlimited" are typed Optional[Union[int, str]]. Wired into Client and AsyncClient, exported from package surface. Tests cover sync + async for both resources, including an "unlimited" limit value and endpoint-path assertions. Version bumped 1.1.0 -> 1.2.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 38 ++++++++++ awsysco/__init__.py | 12 ++- awsysco/async_resources/usage.py | 20 +++++ awsysco/async_resources/web2app.py | 25 ++++++ awsysco/client.py | 12 +++ awsysco/models.py | 80 +++++++++++++++++++- awsysco/resources/usage.py | 27 +++++++ awsysco/resources/web2app.py | 35 +++++++++ pyproject.toml | 2 +- tests/test_async_client.py | 8 ++ tests/test_usage.py | 117 +++++++++++++++++++++++++++++ tests/test_web2app.py | 84 +++++++++++++++++++++ 12 files changed, 457 insertions(+), 3 deletions(-) create mode 100644 awsysco/async_resources/usage.py create mode 100644 awsysco/async_resources/web2app.py create mode 100644 awsysco/resources/usage.py create mode 100644 awsysco/resources/web2app.py create mode 100644 tests/test_usage.py create mode 100644 tests/test_web2app.py diff --git a/README.md b/README.md index d63e8ca..6003a0f 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,40 @@ me = client.me.get() print(me.email, me.subscription_tier, me.is_premium) ``` +### Usage + +| Method | Description | +|---|---| +| `client.usage.get()` | Get live account consumption + tier limits | + +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"`. + +```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") +``` + +### Web2App + +| Method | Description | +|---|---| +| `client.web2app.consume_session(token)` | Consume a Web2App deep-link session | + +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`. + +```python +session = client.web2app.consume_session("0123456789abcdef0123456789abcdef") +print(session.link_id, session.utm_params, session.country) +``` + ## Error Handling All errors inherit from `AwsysError`. @@ -250,6 +284,10 @@ All responses are parsed into Pydantic v2 models: | `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` | ## Development Setup diff --git a/awsysco/__init__.py b/awsysco/__init__.py index 6b36d82..658b5e7 100644 --- a/awsysco/__init__.py +++ b/awsysco/__init__.py @@ -31,11 +31,15 @@ SavedView, SavedViewFilters, TrustScoreResult, + UsageLimits, + UsageOverage, + UsageStats, UtmTemplate, + Web2AppSession, Webhook, ) -__version__ = "1.0.0" +__version__ = "1.2.0" __all__ = [ # Clients "Client", @@ -80,4 +84,10 @@ "CustomDomain", # Affiliate "AffiliateProgram", + # Usage + "UsageStats", + "UsageLimits", + "UsageOverage", + # Web2App + "Web2AppSession", ] diff --git a/awsysco/async_resources/usage.py b/awsysco/async_resources/usage.py new file mode 100644 index 0000000..67a834c --- /dev/null +++ b/awsysco/async_resources/usage.py @@ -0,0 +1,20 @@ +"""Async Usage resource.""" + +from __future__ import annotations + +from .._async_http import AsyncHttpClient +from ..models import UsageStats + + +class AsyncUsageResource: + def __init__(self, http: AsyncHttpClient) -> None: + self._http = http + + async def get(self) -> UsageStats: + """Get live consumption stats for the authenticated account. + + Unlike ``me.get()`` (static profile), this returns live usage + counters, current tier limits, and any active overage state. + """ + data = await self._http.get("/api/user/stats") + return UsageStats.model_validate(data) diff --git a/awsysco/async_resources/web2app.py b/awsysco/async_resources/web2app.py new file mode 100644 index 0000000..c9f8353 --- /dev/null +++ b/awsysco/async_resources/web2app.py @@ -0,0 +1,25 @@ +"""Async Web2App resource.""" + +from __future__ import annotations + +from urllib.parse import quote + +from .._async_http import AsyncHttpClient +from ..models import Web2AppSession + + +class AsyncWeb2AppResource: + def __init__(self, http: AsyncHttpClient) -> None: + self._http = http + + async def consume_session(self, token: str) -> Web2AppSession: + """Consume a single-use Web2App deep-link session by its token. + + Sessions are single-use (consumed on read) with a 24-hour TTL. + Unknown/expired/consumed tokens raise ``AwsysNotFoundError`` (404); + malformed tokens raise ``AwsysValidationError`` (400), mapped by the + underlying transport. + """ + encoded = quote(token, safe="") + data = await self._http.get(f"/api/v1/web2app/{encoded}") + return Web2AppSession.model_validate(data) diff --git a/awsysco/client.py b/awsysco/client.py index 73cc194..c48daf6 100644 --- a/awsysco/client.py +++ b/awsysco/client.py @@ -20,7 +20,9 @@ from .async_resources.saved_views import AsyncSavedViewsResource from .async_resources.tags import AsyncTagsResource from .async_resources.trust_score import AsyncTrustScoreResource +from .async_resources.usage import AsyncUsageResource from .async_resources.utm_templates import AsyncUtmTemplatesResource +from .async_resources.web2app import AsyncWeb2AppResource from .async_resources.webhooks import AsyncWebhooksResource from .resources.affiliate import AffiliateResource from .resources.agentlink import AgentlinkResource @@ -36,7 +38,9 @@ from .resources.saved_views import SavedViewsResource from .resources.tags import TagsResource from .resources.trust_score import TrustScoreResource +from .resources.usage import UsageResource from .resources.utm_templates import UtmTemplatesResource +from .resources.web2app import Web2AppResource from .resources.webhooks import WebhooksResource _DEFAULT_BASE_URL = "https://awsys.co" @@ -92,6 +96,10 @@ def __init__( self.agentlink = AgentlinkResource(self._http) self.affiliate = AffiliateResource(self._http) + # Parity resources + self.usage = UsageResource(self._http) + self.web2app = Web2AppResource(self._http) + def close(self) -> None: """Close the underlying HTTP connection pool.""" self._http.close() @@ -151,6 +159,10 @@ def __init__( self.agentlink = AsyncAgentlinkResource(self._http) self.affiliate = AsyncAffiliateResource(self._http) + # Parity resources + self.usage = AsyncUsageResource(self._http) + self.web2app = AsyncWeb2AppResource(self._http) + async def aclose(self) -> None: """Close the underlying async HTTP connection pool.""" await self._http.aclose() diff --git a/awsysco/models.py b/awsysco/models.py index 82fdfd0..c14ef0b 100644 --- a/awsysco/models.py +++ b/awsysco/models.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Union from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel @@ -30,6 +30,10 @@ "SavedView", "CustomDomain", "AffiliateProgram", + "UsageLimits", + "UsageOverage", + "UsageStats", + "Web2AppSession", ] @@ -333,3 +337,77 @@ class AffiliateProgram(_CamelModel): cpa_rate: Optional[float] = None cookie_days: Optional[int] = None status: Optional[str] = None + + +# --------------------------------------------------------------------------- +# Usage models — live consumption + tier limits +# --------------------------------------------------------------------------- + + +class UsageLimits(_CamelModel): + """Tier limits as reported by ``/api/user/stats``. + + Several limit fields may be either an integer cap or the string + ``"unlimited"`` (the API serializes an unlimited cap of ``-1`` as the + literal ``"unlimited"``), so those are typed as ``Union[int, str]``. + """ + + links_per_month: Optional[Union[int, str]] = None + monthly_links: Optional[Union[int, str]] = None + daily_links: Optional[Union[int, str]] = None + monthly_tracked_clicks: Optional[Union[int, str]] = None + qr_codes: Optional[Union[int, str]] = None + folders: Optional[Union[int, str]] = None + api_calls_per_month: Optional[int] = None + custom_slugs: Optional[int] = None + + +class UsageOverage(_CamelModel): + """Overage (pay-as-you-go) state within the current billing cycle.""" + + active: Optional[bool] = None + started_at: Optional[str] = None + expires_at: Optional[str] = None + hours_until_drop: Optional[float] = None + clicks_this_cycle: Optional[int] = None + spending_limit_cents: Optional[int] = None + estimated_charge_cents: Optional[int] = None + + +class UsageStats(_CamelModel): + """Live consumption stats from ``/api/user/stats``.""" + + total_links: Optional[int] = None + total_clicks: Optional[int] = None + links_created_this_month: Optional[int] = None + qr_codes_this_month: Optional[int] = None + folder_count: Optional[int] = None + api_calls_this_month: Optional[int] = None + tracked_clicks_this_month: Optional[int] = None + tier: Optional[str] = None + limits: Optional[UsageLimits] = None + has_api_key: Optional[bool] = None + api_key_created_at: Optional[str] = None + user_prefix: Optional[str] = None + is_premium: Optional[bool] = None + overage: Optional[UsageOverage] = None + + +# --------------------------------------------------------------------------- +# Web2App model +# --------------------------------------------------------------------------- + + +class Web2AppSession(_CamelModel): + """A single-use Web2App deep-link session returned by + ``/api/v1/web2app/{token}``. + + Sessions are single-use (consumed on read) with a 24-hour TTL. + """ + + success: Optional[bool] = None + link_id: Optional[str] = None + utm_params: Dict[str, str] = Field(default_factory=dict) + routing_rule: Optional[Dict[str, Any]] = None + country: Optional[str] = None + clicked_at: Optional[str] = None diff --git a/awsysco/resources/usage.py b/awsysco/resources/usage.py new file mode 100644 index 0000000..bf45b3f --- /dev/null +++ b/awsysco/resources/usage.py @@ -0,0 +1,27 @@ +"""Usage resource — live account consumption and tier limits.""" + +from __future__ import annotations + +from .._http import HttpClient +from ..models import UsageStats + + +class UsageResource: + """Interact with /api/user/stats.""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def get(self) -> UsageStats: + """Get live consumption stats for the authenticated account. + + Unlike :meth:`MeResource.get`, which returns the user's static + profile, this returns live usage counters (links, clicks, QR codes, + API calls, tracked clicks) alongside the current tier limits and any + active overage state. + + Returns: + A UsageStats with current consumption, limits, and overage state. + """ + data = self._http.get("/api/user/stats") + return UsageStats.model_validate(data) diff --git a/awsysco/resources/web2app.py b/awsysco/resources/web2app.py new file mode 100644 index 0000000..48d284f --- /dev/null +++ b/awsysco/resources/web2app.py @@ -0,0 +1,35 @@ +"""Web2App resource — single-use deep-link session consumption.""" + +from __future__ import annotations + +from urllib.parse import quote + +from .._http import HttpClient +from ..models import Web2AppSession + + +class Web2AppResource: + """Interact with /api/v1/web2app/:token.""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def consume_session(self, token: str) -> Web2AppSession: + """Consume a Web2App deep-link session by its token. + + Sessions are single-use — consuming one deletes it server-side — and + expire 24 hours after creation. Consuming an unknown, expired, or + already-consumed token raises ``AwsysNotFoundError`` (404); a + malformed token raises ``AwsysValidationError`` (400). These are + mapped by the underlying transport. + + Args: + token: The 32-character hex session token. + + Returns: + A Web2AppSession with the resolved link id, UTM params, routing + rule, country, and click timestamp. + """ + encoded = quote(token, safe="") + data = self._http.get(f"/api/v1/web2app/{encoded}") + return Web2AppSession.model_validate(data) diff --git a/pyproject.toml b/pyproject.toml index 50bc4da..140c3e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "awsysco" -version = "1.1.0" +version = "1.2.0" description = "Official Python SDK for the AWSYS.CO URL Shortener API" readme = "README.md" requires-python = ">=3.9" diff --git a/tests/test_async_client.py b/tests/test_async_client.py index d96edbe..36b8883 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -76,6 +76,14 @@ def test_has_affiliate_resource(self): client = AsyncClient(api_key="awsys_test") assert hasattr(client, "affiliate") + def test_has_usage_resource(self): + client = AsyncClient(api_key="awsys_test") + assert hasattr(client, "usage") + + def test_has_web2app_resource(self): + client = AsyncClient(api_key="awsys_test") + assert hasattr(client, "web2app") + def test_qr_get_url_is_synchronous(self): """QR URL construction is sync even in AsyncClient.""" client = AsyncClient(api_key="awsys_test", base_url="https://awsys.co") diff --git a/tests/test_usage.py b/tests/test_usage.py new file mode 100644 index 0000000..38f17ac --- /dev/null +++ b/tests/test_usage.py @@ -0,0 +1,117 @@ +"""Unit tests for the Usage resource (sync + async).""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +from awsysco.async_resources.usage import AsyncUsageResource +from awsysco.models import UsageStats +from awsysco.resources.usage import UsageResource + + +def _sample_payload(): + return { + "totalLinks": 12, + "totalClicks": 340, + "linksCreatedThisMonth": 5, + "qrCodesThisMonth": 2, + "folderCount": 3, + "apiCallsThisMonth": 88, + "trackedClicksThisMonth": 120, + "tier": "pro", + "limits": { + # Mix of an integer cap and an "unlimited" literal. + "linksPerMonth": "unlimited", + "monthlyLinks": "unlimited", + "dailyLinks": 100, + "monthlyTrackedClicks": 50000, + "apiCallsPerMonth": 10000, + "qrCodes": "unlimited", + "folders": 50, + "customSlugs": 25, + }, + "hasApiKey": True, + "apiKeyCreatedAt": "2026-01-01T00:00:00Z", + "userPrefix": "demo", + "isPremium": True, + "overage": { + "active": False, + "startedAt": None, + "expiresAt": None, + "hoursUntilDrop": None, + "clicksThisCycle": 0, + "spendingLimitCents": 0, + "estimatedChargeCents": 0, + }, + } + + +def _make_resource(): + http = MagicMock() + http.get.return_value = _sample_payload() + return UsageResource(http) + + +class TestUsageSync: + def test_get_calls_correct_endpoint(self): + resource = _make_resource() + resource.get() + resource._http.get.assert_called_once_with("/api/user/stats") + + def test_get_returns_usage_stats(self): + result = _make_resource().get() + assert isinstance(result, UsageStats) + + def test_get_populates_counters(self): + result = _make_resource().get() + assert result.total_links == 12 + assert result.total_clicks == 340 + assert result.api_calls_this_month == 88 + assert result.tracked_clicks_this_month == 120 + assert result.tier == "pro" + + def test_unlimited_limit_value(self): + result = _make_resource().get() + assert result.limits.links_per_month == "unlimited" + assert result.limits.qr_codes == "unlimited" + + def test_integer_limit_value(self): + result = _make_resource().get() + assert result.limits.daily_links == 100 + assert result.limits.custom_slugs == 25 + + def test_overage_nested_model(self): + result = _make_resource().get() + assert result.overage.active is False + assert result.overage.estimated_charge_cents == 0 + + def test_has_api_key_and_prefix(self): + result = _make_resource().get() + assert result.has_api_key is True + assert result.user_prefix == "demo" + assert result.is_premium is True + + +def _make_async_resource(): + http = MagicMock() + http.get = AsyncMock(return_value=_sample_payload()) + return AsyncUsageResource(http) + + +class TestUsageAsync: + def test_get_calls_correct_endpoint(self): + resource = _make_async_resource() + asyncio.run(resource.get()) + resource._http.get.assert_awaited_once_with("/api/user/stats") + + def test_get_returns_usage_stats(self): + resource = _make_async_resource() + result = asyncio.run(resource.get()) + assert isinstance(result, UsageStats) + + def test_unlimited_limit_value(self): + resource = _make_async_resource() + result = asyncio.run(resource.get()) + assert result.limits.monthly_links == "unlimited" + assert result.limits.folders == 50 diff --git a/tests/test_web2app.py b/tests/test_web2app.py new file mode 100644 index 0000000..2df41fe --- /dev/null +++ b/tests/test_web2app.py @@ -0,0 +1,84 @@ +"""Unit tests for the Web2App resource (sync + async).""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +from awsysco.async_resources.web2app import AsyncWeb2AppResource +from awsysco.models import Web2AppSession +from awsysco.resources.web2app import Web2AppResource + +_TOKEN = "0123456789abcdef0123456789abcdef" + + +def _sample_payload(): + return { + "success": True, + "linkId": "abc123", + "utmParams": {"source": "newsletter", "medium": "email"}, + "routingRule": {"country": "US", "redirectUrl": "https://apps.apple.com/app"}, + "country": "US", + "clickedAt": "2026-01-01T00:00:00Z", + } + + +def _make_resource(): + http = MagicMock() + http.get.return_value = _sample_payload() + return Web2AppResource(http) + + +class TestWeb2AppSync: + def test_consume_calls_correct_endpoint(self): + resource = _make_resource() + resource.consume_session(_TOKEN) + resource._http.get.assert_called_once_with(f"/api/v1/web2app/{_TOKEN}") + + def test_consume_returns_session(self): + result = _make_resource().consume_session(_TOKEN) + assert isinstance(result, Web2AppSession) + + def test_consume_populates_fields(self): + result = _make_resource().consume_session(_TOKEN) + assert result.success is True + assert result.link_id == "abc123" + assert result.country == "US" + assert result.clicked_at == "2026-01-01T00:00:00Z" + + def test_consume_populates_utm_params(self): + result = _make_resource().consume_session(_TOKEN) + assert result.utm_params == {"source": "newsletter", "medium": "email"} + + def test_consume_populates_routing_rule(self): + result = _make_resource().consume_session(_TOKEN) + assert result.routing_rule["country"] == "US" + + def test_consume_encodes_token(self): + resource = _make_resource() + resource.consume_session("ns/slug") + resource._http.get.assert_called_once_with("/api/v1/web2app/ns%2Fslug") + + +def _make_async_resource(): + http = MagicMock() + http.get = AsyncMock(return_value=_sample_payload()) + return AsyncWeb2AppResource(http) + + +class TestWeb2AppAsync: + def test_consume_calls_correct_endpoint(self): + resource = _make_async_resource() + asyncio.run(resource.consume_session(_TOKEN)) + resource._http.get.assert_awaited_once_with(f"/api/v1/web2app/{_TOKEN}") + + def test_consume_returns_session(self): + resource = _make_async_resource() + result = asyncio.run(resource.consume_session(_TOKEN)) + assert isinstance(result, Web2AppSession) + + def test_consume_populates_fields(self): + resource = _make_async_resource() + result = asyncio.run(resource.consume_session(_TOKEN)) + assert result.link_id == "abc123" + assert result.utm_params == {"source": "newsletter", "medium": "email"}