Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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

Expand Down
12 changes: 11 additions & 1 deletion awsysco/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,15 @@
SavedView,
SavedViewFilters,
TrustScoreResult,
UsageLimits,
UsageOverage,
UsageStats,
UtmTemplate,
Web2AppSession,
Webhook,
)

__version__ = "1.0.0"
__version__ = "1.2.0"
__all__ = [
# Clients
"Client",
Expand Down Expand Up @@ -80,4 +84,10 @@
"CustomDomain",
# Affiliate
"AffiliateProgram",
# Usage
"UsageStats",
"UsageLimits",
"UsageOverage",
# Web2App
"Web2AppSession",
]
20 changes: 20 additions & 0 deletions awsysco/async_resources/usage.py
Original file line number Diff line number Diff line change
@@ -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)
25 changes: 25 additions & 0 deletions awsysco/async_resources/web2app.py
Original file line number Diff line number Diff line change
@@ -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)
12 changes: 12 additions & 0 deletions awsysco/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
80 changes: 79 additions & 1 deletion awsysco/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -30,6 +30,10 @@
"SavedView",
"CustomDomain",
"AffiliateProgram",
"UsageLimits",
"UsageOverage",
"UsageStats",
"Web2AppSession",
]


Expand Down Expand Up @@ -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
27 changes: 27 additions & 0 deletions awsysco/resources/usage.py
Original file line number Diff line number Diff line change
@@ -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)
35 changes: 35 additions & 0 deletions awsysco/resources/web2app.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions tests/test_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading