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