Initial AS213905 SDK release

This commit is contained in:
AS213905 Engineering
2026-08-14 11:06:03 +00:00
commit ff4aa1b64e
29 changed files with 3313 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# Python SDK
```bash
pip install "as213905 @ git+https://git.datacoria.com/Phylex/as213905-sdk.git#subdirectory=python"
```
```python
from as213905 import AS213905
client = AS213905("as213905_…")
print(client.traffic(days=7))
```
The package has no runtime dependencies and uses the Python standard library.
+20
View File
@@ -0,0 +1,20 @@
[build-system]
requires = ["hatchling>=1.24"]
build-backend = "hatchling.build"
[project]
name = "as213905"
version = "1.1.0"
description = "Official AS213905 customer API client"
readme = "README.md"
requires-python = ">=3.9"
license = {text = "MIT"}
authors = [{name = "AS213905", email = "noc@as213905.com"}]
keywords = ["as213905", "bgp", "transit", "geofeed"]
[project.urls]
Documentation = "https://as213905.com/docs/api/reference"
Repository = "https://git.datacoria.com/Phylex/as213905-sdk"
[tool.pytest.ini_options]
testpaths = ["tests"]
+4
View File
@@ -0,0 +1,4 @@
from .client import AS213905, APIError
__all__ = ["AS213905", "APIError"]
__version__ = "1.1.0"
+99
View File
@@ -0,0 +1,99 @@
"""Typed, dependency-free AS213905 API client."""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any, Iterable, Mapping, Optional
from urllib.error import HTTPError, URLError
from urllib.parse import quote, urlencode
from urllib.request import Request, urlopen
@dataclass
class APIError(Exception):
status: int
code: str
message: str = ""
required_scope: Optional[str] = None
retry_after: Optional[int] = None
def __str__(self) -> str:
detail = f": {self.message}" if self.message else ""
return f"AS213905 API error {self.status} ({self.code}){detail}"
class AS213905:
def __init__(self, api_key: str, *, base_url: str = "https://as213905.com", timeout: float = 30.0) -> None:
if not api_key.strip():
raise ValueError("api_key is required")
self.api_key = api_key
self.base_url = base_url.rstrip("/")
self.timeout = timeout
def _request(self, method: str, path: str, *, organization_id: Optional[str] = None, body: Any = None) -> Any:
payload = None if body is None else json.dumps(body).encode()
headers = {"Authorization": f"Bearer {self.api_key}", "Accept": "application/json", "User-Agent": "as213905-python/1.1.0"}
if payload is not None:
headers["Content-Type"] = "application/json"
if organization_id:
headers["X-Organization-ID"] = organization_id
request = Request(self.base_url + path, data=payload, headers=headers, method=method)
try:
with urlopen(request, timeout=self.timeout) as response:
raw = response.read()
except HTTPError as exc:
try:
value = json.loads(exc.read())
except (ValueError, UnicodeDecodeError):
value = {}
retry = value.get("retry_after") or exc.headers.get("Retry-After")
raise APIError(exc.code, value.get("error", "http_error"), value.get("message", ""), value.get("required_scope"), int(retry) if retry else None) from None
except URLError as exc:
raise APIError(0, "network_error", str(exc.reason)) from exc
return json.loads(raw) if raw else None
@staticmethod
def _traffic_query(days: Optional[int], from_date: Optional[str], to_date: Optional[str]) -> str:
query = {key: value for key, value in (("days", days), ("from", from_date), ("to", to_date)) if value is not None}
return "?" + urlencode(query) if query else ""
def organization(self) -> Mapping[str, Any]:
return self._request("GET", "/api/v1/organization")["organization"]
def invoices(self) -> list[Mapping[str, Any]]:
return self._request("GET", "/api/v1/invoices")["invoices"]
def services(self) -> list[Mapping[str, Any]]:
return self._request("GET", "/api/v1/services")["services"]
def service(self, service_id: str) -> Mapping[str, Any]:
return self._request("GET", f"/api/v1/services/{quote(service_id, safe='')}" )["service"]
def service_metrics(self, service_id: str, *, target: Optional[str] = None, range: Optional[str] = None) -> Mapping[str, Any]:
query = urlencode({k: v for k, v in (("target", target), ("range", range)) if v is not None})
return self._request("GET", f"/api/v1/services/{quote(service_id, safe='')}/metrics" + ("?" + query if query else ""))
def power_service(self, service_id: str, action: str) -> Mapping[str, Any]:
return self._request("POST", f"/api/v1/services/{quote(service_id, safe='')}/power", body={"action": action})
def traffic(self, *, days: Optional[int] = None, from_date: Optional[str] = None, to_date: Optional[str] = None) -> Mapping[str, Any]:
return self._request("GET", "/api/v1/traffic" + self._traffic_query(days, from_date, to_date))
def tunnels(self) -> list[Mapping[str, Any]]:
return self._request("GET", "/api/v1/tunnels")["tunnels"]
def create_tunnel(self, peer_endpoint: str, location: str, *, label: Optional[str] = None, announce: bool = False) -> Mapping[str, Any]:
body = {"peer_endpoint": peer_endpoint, "location": location, "announce": announce}
if label is not None: body["label"] = label
return self._request("POST", "/api/v1/tunnels", body=body)["tunnel"]
def delete_tunnel(self, tunnel_id: str) -> None:
self._request("DELETE", f"/api/v1/tunnels/{quote(tunnel_id, safe='')}")
def tunnel_traffic(self, tunnel_id: str, *, days: Optional[int] = None, from_date: Optional[str] = None, to_date: Optional[str] = None) -> Mapping[str, Any]:
return self._request("GET", f"/api/v1/tunnels/{quote(tunnel_id, safe='')}/traffic" + self._traffic_query(days, from_date, to_date))
def geofeeds(self, organization_id: str) -> Mapping[str, Any]:
return self._request("GET", "/api/v1/geofeeds", organization_id=organization_id)
def create_geofeed(self, organization_id: str, name: str) -> Mapping[str, Any]:
return self._request("POST", "/api/v1/geofeeds", organization_id=organization_id, body={"name": name})
def geofeed(self, organization_id: str, geofeed_id: str) -> Mapping[str, Any]:
return self._request("GET", f"/api/v1/geofeeds/{quote(geofeed_id, safe='')}", organization_id=organization_id)["geofeed"]
def rename_geofeed(self, organization_id: str, geofeed_id: str, name: str) -> None:
self._request("PATCH", f"/api/v1/geofeeds/{quote(geofeed_id, safe='')}", organization_id=organization_id, body={"name": name})
def delete_geofeed(self, organization_id: str, geofeed_id: str) -> None:
self._request("DELETE", f"/api/v1/geofeeds/{quote(geofeed_id, safe='')}", organization_id=organization_id)
def replace_geofeed_records(self, organization_id: str, geofeed_id: str, records: Iterable[Mapping[str, Any]]) -> None:
self._request("PUT", f"/api/v1/geofeeds/{quote(geofeed_id, safe='')}/records", organization_id=organization_id, body={"records": list(records)})
def rotate_geofeed_url(self, organization_id: str, geofeed_id: str) -> Mapping[str, Any]:
return self._request("POST", f"/api/v1/geofeeds/{quote(geofeed_id, safe='')}/rotate", organization_id=organization_id)
+28
View File
@@ -0,0 +1,28 @@
import io
import json
import unittest
from unittest.mock import patch
from as213905 import AS213905, APIError
class Response(io.BytesIO):
def __enter__(self): return self
def __exit__(self, *args): return False
class ClientTests(unittest.TestCase):
def test_organization_sets_bearer_header(self):
def open_(request, timeout):
self.assertEqual(request.get_header("Authorization"), "Bearer secret")
return Response(json.dumps({"organization": {"asn": 213905}}).encode())
with patch("as213905.client.urlopen", open_):
self.assertEqual(AS213905("secret").organization()["asn"], 213905)
def test_traffic_query(self):
client = AS213905("secret")
self.assertEqual(client._traffic_query(7, None, None), "?days=7")
if __name__ == "__main__":
unittest.main()