shrtnr 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- shrtnr/__init__.py +78 -0
- shrtnr/_base_client.py +76 -0
- shrtnr/async_client.py +260 -0
- shrtnr/client.py +248 -0
- shrtnr/errors.py +31 -0
- shrtnr/models.py +477 -0
- shrtnr/py.typed +0 -0
- shrtnr-0.1.0.dist-info/METADATA +372 -0
- shrtnr-0.1.0.dist-info/RECORD +11 -0
- shrtnr-0.1.0.dist-info/WHEEL +4 -0
- shrtnr-0.1.0.dist-info/licenses/LICENSE +191 -0
shrtnr/__init__.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Copyright 2026 Oddbit (https://oddbit.id)
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Python SDK for the shrtnr URL shortener API.
|
|
5
|
+
|
|
6
|
+
Exposes a synchronous :class:`Shrtnr` and asynchronous :class:`AsyncShrtnr`
|
|
7
|
+
client with identical method surfaces. See README.md for usage.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from importlib.metadata import PackageNotFoundError
|
|
11
|
+
from importlib.metadata import version as _pkg_version
|
|
12
|
+
|
|
13
|
+
from .async_client import AsyncShrtnr
|
|
14
|
+
from .client import Shrtnr
|
|
15
|
+
from .errors import ShrtnrError
|
|
16
|
+
from .models import (
|
|
17
|
+
Bundle,
|
|
18
|
+
BundleAccent,
|
|
19
|
+
BundleStats,
|
|
20
|
+
BundleStatsPerLink,
|
|
21
|
+
BundleTopCountry,
|
|
22
|
+
BundleTopLink,
|
|
23
|
+
BundleTopPerformer,
|
|
24
|
+
BundleWithSummary,
|
|
25
|
+
ClickStats,
|
|
26
|
+
CreateBundleOptions,
|
|
27
|
+
CreateLinkOptions,
|
|
28
|
+
DateCount,
|
|
29
|
+
HealthStatus,
|
|
30
|
+
Link,
|
|
31
|
+
NameCount,
|
|
32
|
+
Slug,
|
|
33
|
+
SlugCount,
|
|
34
|
+
TimelineBucket,
|
|
35
|
+
TimelineData,
|
|
36
|
+
TimelineRange,
|
|
37
|
+
TimelineSummary,
|
|
38
|
+
UpdateBundleOptions,
|
|
39
|
+
UpdateLinkOptions,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Derive __version__ from installed package metadata so pyproject.toml is
|
|
43
|
+
# the single source of truth. Falls back when running from a source tree
|
|
44
|
+
# that has not been installed (e.g. scratch checkouts).
|
|
45
|
+
try:
|
|
46
|
+
__version__ = _pkg_version("shrtnr")
|
|
47
|
+
except PackageNotFoundError:
|
|
48
|
+
__version__ = "0.0.0+unknown"
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"AsyncShrtnr",
|
|
52
|
+
"Bundle",
|
|
53
|
+
"BundleAccent",
|
|
54
|
+
"BundleStats",
|
|
55
|
+
"BundleStatsPerLink",
|
|
56
|
+
"BundleTopCountry",
|
|
57
|
+
"BundleTopLink",
|
|
58
|
+
"BundleTopPerformer",
|
|
59
|
+
"BundleWithSummary",
|
|
60
|
+
"ClickStats",
|
|
61
|
+
"CreateBundleOptions",
|
|
62
|
+
"CreateLinkOptions",
|
|
63
|
+
"DateCount",
|
|
64
|
+
"HealthStatus",
|
|
65
|
+
"Link",
|
|
66
|
+
"NameCount",
|
|
67
|
+
"Shrtnr",
|
|
68
|
+
"ShrtnrError",
|
|
69
|
+
"Slug",
|
|
70
|
+
"SlugCount",
|
|
71
|
+
"TimelineBucket",
|
|
72
|
+
"TimelineData",
|
|
73
|
+
"TimelineRange",
|
|
74
|
+
"TimelineSummary",
|
|
75
|
+
"UpdateBundleOptions",
|
|
76
|
+
"UpdateLinkOptions",
|
|
77
|
+
"__version__",
|
|
78
|
+
]
|
shrtnr/_base_client.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Copyright 2026 Oddbit (https://oddbit.id)
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Shared HTTP plumbing for the sync and async shrtnr clients.
|
|
5
|
+
|
|
6
|
+
Both clients call the same API, speak the same auth, parse the same
|
|
7
|
+
responses, and raise the same errors. Everything in this module is the part
|
|
8
|
+
that would be duplicated between :class:`Shrtnr` and :class:`AsyncShrtnr` if
|
|
9
|
+
we wrote them from scratch twice.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.parse import quote
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from .errors import ShrtnrError
|
|
20
|
+
|
|
21
|
+
DEFAULT_TIMEOUT = 30.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_headers(api_key: str, *, with_content_type: bool = False) -> dict[str, str]:
|
|
25
|
+
"""Build the request headers.
|
|
26
|
+
|
|
27
|
+
``X-Client: sdk`` is set so server-side telemetry can distinguish SDK
|
|
28
|
+
traffic from dashboard and raw API traffic.
|
|
29
|
+
"""
|
|
30
|
+
headers = {
|
|
31
|
+
"Authorization": f"Bearer {api_key}",
|
|
32
|
+
"X-Client": "sdk",
|
|
33
|
+
}
|
|
34
|
+
if with_content_type:
|
|
35
|
+
headers["Content-Type"] = "application/json"
|
|
36
|
+
return headers
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_url(base_url: str, path: str) -> str:
|
|
40
|
+
return f"{base_url.rstrip('/')}{path}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def url_quote(value: str) -> str:
|
|
44
|
+
"""Percent-encode a path segment.
|
|
45
|
+
|
|
46
|
+
Safe-list is empty: we want ``/``, ``:``, ``?``, etc. all encoded when
|
|
47
|
+
they appear inside a slug or owner identifier.
|
|
48
|
+
"""
|
|
49
|
+
return quote(value, safe="")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def handle_response(response: httpx.Response) -> Any:
|
|
53
|
+
"""Parse a JSON response or raise ShrtnrError on failure.
|
|
54
|
+
|
|
55
|
+
``204 No Content`` responses are never produced by the shrtnr API, but
|
|
56
|
+
tolerate them gracefully just in case.
|
|
57
|
+
"""
|
|
58
|
+
if not response.is_success:
|
|
59
|
+
try:
|
|
60
|
+
body: Any = response.json()
|
|
61
|
+
except Exception:
|
|
62
|
+
body = None
|
|
63
|
+
raise ShrtnrError(response.status_code, body)
|
|
64
|
+
if response.status_code == 204 or not response.content:
|
|
65
|
+
return None
|
|
66
|
+
return response.json()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def handle_text_response(response: httpx.Response) -> str:
|
|
70
|
+
if not response.is_success:
|
|
71
|
+
try:
|
|
72
|
+
body: Any = response.json()
|
|
73
|
+
except Exception:
|
|
74
|
+
body = None
|
|
75
|
+
raise ShrtnrError(response.status_code, body)
|
|
76
|
+
return response.text
|
shrtnr/async_client.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# Copyright 2026 Oddbit (https://oddbit.id)
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Asynchronous client for the shrtnr API."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from ._base_client import (
|
|
13
|
+
DEFAULT_TIMEOUT,
|
|
14
|
+
build_headers,
|
|
15
|
+
build_url,
|
|
16
|
+
handle_response,
|
|
17
|
+
handle_text_response,
|
|
18
|
+
url_quote,
|
|
19
|
+
)
|
|
20
|
+
from .models import (
|
|
21
|
+
Bundle,
|
|
22
|
+
BundleStats,
|
|
23
|
+
BundleWithSummary,
|
|
24
|
+
ClickStats,
|
|
25
|
+
CreateBundleOptions,
|
|
26
|
+
CreateLinkOptions,
|
|
27
|
+
HealthStatus,
|
|
28
|
+
Link,
|
|
29
|
+
Slug,
|
|
30
|
+
TimelineRange,
|
|
31
|
+
UpdateBundleOptions,
|
|
32
|
+
UpdateLinkOptions,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AsyncShrtnr:
|
|
37
|
+
"""Asynchronous shrtnr API client built on ``httpx.AsyncClient``.
|
|
38
|
+
|
|
39
|
+
Use as an async context manager::
|
|
40
|
+
|
|
41
|
+
async with AsyncShrtnr("https://s.example.com", api_key="sk_...") as client:
|
|
42
|
+
link = await client.create_link(CreateLinkOptions(url="https://example.com"))
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
base_url: str,
|
|
48
|
+
*,
|
|
49
|
+
api_key: str,
|
|
50
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
51
|
+
client: httpx.AsyncClient | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
self._base_url = base_url
|
|
54
|
+
self._api_key = api_key
|
|
55
|
+
self._client = client if client is not None else httpx.AsyncClient(timeout=timeout)
|
|
56
|
+
self._owns_client = client is None
|
|
57
|
+
|
|
58
|
+
# ---- context manager ----
|
|
59
|
+
|
|
60
|
+
async def __aenter__(self) -> AsyncShrtnr:
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
async def __aexit__(
|
|
64
|
+
self,
|
|
65
|
+
exc_type: type[BaseException] | None,
|
|
66
|
+
exc: BaseException | None,
|
|
67
|
+
tb: TracebackType | None,
|
|
68
|
+
) -> None:
|
|
69
|
+
await self.aclose()
|
|
70
|
+
|
|
71
|
+
async def aclose(self) -> None:
|
|
72
|
+
if self._owns_client:
|
|
73
|
+
await self._client.aclose()
|
|
74
|
+
|
|
75
|
+
# ---- internal ----
|
|
76
|
+
|
|
77
|
+
def _url(self, path: str) -> str:
|
|
78
|
+
return build_url(self._base_url, path)
|
|
79
|
+
|
|
80
|
+
async def _get(self, path: str) -> object:
|
|
81
|
+
return handle_response(
|
|
82
|
+
await self._client.get(self._url(path), headers=build_headers(self._api_key)),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
async def _get_text(self, path: str) -> str:
|
|
86
|
+
return handle_text_response(
|
|
87
|
+
await self._client.get(self._url(path), headers=build_headers(self._api_key)),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
async def _post(self, path: str, json: object | None = None) -> object:
|
|
91
|
+
if json is None:
|
|
92
|
+
return handle_response(
|
|
93
|
+
await self._client.post(self._url(path), headers=build_headers(self._api_key)),
|
|
94
|
+
)
|
|
95
|
+
return handle_response(
|
|
96
|
+
await self._client.post(
|
|
97
|
+
self._url(path),
|
|
98
|
+
headers=build_headers(self._api_key, with_content_type=True),
|
|
99
|
+
json=json,
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
async def _put(self, path: str, json: object) -> object:
|
|
104
|
+
return handle_response(
|
|
105
|
+
await self._client.put(
|
|
106
|
+
self._url(path),
|
|
107
|
+
headers=build_headers(self._api_key, with_content_type=True),
|
|
108
|
+
json=json,
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
async def _delete(self, path: str) -> object:
|
|
113
|
+
return handle_response(
|
|
114
|
+
await self._client.delete(self._url(path), headers=build_headers(self._api_key)),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# ---- health ----
|
|
118
|
+
|
|
119
|
+
async def health(self) -> HealthStatus:
|
|
120
|
+
return HealthStatus.from_json(_as_dict(await self._get("/_/health")))
|
|
121
|
+
|
|
122
|
+
# ---- links ----
|
|
123
|
+
|
|
124
|
+
async def create_link(self, options: CreateLinkOptions) -> Link:
|
|
125
|
+
return Link.from_json(_as_dict(await self._post("/_/api/links", options.to_json())))
|
|
126
|
+
|
|
127
|
+
async def list_links(self) -> list[Link]:
|
|
128
|
+
return [Link.from_json(x) for x in _as_list(await self._get("/_/api/links"))]
|
|
129
|
+
|
|
130
|
+
async def get_link(self, link_id: int) -> Link:
|
|
131
|
+
return Link.from_json(_as_dict(await self._get(f"/_/api/links/{link_id}")))
|
|
132
|
+
|
|
133
|
+
async def update_link(self, link_id: int, options: UpdateLinkOptions) -> Link:
|
|
134
|
+
return Link.from_json(
|
|
135
|
+
_as_dict(await self._put(f"/_/api/links/{link_id}", options.to_json())),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
async def disable_link(self, link_id: int) -> Link:
|
|
139
|
+
return Link.from_json(_as_dict(await self._post(f"/_/api/links/{link_id}/disable")))
|
|
140
|
+
|
|
141
|
+
async def enable_link(self, link_id: int) -> Link:
|
|
142
|
+
return Link.from_json(_as_dict(await self._post(f"/_/api/links/{link_id}/enable")))
|
|
143
|
+
|
|
144
|
+
async def delete_link(self, link_id: int) -> bool:
|
|
145
|
+
result = _as_dict(await self._delete(f"/_/api/links/{link_id}"))
|
|
146
|
+
return bool(result.get("deleted", False))
|
|
147
|
+
|
|
148
|
+
async def list_links_by_owner(self, owner: str) -> list[Link]:
|
|
149
|
+
path = f"/_/api/links?owner={url_quote(owner)}"
|
|
150
|
+
return [Link.from_json(x) for x in _as_list(await self._get(path))]
|
|
151
|
+
|
|
152
|
+
# ---- slugs ----
|
|
153
|
+
|
|
154
|
+
async def add_custom_slug(self, link_id: int, slug: str) -> Slug:
|
|
155
|
+
return Slug.from_json(
|
|
156
|
+
_as_dict(await self._post(f"/_/api/links/{link_id}/slugs", {"slug": slug})),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
async def disable_slug(self, link_id: int, slug: str) -> Slug:
|
|
160
|
+
path = f"/_/api/links/{link_id}/slugs/{url_quote(slug)}/disable"
|
|
161
|
+
return Slug.from_json(_as_dict(await self._post(path)))
|
|
162
|
+
|
|
163
|
+
async def enable_slug(self, link_id: int, slug: str) -> Slug:
|
|
164
|
+
path = f"/_/api/links/{link_id}/slugs/{url_quote(slug)}/enable"
|
|
165
|
+
return Slug.from_json(_as_dict(await self._post(path)))
|
|
166
|
+
|
|
167
|
+
async def remove_slug(self, link_id: int, slug: str) -> bool:
|
|
168
|
+
path = f"/_/api/links/{link_id}/slugs/{url_quote(slug)}"
|
|
169
|
+
result = _as_dict(await self._delete(path))
|
|
170
|
+
return bool(result.get("removed", False))
|
|
171
|
+
|
|
172
|
+
async def get_link_by_slug(self, slug: str) -> Link:
|
|
173
|
+
return Link.from_json(_as_dict(await self._get(f"/_/api/slugs/{url_quote(slug)}")))
|
|
174
|
+
|
|
175
|
+
# ---- analytics + qr ----
|
|
176
|
+
|
|
177
|
+
async def get_link_analytics(self, link_id: int) -> ClickStats:
|
|
178
|
+
return ClickStats.from_json(
|
|
179
|
+
_as_dict(await self._get(f"/_/api/links/{link_id}/analytics")),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
async def get_link_qr(self, link_id: int, *, slug: str | None = None) -> str:
|
|
183
|
+
suffix = f"?slug={url_quote(slug)}" if slug else ""
|
|
184
|
+
return await self._get_text(f"/_/api/links/{link_id}/qr{suffix}")
|
|
185
|
+
|
|
186
|
+
# ---- bundles ----
|
|
187
|
+
|
|
188
|
+
async def create_bundle(self, options: CreateBundleOptions) -> Bundle:
|
|
189
|
+
return Bundle.from_json(_as_dict(await self._post("/_/api/bundles", options.to_json())))
|
|
190
|
+
|
|
191
|
+
async def list_bundles(self, *, archived: bool | None = None) -> list[BundleWithSummary]:
|
|
192
|
+
path = "/_/api/bundles"
|
|
193
|
+
if archived is True:
|
|
194
|
+
path += "?archived=all"
|
|
195
|
+
elif archived is False:
|
|
196
|
+
path += "?archived=false"
|
|
197
|
+
return [BundleWithSummary.from_json(x) for x in _as_list(await self._get(path))]
|
|
198
|
+
|
|
199
|
+
async def get_bundle(self, bundle_id: int) -> Bundle:
|
|
200
|
+
return Bundle.from_json(_as_dict(await self._get(f"/_/api/bundles/{bundle_id}")))
|
|
201
|
+
|
|
202
|
+
async def update_bundle(self, bundle_id: int, options: UpdateBundleOptions) -> Bundle:
|
|
203
|
+
return Bundle.from_json(
|
|
204
|
+
_as_dict(await self._put(f"/_/api/bundles/{bundle_id}", options.to_json())),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
async def delete_bundle(self, bundle_id: int) -> bool:
|
|
208
|
+
result = _as_dict(await self._delete(f"/_/api/bundles/{bundle_id}"))
|
|
209
|
+
return bool(result.get("deleted", False))
|
|
210
|
+
|
|
211
|
+
async def archive_bundle(self, bundle_id: int) -> Bundle:
|
|
212
|
+
return Bundle.from_json(_as_dict(await self._post(f"/_/api/bundles/{bundle_id}/archive")))
|
|
213
|
+
|
|
214
|
+
async def unarchive_bundle(self, bundle_id: int) -> Bundle:
|
|
215
|
+
return Bundle.from_json(
|
|
216
|
+
_as_dict(await self._post(f"/_/api/bundles/{bundle_id}/unarchive")),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
async def get_bundle_analytics(
|
|
220
|
+
self,
|
|
221
|
+
bundle_id: int,
|
|
222
|
+
*,
|
|
223
|
+
range: TimelineRange = "30d",
|
|
224
|
+
) -> BundleStats:
|
|
225
|
+
path = f"/_/api/bundles/{bundle_id}/analytics?range={range}"
|
|
226
|
+
return BundleStats.from_json(_as_dict(await self._get(path)))
|
|
227
|
+
|
|
228
|
+
async def list_bundle_links(self, bundle_id: int) -> list[Link]:
|
|
229
|
+
return [
|
|
230
|
+
Link.from_json(x)
|
|
231
|
+
for x in _as_list(await self._get(f"/_/api/bundles/{bundle_id}/links"))
|
|
232
|
+
]
|
|
233
|
+
|
|
234
|
+
async def add_link_to_bundle(self, bundle_id: int, link_id: int) -> bool:
|
|
235
|
+
result = _as_dict(
|
|
236
|
+
await self._post(f"/_/api/bundles/{bundle_id}/links", {"link_id": link_id}),
|
|
237
|
+
)
|
|
238
|
+
return bool(result.get("added", False))
|
|
239
|
+
|
|
240
|
+
async def remove_link_from_bundle(self, bundle_id: int, link_id: int) -> bool:
|
|
241
|
+
result = _as_dict(await self._delete(f"/_/api/bundles/{bundle_id}/links/{link_id}"))
|
|
242
|
+
return bool(result.get("removed", False))
|
|
243
|
+
|
|
244
|
+
async def list_bundles_for_link(self, link_id: int) -> list[Bundle]:
|
|
245
|
+
return [
|
|
246
|
+
Bundle.from_json(x)
|
|
247
|
+
for x in _as_list(await self._get(f"/_/api/links/{link_id}/bundles"))
|
|
248
|
+
]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _as_dict(value: object) -> dict[str, object]:
|
|
252
|
+
if not isinstance(value, dict):
|
|
253
|
+
raise TypeError(f"expected object from API, got {type(value).__name__}")
|
|
254
|
+
return value
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _as_list(value: object) -> list[dict[str, object]]:
|
|
258
|
+
if not isinstance(value, list):
|
|
259
|
+
raise TypeError(f"expected array from API, got {type(value).__name__}")
|
|
260
|
+
return value
|
shrtnr/client.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# Copyright 2026 Oddbit (https://oddbit.id)
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Synchronous client for the shrtnr API."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from ._base_client import (
|
|
13
|
+
DEFAULT_TIMEOUT,
|
|
14
|
+
build_headers,
|
|
15
|
+
build_url,
|
|
16
|
+
handle_response,
|
|
17
|
+
handle_text_response,
|
|
18
|
+
url_quote,
|
|
19
|
+
)
|
|
20
|
+
from .models import (
|
|
21
|
+
Bundle,
|
|
22
|
+
BundleStats,
|
|
23
|
+
BundleWithSummary,
|
|
24
|
+
ClickStats,
|
|
25
|
+
CreateBundleOptions,
|
|
26
|
+
CreateLinkOptions,
|
|
27
|
+
HealthStatus,
|
|
28
|
+
Link,
|
|
29
|
+
Slug,
|
|
30
|
+
TimelineRange,
|
|
31
|
+
UpdateBundleOptions,
|
|
32
|
+
UpdateLinkOptions,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Shrtnr:
|
|
37
|
+
"""Synchronous shrtnr API client built on ``httpx.Client``.
|
|
38
|
+
|
|
39
|
+
Construct with the shrtnr deployment's base URL and an API key minted
|
|
40
|
+
from the admin dashboard. Use as a context manager to ensure the
|
|
41
|
+
underlying HTTP connection pool is closed deterministically::
|
|
42
|
+
|
|
43
|
+
with Shrtnr("https://s.example.com", api_key="sk_...") as client:
|
|
44
|
+
link = client.create_link(CreateLinkOptions(url="https://example.com"))
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
base_url: str,
|
|
50
|
+
*,
|
|
51
|
+
api_key: str,
|
|
52
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
53
|
+
client: httpx.Client | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
self._base_url = base_url
|
|
56
|
+
self._api_key = api_key
|
|
57
|
+
self._client = client if client is not None else httpx.Client(timeout=timeout)
|
|
58
|
+
self._owns_client = client is None
|
|
59
|
+
|
|
60
|
+
# ---- context manager ----
|
|
61
|
+
|
|
62
|
+
def __enter__(self) -> Shrtnr:
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
def __exit__(
|
|
66
|
+
self,
|
|
67
|
+
exc_type: type[BaseException] | None,
|
|
68
|
+
exc: BaseException | None,
|
|
69
|
+
tb: TracebackType | None,
|
|
70
|
+
) -> None:
|
|
71
|
+
self.close()
|
|
72
|
+
|
|
73
|
+
def close(self) -> None:
|
|
74
|
+
if self._owns_client:
|
|
75
|
+
self._client.close()
|
|
76
|
+
|
|
77
|
+
# ---- internal ----
|
|
78
|
+
|
|
79
|
+
def _url(self, path: str) -> str:
|
|
80
|
+
return build_url(self._base_url, path)
|
|
81
|
+
|
|
82
|
+
def _get(self, path: str) -> object:
|
|
83
|
+
return handle_response(
|
|
84
|
+
self._client.get(self._url(path), headers=build_headers(self._api_key)),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def _get_text(self, path: str) -> str:
|
|
88
|
+
return handle_text_response(
|
|
89
|
+
self._client.get(self._url(path), headers=build_headers(self._api_key)),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def _post(self, path: str, json: object | None = None) -> object:
|
|
93
|
+
if json is None:
|
|
94
|
+
return handle_response(
|
|
95
|
+
self._client.post(self._url(path), headers=build_headers(self._api_key)),
|
|
96
|
+
)
|
|
97
|
+
return handle_response(
|
|
98
|
+
self._client.post(
|
|
99
|
+
self._url(path),
|
|
100
|
+
headers=build_headers(self._api_key, with_content_type=True),
|
|
101
|
+
json=json,
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def _put(self, path: str, json: object) -> object:
|
|
106
|
+
return handle_response(
|
|
107
|
+
self._client.put(
|
|
108
|
+
self._url(path),
|
|
109
|
+
headers=build_headers(self._api_key, with_content_type=True),
|
|
110
|
+
json=json,
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def _delete(self, path: str) -> object:
|
|
115
|
+
return handle_response(
|
|
116
|
+
self._client.delete(self._url(path), headers=build_headers(self._api_key)),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# ---- health ----
|
|
120
|
+
|
|
121
|
+
def health(self) -> HealthStatus:
|
|
122
|
+
return HealthStatus.from_json(_as_dict(self._get("/_/health")))
|
|
123
|
+
|
|
124
|
+
# ---- links ----
|
|
125
|
+
|
|
126
|
+
def create_link(self, options: CreateLinkOptions) -> Link:
|
|
127
|
+
return Link.from_json(_as_dict(self._post("/_/api/links", options.to_json())))
|
|
128
|
+
|
|
129
|
+
def list_links(self) -> list[Link]:
|
|
130
|
+
return [Link.from_json(x) for x in _as_list(self._get("/_/api/links"))]
|
|
131
|
+
|
|
132
|
+
def get_link(self, link_id: int) -> Link:
|
|
133
|
+
return Link.from_json(_as_dict(self._get(f"/_/api/links/{link_id}")))
|
|
134
|
+
|
|
135
|
+
def update_link(self, link_id: int, options: UpdateLinkOptions) -> Link:
|
|
136
|
+
return Link.from_json(_as_dict(self._put(f"/_/api/links/{link_id}", options.to_json())))
|
|
137
|
+
|
|
138
|
+
def disable_link(self, link_id: int) -> Link:
|
|
139
|
+
return Link.from_json(_as_dict(self._post(f"/_/api/links/{link_id}/disable")))
|
|
140
|
+
|
|
141
|
+
def enable_link(self, link_id: int) -> Link:
|
|
142
|
+
return Link.from_json(_as_dict(self._post(f"/_/api/links/{link_id}/enable")))
|
|
143
|
+
|
|
144
|
+
def delete_link(self, link_id: int) -> bool:
|
|
145
|
+
result = _as_dict(self._delete(f"/_/api/links/{link_id}"))
|
|
146
|
+
return bool(result.get("deleted", False))
|
|
147
|
+
|
|
148
|
+
def list_links_by_owner(self, owner: str) -> list[Link]:
|
|
149
|
+
path = f"/_/api/links?owner={url_quote(owner)}"
|
|
150
|
+
return [Link.from_json(x) for x in _as_list(self._get(path))]
|
|
151
|
+
|
|
152
|
+
# ---- slugs ----
|
|
153
|
+
|
|
154
|
+
def add_custom_slug(self, link_id: int, slug: str) -> Slug:
|
|
155
|
+
return Slug.from_json(_as_dict(self._post(f"/_/api/links/{link_id}/slugs", {"slug": slug})))
|
|
156
|
+
|
|
157
|
+
def disable_slug(self, link_id: int, slug: str) -> Slug:
|
|
158
|
+
path = f"/_/api/links/{link_id}/slugs/{url_quote(slug)}/disable"
|
|
159
|
+
return Slug.from_json(_as_dict(self._post(path)))
|
|
160
|
+
|
|
161
|
+
def enable_slug(self, link_id: int, slug: str) -> Slug:
|
|
162
|
+
path = f"/_/api/links/{link_id}/slugs/{url_quote(slug)}/enable"
|
|
163
|
+
return Slug.from_json(_as_dict(self._post(path)))
|
|
164
|
+
|
|
165
|
+
def remove_slug(self, link_id: int, slug: str) -> bool:
|
|
166
|
+
path = f"/_/api/links/{link_id}/slugs/{url_quote(slug)}"
|
|
167
|
+
result = _as_dict(self._delete(path))
|
|
168
|
+
return bool(result.get("removed", False))
|
|
169
|
+
|
|
170
|
+
def get_link_by_slug(self, slug: str) -> Link:
|
|
171
|
+
return Link.from_json(_as_dict(self._get(f"/_/api/slugs/{url_quote(slug)}")))
|
|
172
|
+
|
|
173
|
+
# ---- analytics + qr ----
|
|
174
|
+
|
|
175
|
+
def get_link_analytics(self, link_id: int) -> ClickStats:
|
|
176
|
+
return ClickStats.from_json(_as_dict(self._get(f"/_/api/links/{link_id}/analytics")))
|
|
177
|
+
|
|
178
|
+
def get_link_qr(self, link_id: int, *, slug: str | None = None) -> str:
|
|
179
|
+
suffix = f"?slug={url_quote(slug)}" if slug else ""
|
|
180
|
+
return self._get_text(f"/_/api/links/{link_id}/qr{suffix}")
|
|
181
|
+
|
|
182
|
+
# ---- bundles ----
|
|
183
|
+
|
|
184
|
+
def create_bundle(self, options: CreateBundleOptions) -> Bundle:
|
|
185
|
+
return Bundle.from_json(_as_dict(self._post("/_/api/bundles", options.to_json())))
|
|
186
|
+
|
|
187
|
+
def list_bundles(self, *, archived: bool | None = None) -> list[BundleWithSummary]:
|
|
188
|
+
path = "/_/api/bundles"
|
|
189
|
+
if archived is True:
|
|
190
|
+
path += "?archived=all"
|
|
191
|
+
elif archived is False:
|
|
192
|
+
path += "?archived=false"
|
|
193
|
+
return [BundleWithSummary.from_json(x) for x in _as_list(self._get(path))]
|
|
194
|
+
|
|
195
|
+
def get_bundle(self, bundle_id: int) -> Bundle:
|
|
196
|
+
return Bundle.from_json(_as_dict(self._get(f"/_/api/bundles/{bundle_id}")))
|
|
197
|
+
|
|
198
|
+
def update_bundle(self, bundle_id: int, options: UpdateBundleOptions) -> Bundle:
|
|
199
|
+
return Bundle.from_json(
|
|
200
|
+
_as_dict(self._put(f"/_/api/bundles/{bundle_id}", options.to_json())),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def delete_bundle(self, bundle_id: int) -> bool:
|
|
204
|
+
result = _as_dict(self._delete(f"/_/api/bundles/{bundle_id}"))
|
|
205
|
+
return bool(result.get("deleted", False))
|
|
206
|
+
|
|
207
|
+
def archive_bundle(self, bundle_id: int) -> Bundle:
|
|
208
|
+
return Bundle.from_json(_as_dict(self._post(f"/_/api/bundles/{bundle_id}/archive")))
|
|
209
|
+
|
|
210
|
+
def unarchive_bundle(self, bundle_id: int) -> Bundle:
|
|
211
|
+
return Bundle.from_json(_as_dict(self._post(f"/_/api/bundles/{bundle_id}/unarchive")))
|
|
212
|
+
|
|
213
|
+
def get_bundle_analytics(
|
|
214
|
+
self,
|
|
215
|
+
bundle_id: int,
|
|
216
|
+
*,
|
|
217
|
+
range: TimelineRange = "30d",
|
|
218
|
+
) -> BundleStats:
|
|
219
|
+
path = f"/_/api/bundles/{bundle_id}/analytics?range={range}"
|
|
220
|
+
return BundleStats.from_json(_as_dict(self._get(path)))
|
|
221
|
+
|
|
222
|
+
def list_bundle_links(self, bundle_id: int) -> list[Link]:
|
|
223
|
+
return [Link.from_json(x) for x in _as_list(self._get(f"/_/api/bundles/{bundle_id}/links"))]
|
|
224
|
+
|
|
225
|
+
def add_link_to_bundle(self, bundle_id: int, link_id: int) -> bool:
|
|
226
|
+
result = _as_dict(
|
|
227
|
+
self._post(f"/_/api/bundles/{bundle_id}/links", {"link_id": link_id}),
|
|
228
|
+
)
|
|
229
|
+
return bool(result.get("added", False))
|
|
230
|
+
|
|
231
|
+
def remove_link_from_bundle(self, bundle_id: int, link_id: int) -> bool:
|
|
232
|
+
result = _as_dict(self._delete(f"/_/api/bundles/{bundle_id}/links/{link_id}"))
|
|
233
|
+
return bool(result.get("removed", False))
|
|
234
|
+
|
|
235
|
+
def list_bundles_for_link(self, link_id: int) -> list[Bundle]:
|
|
236
|
+
return [Bundle.from_json(x) for x in _as_list(self._get(f"/_/api/links/{link_id}/bundles"))]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _as_dict(value: object) -> dict[str, object]:
|
|
240
|
+
if not isinstance(value, dict):
|
|
241
|
+
raise TypeError(f"expected object from API, got {type(value).__name__}")
|
|
242
|
+
return value
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _as_list(value: object) -> list[dict[str, object]]:
|
|
246
|
+
if not isinstance(value, list):
|
|
247
|
+
raise TypeError(f"expected array from API, got {type(value).__name__}")
|
|
248
|
+
return value
|
shrtnr/errors.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Copyright 2026 Oddbit (https://oddbit.id)
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Error surface for the shrtnr SDK.
|
|
5
|
+
|
|
6
|
+
Mirrors :class:`ShrtnrError` in the TypeScript SDK: every non-2xx response
|
|
7
|
+
raises :class:`ShrtnrError` carrying the HTTP status and the parsed response
|
|
8
|
+
body. When the body contains an ``"error"`` key its value is used as the
|
|
9
|
+
exception message, otherwise it falls back to ``"HTTP <status>"``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ShrtnrError(Exception):
|
|
18
|
+
"""Raised when the shrtnr API returns a non-2xx response."""
|
|
19
|
+
|
|
20
|
+
status: int
|
|
21
|
+
body: Any
|
|
22
|
+
|
|
23
|
+
def __init__(self, status: int, body: Any) -> None:
|
|
24
|
+
self.status = status
|
|
25
|
+
self.body = body
|
|
26
|
+
message: str
|
|
27
|
+
if isinstance(body, dict) and isinstance(body.get("error"), str):
|
|
28
|
+
message = body["error"]
|
|
29
|
+
else:
|
|
30
|
+
message = f"HTTP {status}"
|
|
31
|
+
super().__init__(message)
|