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
12 changes: 8 additions & 4 deletions .github/workflows/secret-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ jobs:
with:
fetch-depth: 0

- name: Run gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Use the gitleaks CLI directly (MIT, free) instead of
# gitleaks/gitleaks-action@v2, which requires a paid GITLEAKS_LICENSE
# for GitHub organizations and otherwise fails the job.
- name: Run gitleaks (CLI)
run: |
VERSION=8.18.4
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz" | tar -xz gitleaks
./gitleaks detect --source . --no-git --no-banner --redact --exit-code 1
29 changes: 10 additions & 19 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -1,27 +1,18 @@
# Extends gitleaks' default rules; allowlists fixtures that intentionally
# contain fake credentials (test files, examples, docs).
[extend]
useDefault = true

[[rules]]
id = "awsys-api-key"
description = "AWSYS.CO API Key"
regex = '''awsys_[0-9a-f]{64}'''
tags = ["key", "awsys"]

[[rules]]
id = "awsys-api-key-short"
description = "AWSYS.CO API Key (short form)"
regex = '''awsys_[0-9a-zA-Z]{20,}'''
tags = ["key", "awsys"]

[allowlist]
description = "Global allowlist"
description = "Test fixtures, examples and docs use placeholder/fake tokens"
paths = [
'''.gitleaks.toml''',
'''\.env\.example''',
'''README\.md''',
'''(^|/)tests?/''',
'''.*\.test\.(ts|js)$''',
'''.*_test\.go$''',
'''(^|/)examples?/''',
'''(^|/)README\.md$''',
'''(^|/)CHANGELOG\.md$''',
]
regexes = [
'''awsys_<your[_-]api[_-]key>''',
'''awsys_YOUR_API_KEY''',
'''awsys_\.\.\.',
'''awsys_(test|example|dummy|fake)[A-Za-z0-9_]*''',
]
63 changes: 62 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ client.links.delete("my-link")

| Method | Description |
|---|---|
| `client.analytics.get_stats(short_path)` | Get click stats for a link |
| `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 |

```python
stats = client.analytics.get_stats("abc123")
Expand All @@ -96,6 +97,25 @@ for click in stats.clicks:
print(click.country, click.device, click.timestamp)
```

`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.

```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)
```

### QR Codes

| Method | Description |
Expand Down Expand Up @@ -193,6 +213,40 @@ 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.

```python
job = client.imports.start(provider="bitly", access_token="<bitly-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)
```

All `imports` methods are available identically on `AsyncClient`
(`await client.imports.start(...)`, `await client.imports.wait_for_completion(...)`).

## Error Handling

All errors inherit from `AwsysError`.
Expand Down Expand Up @@ -288,6 +342,13 @@ All responses are parsed into Pydantic v2 models:
| `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` |

## Development Setup

Expand Down
20 changes: 19 additions & 1 deletion awsysco/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,19 @@
)
from .models import (
AffiliateProgram,
AggregateAnalytics,
BulkLinkResult,
BulkResult,
ClickEvent,
CustomDomain,
DayClicks,
DeviceBreakdown,
Folder,
FolderList,
GeoRestriction,
HourClicks,
ImportCounts,
ImportJob,
Link,
LinkList,
LinkStats,
Expand All @@ -31,15 +37,17 @@
SavedView,
SavedViewFilters,
TrustScoreResult,
UpgradeForMore,
UsageLimits,
UsageOverage,
UsageStats,
UTMBreakdown,
UtmTemplate,
Web2AppSession,
Webhook,
)

__version__ = "1.2.0"
__version__ = "1.3.0"
__all__ = [
# Clients
"Client",
Expand Down Expand Up @@ -90,4 +98,14 @@
"UsageOverage",
# Web2App
"Web2AppSession",
# Imports
"ImportCounts",
"ImportJob",
# Aggregate analytics
"AggregateAnalytics",
"DayClicks",
"HourClicks",
"DeviceBreakdown",
"UTMBreakdown",
"UpgradeForMore",
]
14 changes: 13 additions & 1 deletion awsysco/async_resources/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from typing import List, Optional

from .._async_http import AsyncHttpClient
from ..models import ClickEvent, LinkStats
from ..models import AggregateAnalytics, ClickEvent, LinkStats


class AsyncAnalyticsResource:
Expand All @@ -22,6 +22,18 @@ async def get_stats(self, short_path: str, *, period: Optional[str] = None) -> L
)
return LinkStats.model_validate(data)

async def get_aggregate_stats(
self, short_path: str, *, period: Optional[str] = None
) -> AggregateAnalytics:
params = {}
if period is not None:
params["period"] = period
data = await self._http.get(
f"/api/v1/links/{short_path}/stats/aggregate",
params=params if params else None,
)
return AggregateAnalytics.model_validate(data)

async def get_recent_clicks(self, *, limit: Optional[int] = None) -> List[ClickEvent]:
params = {}
if limit is not None:
Expand Down
92 changes: 92 additions & 0 deletions awsysco/async_resources/imports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Async Imports resource."""

from __future__ import annotations

import asyncio
import time
from typing import List, Optional
from urllib.parse import quote

from .._async_http import AsyncHttpClient
from ..models import ImportJob

# Statuses that indicate the import job has stopped progressing.
_TERMINAL_STATES = {"completed", "partial", "failed", "cancelled"}


class AsyncImportsResource:
def __init__(self, http: AsyncHttpClient) -> None:
self._http = http

async def start(
self,
*,
provider: str,
access_token: str,
target_namespace: 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}
if target_namespace is not None:
body["target_namespace"] = target_namespace
if scan_only is not None:
body["scan_only"] = scan_only
data = await self._http.post("/api/v1/imports", json=body)
return ImportJob.model_validate(data)

async def get_status(self, job_id: str) -> ImportJob:
"""Get the current state of an import job."""
encoded = quote(job_id, safe="")
data = await self._http.get(f"/api/v1/imports/{encoded}")
return ImportJob.model_validate(data)

async def cancel(self, job_id: str) -> ImportJob:
"""Cancel an in-progress import job."""
encoded = quote(job_id, safe="")
data = await self._http.delete(f"/api/v1/imports/{encoded}")
return ImportJob.model_validate(data)

async def list(self, *, limit: Optional[int] = None) -> List[ImportJob]:
"""List import jobs for the authenticated user."""
params = {}
if limit is not None:
params["limit"] = limit
data = await self._http.get(
"/api/v1/imports",
params=params if params else None,
)
items = data.get("jobs", []) if isinstance(data, dict) else (data or [])
return [ImportJob.model_validate(item) for item in items]

async def wait_for_completion(
self,
job_id: str,
*,
poll_interval: float = 2.0,
timeout: float = 120.0,
) -> ImportJob:
"""Poll an import job until it reaches a terminal state.

Polls ``get_status`` every ``poll_interval`` seconds until the job
status is one of ``completed``, ``partial``, ``failed``, or
``cancelled``.

Raises:
TimeoutError: If the job does not finish within ``timeout``.
"""
deadline = time.monotonic() + timeout
while True:
job = await self.get_status(job_id)
if job.status in _TERMINAL_STATES:
return job
if time.monotonic() >= deadline:
raise TimeoutError(
f"Import job {job_id} did not complete within {timeout}s "
f"(last status: {job.status})"
)
await asyncio.sleep(poll_interval)
4 changes: 4 additions & 0 deletions awsysco/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from .async_resources.custom_domains import AsyncCustomDomainsResource
from .async_resources.data_export import AsyncDataExportResource
from .async_resources.folders import AsyncFoldersResource
from .async_resources.imports import AsyncImportsResource
from .async_resources.links import AsyncLinksResource
from .async_resources.me import AsyncMeResource
from .async_resources.namespace import AsyncNamespaceResource
Expand All @@ -31,6 +32,7 @@
from .resources.custom_domains import CustomDomainsResource
from .resources.data_export import DataExportResource
from .resources.folders import FoldersResource
from .resources.imports import ImportsResource
from .resources.links import LinksResource
from .resources.me import MeResource
from .resources.namespace import NamespaceResource
Expand Down Expand Up @@ -99,6 +101,7 @@ def __init__(
# Parity resources
self.usage = UsageResource(self._http)
self.web2app = Web2AppResource(self._http)
self.imports = ImportsResource(self._http)

def close(self) -> None:
"""Close the underlying HTTP connection pool."""
Expand Down Expand Up @@ -162,6 +165,7 @@ def __init__(
# Parity resources
self.usage = AsyncUsageResource(self._http)
self.web2app = AsyncWeb2AppResource(self._http)
self.imports = AsyncImportsResource(self._http)

async def aclose(self) -> None:
"""Close the underlying async HTTP connection pool."""
Expand Down
Loading
Loading