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
44 changes: 40 additions & 4 deletions awsysco/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""AWSYS.CO Python SDK — Official client library for the AWSYS.CO URL Shortener API."""

from .client import Client
from .client import AsyncClient, Client
from .exceptions import (
AwsysAuthError,
AwsysConflictError,
Expand All @@ -11,21 +11,35 @@
AwsysValidationError,
)
from .models import (
AffiliateProgram,
BulkLinkResult,
BulkResult,
ClickEvent,
CustomDomain,
Folder,
FolderList,
GeoRestriction,
Link,
LinkList,
LinkStats,
MeResponse,
NamespaceCheckResult,
NamespaceInfo,
OgMeta,
QRSettings,
RoutingRule,
SavedView,
SavedViewFilters,
TrustScoreResult,
UtmTemplate,
Webhook,
)

__version__ = "0.1.0"
__version__ = "1.0.0"
__all__ = [
# Client
# Clients
"Client",
"AsyncClient",
# Exceptions
"AwsysError",
"AwsysAuthError",
Expand All @@ -34,7 +48,7 @@
"AwsysConflictError",
"AwsysValidationError",
"AwsysRateLimitError",
# Models
# Core models
"Link",
"LinkList",
"LinkStats",
Expand All @@ -44,4 +58,26 @@
"BulkResult",
"BulkLinkResult",
"MeResponse",
# Links advanced models
"RoutingRule",
"OgMeta",
"GeoRestriction",
# QR
"QRSettings",
# Trust Score
"TrustScoreResult",
# Namespace
"NamespaceInfo",
"NamespaceCheckResult",
# UTM Templates
"UtmTemplate",
# Webhooks
"Webhook",
# Saved Views
"SavedViewFilters",
"SavedView",
# Custom Domains
"CustomDomain",
# Affiliate
"AffiliateProgram",
]
158 changes: 158 additions & 0 deletions awsysco/_async_http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"""Async HTTP client wrapper with retry logic and error mapping."""

from __future__ import annotations

import asyncio
from typing import Any, Dict, Optional

import httpx

from .exceptions import (
AwsysAuthError,
AwsysConflictError,
AwsysError,
AwsysForbiddenError,
AwsysNotFoundError,
AwsysRateLimitError,
AwsysValidationError,
)

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


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("/")
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",
},
timeout=timeout,
)

async def _request(
self,
method: str,
path: str,
*,
params: Optional[Dict[str, Any]] = None,
json: Optional[Any] = None,
) -> Any:
"""Execute an async HTTP request with 429-retry logic."""
attempt = 0
while True:
response = await self._client.request(method, path, params=params, json=json)

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)
attempt += 1
continue

if response.is_error:
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)

async def get_text(self, path: str, *, params: Optional[Dict[str, Any]] = 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)

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)
attempt += 1
continue

if response.is_error:
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 patch(self, path: str, *, json: Optional[Any] = None) -> Any:
return await self._request("PATCH", path, json=json)

async def put(self, path: str, *, json: Optional[Any] = None) -> Any:
return await self._request("PUT", path, json=json)

async def delete(self, path: str) -> Any:
return await self._request("DELETE", path)

async def aclose(self) -> None:
"""Close the underlying async HTTP connection pool."""
await self._client.aclose()

async def __aenter__(self) -> "AsyncHttpClient":
return self

async def __aexit__(self, *args: Any) -> None:
await self.aclose()
24 changes: 23 additions & 1 deletion awsysco/_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ def __init__(self, api_key: str, base_url: str, timeout: float = 30.0) -> None:
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "awsysco-python-sdk/0.1.0",
"User-Agent": "awsysco-python-sdk/1.0.0",
},
timeout=timeout,
)
Expand Down Expand Up @@ -118,12 +118,34 @@ def _request(
def get(self, path: str, *, params: Optional[Dict[str, Any]] = None) -> Any:
return self._request("GET", path, params=params)

def get_text(self, path: str, *, params: Optional[Dict[str, Any]] = None) -> str:
"""Like get() but returns response.text instead of response.json()."""
attempt = 0
while True:
response = self._client.request("GET", path, params=params)

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)
attempt += 1
continue

if response.is_error:
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 patch(self, path: str, *, json: Optional[Any] = None) -> Any:
return self._request("PATCH", path, json=json)

def put(self, path: str, *, json: Optional[Any] = None) -> Any:
return self._request("PUT", path, json=json)

def delete(self, path: str) -> Any:
return self._request("DELETE", path)

Expand Down
1 change: 1 addition & 0 deletions awsysco/async_resources/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Async resource classes for the AWSYS.CO SDK."""
80 changes: 80 additions & 0 deletions awsysco/async_resources/affiliate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Async Affiliate resource."""

from __future__ import annotations

from typing import Any, Dict, List, Optional

from .._async_http import AsyncHttpClient
from ..models import AffiliateProgram


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

async def create_program(self, name: str, commission_type: str, **kwargs: Any) -> AffiliateProgram:
body: Dict[str, Any] = {"name": name, "commissionType": commission_type}
field_map = {"description": "description", "cpc_rate": "cpcRate", "cpa_rate": "cpaRate", "cookie_days": "cookieDays"}
for k, v in kwargs.items():
body[field_map.get(k, k)] = v
data = await self._http.post("/api/affiliate/programs", json=body)
return AffiliateProgram.model_validate(data)

async def list_programs(self) -> List[AffiliateProgram]:
data = await self._http.get("/api/affiliate/programs")
if isinstance(data, list):
return [AffiliateProgram.model_validate(item) for item in data]
items = data.get("programs", []) if isinstance(data, dict) else []
return [AffiliateProgram.model_validate(item) for item in items]

async def get_program(self, program_id: str) -> AffiliateProgram:
data = await self._http.get(f"/api/affiliate/programs/{program_id}")
return AffiliateProgram.model_validate(data)

async def update_program(self, program_id: str, **kwargs: Any) -> AffiliateProgram:
body: Dict[str, Any] = {}
field_map = {"cpc_rate": "cpcRate", "cpa_rate": "cpaRate", "cookie_days": "cookieDays", "commission_type": "commissionType"}
for k, v in kwargs.items():
body[field_map.get(k, k)] = v
data = await self._http.patch(f"/api/affiliate/programs/{program_id}", json=body)
return AffiliateProgram.model_validate(data)

async def get_program_stats(self, program_id: str, *, period: str = "30d") -> dict:
return await self._http.get(f"/api/affiliate/programs/{program_id}/stats", params={"period": period}) or {}

async def list_partners(self, program_id: str) -> List[dict]:
data = await self._http.get(f"/api/affiliate/programs/{program_id}/partners")
if isinstance(data, list):
return data
return data.get("partners", []) if isinstance(data, dict) else []

async def update_partner_status(self, program_id: str, partner_id: str, status: str) -> dict:
return await self._http.patch(f"/api/affiliate/programs/{program_id}/partners/{partner_id}", json={"status": status}) or {}

async def discover(self, *, limit: int = 20) -> List[AffiliateProgram]:
data = await self._http.get("/api/affiliate/discover", params={"limit": limit})
if isinstance(data, list):
return [AffiliateProgram.model_validate(item) for item in data]
items = data.get("programs", []) if isinstance(data, dict) else []
return [AffiliateProgram.model_validate(item) for item in items]

async def join(self, program_id: str, *, partner_code: Optional[str] = None) -> dict:
body: Dict[str, Any] = {}
if partner_code is not None:
body["partnerCode"] = partner_code
return await self._http.post(f"/api/affiliate/join/{program_id}", json=body) or {}

async def list_partnerships(self) -> List[dict]:
data = await self._http.get("/api/affiliate/partnerships")
if isinstance(data, list):
return data
return data.get("partnerships", []) if isinstance(data, dict) else []

async def get_partnership_stats(self, partnership_id: str, *, period: str = "30d") -> dict:
return await self._http.get(f"/api/affiliate/partnerships/{partnership_id}/stats", params={"period": period}) or {}

async def leave_program(self, partnership_id: str) -> dict:
return await self._http.delete(f"/api/affiliate/partnerships/{partnership_id}") or {}

async def get_limits(self) -> dict:
return await self._http.get("/api/affiliate/limits") or {}
19 changes: 19 additions & 0 deletions awsysco/async_resources/agentlink.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Async AgentLink resource."""

from __future__ import annotations

from .._async_http import AsyncHttpClient


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

async def subscribe(self, email: str) -> dict:
return await self._http.post("/api/agentlink/subscribe", json={"email": email}) or {}

async def get_link_stats(self, short_path: str, *, period_days: int = 7) -> dict:
return await self._http.get(f"/api/agentlink/links/{short_path}/stats", params={"period": period_days}) or {}

async def get_account_stats(self, *, period_days: int = 7) -> dict:
return await self._http.get("/api/agentlink/account/stats", params={"period": period_days}) or {}
Loading
Loading