pagekit-core 0.1.0__tar.gz

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.
@@ -0,0 +1,13 @@
1
+ node_modules/
2
+ dist/
3
+ *.tsbuildinfo
4
+ .turbo/
5
+ .env
6
+ .env.local
7
+ *.log
8
+ .DS_Store
9
+ coverage/
10
+ __pycache__/
11
+ *.pyc
12
+ *.egg-info/
13
+ .venv/
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.5
2
+ Name: pagekit-core
3
+ Version: 0.1.0
4
+ Summary: Python client for the Pagekit content API
5
+ Project-URL: Homepage, https://github.com/arovi-labs/pagekit
6
+ Project-URL: Repository, https://github.com/arovi-labs/pagekit
7
+ Project-URL: Issues, https://github.com/arovi-labs/pagekit/issues
8
+ Author: Arovi Labs
9
+ License-Expression: MIT
10
+ Keywords: blog,cms,content-api,headless,pagekit
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: httpx<1,>=0.27
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
24
+ Requires-Dist: pytest>=8; extra == 'dev'
25
+ Requires-Dist: respx>=0.22; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # pagekit
29
+
30
+ Python client for the [Pagekit](https://pagekit.app) content API.
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ pip install pagekit-core
36
+ ```
37
+
38
+ ## Quick start
39
+
40
+ ```python
41
+ from pagekit import Pagekit
42
+
43
+ client = Pagekit(api_key="pk_live_...")
44
+
45
+ # List posts
46
+ page = client.posts.list(status="published", limit=10)
47
+ for post in page.data:
48
+ print(post.title)
49
+
50
+ # Create a post
51
+ post = client.posts.create(title="Hello world", content="<p>First post</p>")
52
+
53
+ # Get by slug
54
+ post = client.posts.get_by_slug("hello-world")
55
+ ```
56
+
57
+ ## Async usage
58
+
59
+ ```python
60
+ from pagekit import AsyncPagekit
61
+
62
+ client = AsyncPagekit(api_key="pk_live_...")
63
+
64
+ posts = await client.posts.list()
65
+ ```
66
+
67
+ ## Configuration
68
+
69
+ ```python
70
+ client = Pagekit(
71
+ api_key="pk_live_...",
72
+ base_url="https://api.pagekit.app/v1", # default
73
+ timeout=30, # seconds
74
+ headers={"X-Custom": "value"}, # extra headers
75
+ )
76
+ ```
77
+
78
+ ## Error handling
79
+
80
+ ```python
81
+ from pagekit import Pagekit, PagekitError
82
+
83
+ client = Pagekit(api_key="pk_live_...")
84
+
85
+ try:
86
+ post = client.posts.get("nonexistent")
87
+ except PagekitError as e:
88
+ print(e.status) # 404
89
+ print(e.code) # "not_found"
90
+ print(e.is_auth_error) # False
91
+ print(e.is_rate_limited) # False
92
+ print(e.is_server_error) # False
93
+ ```
94
+
95
+ ## License
96
+
97
+ MIT
@@ -0,0 +1,70 @@
1
+ # pagekit
2
+
3
+ Python client for the [Pagekit](https://pagekit.app) content API.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install pagekit-core
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```python
14
+ from pagekit import Pagekit
15
+
16
+ client = Pagekit(api_key="pk_live_...")
17
+
18
+ # List posts
19
+ page = client.posts.list(status="published", limit=10)
20
+ for post in page.data:
21
+ print(post.title)
22
+
23
+ # Create a post
24
+ post = client.posts.create(title="Hello world", content="<p>First post</p>")
25
+
26
+ # Get by slug
27
+ post = client.posts.get_by_slug("hello-world")
28
+ ```
29
+
30
+ ## Async usage
31
+
32
+ ```python
33
+ from pagekit import AsyncPagekit
34
+
35
+ client = AsyncPagekit(api_key="pk_live_...")
36
+
37
+ posts = await client.posts.list()
38
+ ```
39
+
40
+ ## Configuration
41
+
42
+ ```python
43
+ client = Pagekit(
44
+ api_key="pk_live_...",
45
+ base_url="https://api.pagekit.app/v1", # default
46
+ timeout=30, # seconds
47
+ headers={"X-Custom": "value"}, # extra headers
48
+ )
49
+ ```
50
+
51
+ ## Error handling
52
+
53
+ ```python
54
+ from pagekit import Pagekit, PagekitError
55
+
56
+ client = Pagekit(api_key="pk_live_...")
57
+
58
+ try:
59
+ post = client.posts.get("nonexistent")
60
+ except PagekitError as e:
61
+ print(e.status) # 404
62
+ print(e.code) # "not_found"
63
+ print(e.is_auth_error) # False
64
+ print(e.is_rate_limited) # False
65
+ print(e.is_server_error) # False
66
+ ```
67
+
68
+ ## License
69
+
70
+ MIT
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pagekit-core"
7
+ version = "0.1.0"
8
+ description = "Python client for the Pagekit content API"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Arovi Labs" }]
13
+ dependencies = ["httpx>=0.27,<1"]
14
+ keywords = ["pagekit", "cms", "headless", "content-api", "blog"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Typing :: Typed",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/arovi-labs/pagekit"
29
+ Repository = "https://github.com/arovi-labs/pagekit"
30
+ Issues = "https://github.com/arovi-labs/pagekit/issues"
31
+
32
+ [project.optional-dependencies]
33
+ dev = ["pytest>=8", "pytest-asyncio>=0.24", "respx>=0.22"]
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/pagekit"]
37
+
38
+ [tool.pytest.ini_options]
39
+ asyncio_mode = "auto"
40
+ testpaths = ["tests"]
@@ -0,0 +1,46 @@
1
+ from .client import AsyncPagekit, Pagekit
2
+ from .errors import PagekitError
3
+ from .http import DEFAULT_BASE_URL
4
+ from .types import (
5
+ Author,
6
+ AuthorListParams,
7
+ Category,
8
+ CategoryListParams,
9
+ Media,
10
+ MediaCreateInput,
11
+ MediaListParams,
12
+ Paginated,
13
+ Pagination,
14
+ Post,
15
+ PostCreateInput,
16
+ PostListParams,
17
+ PostStatus,
18
+ PostUpdateInput,
19
+ Seo,
20
+ Tag,
21
+ TagListParams,
22
+ )
23
+
24
+ __all__ = [
25
+ "AsyncPagekit",
26
+ "DEFAULT_BASE_URL",
27
+ "Pagekit",
28
+ "PagekitError",
29
+ "Author",
30
+ "AuthorListParams",
31
+ "Category",
32
+ "CategoryListParams",
33
+ "Media",
34
+ "MediaCreateInput",
35
+ "MediaListParams",
36
+ "Paginated",
37
+ "Pagination",
38
+ "Post",
39
+ "PostCreateInput",
40
+ "PostListParams",
41
+ "PostStatus",
42
+ "PostUpdateInput",
43
+ "Seo",
44
+ "Tag",
45
+ "TagListParams",
46
+ ]
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+
5
+ from .errors import PagekitError
6
+ from .http import DEFAULT_BASE_URL, AsyncHttpClient, SyncHttpClient
7
+ from .resources.authors import AsyncAuthorsResource, SyncAuthorsResource
8
+ from .resources.categories import AsyncCategoriesResource, SyncCategoriesResource
9
+ from .resources.media import AsyncMediaResource, SyncMediaResource
10
+ from .resources.posts import AsyncPostsResource, SyncPostsResource
11
+ from .resources.tags import AsyncTagsResource, SyncTagsResource
12
+
13
+
14
+ class AsyncPagekit:
15
+ """Async Pagekit content API client."""
16
+
17
+ def __init__(
18
+ self,
19
+ *,
20
+ api_key: str,
21
+ base_url: str = DEFAULT_BASE_URL,
22
+ timeout: float = 30,
23
+ headers: dict[str, str] | None = None,
24
+ http_client: httpx.AsyncClient | None = None,
25
+ ) -> None:
26
+ self._http = AsyncHttpClient(
27
+ api_key=api_key,
28
+ base_url=base_url,
29
+ timeout=timeout,
30
+ headers=headers,
31
+ http_client=http_client,
32
+ )
33
+ self.posts = AsyncPostsResource(self._http)
34
+ self.authors = AsyncAuthorsResource(self._http)
35
+ self.categories = AsyncCategoriesResource(self._http)
36
+ self.tags = AsyncTagsResource(self._http)
37
+ self.media = AsyncMediaResource(self._http)
38
+
39
+ async def close(self) -> None:
40
+ await self._http._client.aclose()
41
+
42
+ async def __aenter__(self) -> AsyncPagekit:
43
+ return self
44
+
45
+ async def __aexit__(self, *args: object) -> None:
46
+ await self.close()
47
+
48
+
49
+ class Pagekit:
50
+ """Synchronous Pagekit content API client."""
51
+
52
+ def __init__(
53
+ self,
54
+ *,
55
+ api_key: str,
56
+ base_url: str = DEFAULT_BASE_URL,
57
+ timeout: float = 30,
58
+ headers: dict[str, str] | None = None,
59
+ http_client: httpx.Client | None = None,
60
+ ) -> None:
61
+ self._http = SyncHttpClient(
62
+ api_key=api_key,
63
+ base_url=base_url,
64
+ timeout=timeout,
65
+ headers=headers,
66
+ http_client=http_client,
67
+ )
68
+ self.posts = SyncPostsResource(self._http)
69
+ self.authors = SyncAuthorsResource(self._http)
70
+ self.categories = SyncCategoriesResource(self._http)
71
+ self.tags = SyncTagsResource(self._http)
72
+ self.media = SyncMediaResource(self._http)
73
+
74
+ def close(self) -> None:
75
+ self._http._client.close()
76
+
77
+ def __enter__(self) -> Pagekit:
78
+ return self
79
+
80
+ def __exit__(self, *args: object) -> None:
81
+ self.close()
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class PagekitError(Exception):
7
+ """Error returned by the Pagekit API."""
8
+
9
+ def __init__(
10
+ self,
11
+ message: str,
12
+ *,
13
+ status: int = 0,
14
+ code: str = "api_error",
15
+ details: Any = None,
16
+ ) -> None:
17
+ super().__init__(message)
18
+ self.status = status
19
+ self.code = code
20
+ self.details = details
21
+
22
+ @property
23
+ def is_auth_error(self) -> bool:
24
+ return self.status in (401, 403)
25
+
26
+ @property
27
+ def is_rate_limited(self) -> bool:
28
+ return self.status == 429
29
+
30
+ @property
31
+ def is_server_error(self) -> bool:
32
+ return self.status >= 500
@@ -0,0 +1,241 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Protocol
4
+
5
+ import httpx
6
+
7
+ from .errors import PagekitError
8
+
9
+ DEFAULT_BASE_URL = "https://api.pagekit.app/v1"
10
+
11
+
12
+ class HttpClient(Protocol):
13
+ async def request(
14
+ self, path: str, *, method: str = "GET", query: dict[str, Any] | None = None, body: Any = None
15
+ ) -> Any: ...
16
+
17
+
18
+ def _to_camel(key: str) -> str:
19
+ parts = key.split("_")
20
+ return parts[0] + "".join(w.capitalize() for w in parts[1:])
21
+
22
+
23
+ def _serialize_params(obj: Any) -> Any:
24
+ if hasattr(obj, "__dataclass_fields__"):
25
+ result = {}
26
+ for k, v in obj.__dataclass_fields__.items(): # type: ignore[attr-defined]
27
+ val = getattr(obj, k)
28
+ if val is not None:
29
+ result[_to_camel(k)] = _serialize_params(val)
30
+ return result
31
+ if isinstance(obj, dict):
32
+ return {k: _serialize_params(v) for k, v in obj.items() if v is not None}
33
+ if isinstance(obj, list):
34
+ return [_serialize_params(v) for v in obj]
35
+ if hasattr(obj, "value"):
36
+ return obj.value
37
+ return obj
38
+
39
+
40
+ def _build_query(params: Any) -> dict[str, str]:
41
+ if params is None:
42
+ return {}
43
+ raw = _serialize_params(params) if hasattr(params, "__dataclass_fields__") else params or {}
44
+ return {k: str(v) for k, v in raw.items() if v is not None and v != ""}
45
+
46
+
47
+ def _unwrap_type(ft: Any) -> Any:
48
+ """Unwrap Optional/Union types to get the inner dataclass type."""
49
+ import typing
50
+
51
+ origin = getattr(ft, "__origin__", None)
52
+ if origin is typing.Union:
53
+ args = [a for a in ft.__args__ if a is not type(None)]
54
+ if len(args) == 1:
55
+ return args[0]
56
+ return ft
57
+
58
+
59
+ def _deserialize(data: dict[str, Any], cls: type) -> Any:
60
+ if not isinstance(data, dict):
61
+ return data
62
+ import dataclasses
63
+
64
+ if not dataclasses.is_dataclass(cls):
65
+ return data
66
+
67
+ field_map = {f.name: f for f in dataclasses.fields(cls)}
68
+ kwargs = {}
69
+ for f in dataclasses.fields(cls):
70
+ snake = f.name
71
+ camel = _to_camel(snake)
72
+ raw = data.get(camel, data.get(snake))
73
+ if raw is None:
74
+ continue
75
+ ft = f.type
76
+ if isinstance(ft, str):
77
+ import typing
78
+
79
+ ft = typing.get_type_hints(cls).get(snake, f.type)
80
+ inner = _unwrap_type(ft)
81
+ if dataclasses.is_dataclass(inner) and isinstance(raw, dict):
82
+ kwargs[snake] = _deserialize(raw, inner)
83
+ else:
84
+ kwargs[snake] = raw
85
+ return cls(**kwargs)
86
+
87
+
88
+ def _extract_message(body: Any) -> str:
89
+ if isinstance(body, dict):
90
+ err = body.get("error")
91
+ if isinstance(err, dict) and "message" in err:
92
+ return err["message"]
93
+ if "message" in body:
94
+ return body["message"]
95
+ return "Unknown error"
96
+
97
+
98
+ def _extract_code(body: Any) -> str:
99
+ if isinstance(body, dict):
100
+ err = body.get("error")
101
+ if isinstance(err, dict) and "code" in err:
102
+ return err["code"]
103
+ if "code" in body:
104
+ return body["code"]
105
+ return "api_error"
106
+
107
+
108
+ class AsyncHttpClient:
109
+ def __init__(
110
+ self,
111
+ *,
112
+ api_key: str,
113
+ base_url: str = DEFAULT_BASE_URL,
114
+ timeout: float = 30,
115
+ headers: dict[str, str] | None = None,
116
+ http_client: httpx.AsyncClient | None = None,
117
+ ) -> None:
118
+ if not api_key:
119
+ raise PagekitError("api_key is required", code="missing_api_key")
120
+ self.api_key = api_key
121
+ self.base_url = base_url.rstrip("/")
122
+ self._default_headers = {
123
+ "Authorization": f"Bearer {api_key}",
124
+ "Accept": "application/json",
125
+ **(headers or {}),
126
+ }
127
+ self._client = http_client or httpx.AsyncClient(
128
+ timeout=httpx.Timeout(timeout),
129
+ )
130
+
131
+ async def request(
132
+ self,
133
+ path: str,
134
+ *,
135
+ method: str = "GET",
136
+ query: dict[str, Any] | None = None,
137
+ body: Any = None,
138
+ ) -> Any:
139
+ url = f"{self.base_url}{path}"
140
+ headers = {**self._default_headers}
141
+ if body is not None:
142
+ headers["Content-Type"] = "application/json"
143
+
144
+ try:
145
+ resp = await self._client.request(
146
+ method,
147
+ url,
148
+ headers=headers,
149
+ params=_build_query(query) or None,
150
+ content=__import__("json").dumps(_serialize_params(body)) if body is not None else None,
151
+ )
152
+ except httpx.TimeoutException:
153
+ raise PagekitError("Request timed out", code="timeout")
154
+ except httpx.HTTPError as exc:
155
+ raise PagekitError(f"Network error: {exc}", code="network_error")
156
+
157
+ if resp.status_code == 204:
158
+ return None
159
+
160
+ try:
161
+ data = resp.json()
162
+ except Exception:
163
+ data = None
164
+
165
+ if not resp.is_success:
166
+ raise PagekitError(
167
+ _extract_message(data),
168
+ status=resp.status_code,
169
+ code=_extract_code(data),
170
+ details=data,
171
+ )
172
+
173
+ return data
174
+
175
+
176
+ class SyncHttpClient:
177
+ def __init__(
178
+ self,
179
+ *,
180
+ api_key: str,
181
+ base_url: str = DEFAULT_BASE_URL,
182
+ timeout: float = 30,
183
+ headers: dict[str, str] | None = None,
184
+ http_client: httpx.Client | None = None,
185
+ ) -> None:
186
+ if not api_key:
187
+ raise PagekitError("api_key is required", code="missing_api_key")
188
+ self.api_key = api_key
189
+ self.base_url = base_url.rstrip("/")
190
+ self._default_headers = {
191
+ "Authorization": f"Bearer {api_key}",
192
+ "Accept": "application/json",
193
+ **(headers or {}),
194
+ }
195
+ self._client = http_client or httpx.Client(
196
+ timeout=httpx.Timeout(timeout),
197
+ )
198
+
199
+ def request(
200
+ self,
201
+ path: str,
202
+ *,
203
+ method: str = "GET",
204
+ query: dict[str, Any] | None = None,
205
+ body: Any = None,
206
+ ) -> Any:
207
+ url = f"{self.base_url}{path}"
208
+ headers = {**self._default_headers}
209
+ if body is not None:
210
+ headers["Content-Type"] = "application/json"
211
+
212
+ try:
213
+ resp = self._client.request(
214
+ method,
215
+ url,
216
+ headers=headers,
217
+ params=_build_query(query) or None,
218
+ content=__import__("json").dumps(_serialize_params(body)) if body is not None else None,
219
+ )
220
+ except httpx.TimeoutException:
221
+ raise PagekitError("Request timed out", code="timeout")
222
+ except httpx.HTTPError as exc:
223
+ raise PagekitError(f"Network error: {exc}", code="network_error")
224
+
225
+ if resp.status_code == 204:
226
+ return None
227
+
228
+ try:
229
+ data = resp.json()
230
+ except Exception:
231
+ data = None
232
+
233
+ if not resp.is_success:
234
+ raise PagekitError(
235
+ _extract_message(data),
236
+ status=resp.status_code,
237
+ code=_extract_code(data),
238
+ details=data,
239
+ )
240
+
241
+ return data
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from ..resources.authors import AsyncAuthorsResource, SyncAuthorsResource
4
+ from ..resources.categories import AsyncCategoriesResource, SyncCategoriesResource
5
+ from ..resources.media import AsyncMediaResource, SyncMediaResource
6
+ from ..resources.posts import AsyncPostsResource, SyncPostsResource
7
+ from ..resources.tags import AsyncTagsResource, SyncTagsResource
8
+
9
+ __all__ = [
10
+ "AsyncAuthorsResource",
11
+ "AsyncCategoriesResource",
12
+ "AsyncMediaResource",
13
+ "AsyncPostsResource",
14
+ "AsyncTagsResource",
15
+ "SyncAuthorsResource",
16
+ "SyncCategoriesResource",
17
+ "SyncMediaResource",
18
+ "SyncPostsResource",
19
+ "SyncTagsResource",
20
+ ]
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from urllib.parse import quote
4
+
5
+ from ..http import AsyncHttpClient, SyncHttpClient, _build_query, _deserialize
6
+ from ..types import Author, AuthorListParams, Paginated, Pagination
7
+
8
+
9
+ class AsyncAuthorsResource:
10
+ def __init__(self, http: AsyncHttpClient) -> None:
11
+ self._http = http
12
+
13
+ async def list(self, params: AuthorListParams | None = None) -> Paginated[Author]:
14
+ query = _build_query(params) if params else {}
15
+ raw = await self._http.request("/authors", query=query)
16
+ return Paginated(
17
+ data=[_deserialize(a, Author) for a in raw["data"]],
18
+ pagination=_deserialize(raw["pagination"], Pagination),
19
+ )
20
+
21
+ async def get(self, id: str) -> Author:
22
+ raw = await self._http.request(f"/authors/{quote(id, safe='')}")
23
+ return _deserialize(raw, Author)
24
+
25
+
26
+ class SyncAuthorsResource:
27
+ def __init__(self, http: SyncHttpClient) -> None:
28
+ self._http = http
29
+
30
+ def list(self, params: AuthorListParams | None = None) -> Paginated[Author]:
31
+ query = _build_query(params) if params else {}
32
+ raw = self._http.request("/authors", query=query)
33
+ return Paginated(
34
+ data=[_deserialize(a, Author) for a in raw["data"]],
35
+ pagination=_deserialize(raw["pagination"], Pagination),
36
+ )
37
+
38
+ def get(self, id: str) -> Author:
39
+ raw = self._http.request(f"/authors/{quote(id, safe='')}")
40
+ return _deserialize(raw, Author)
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from urllib.parse import quote
4
+
5
+ from ..http import AsyncHttpClient, SyncHttpClient, _build_query, _deserialize
6
+ from ..types import Category, CategoryListParams, Paginated, Pagination
7
+
8
+
9
+ class AsyncCategoriesResource:
10
+ def __init__(self, http: AsyncHttpClient) -> None:
11
+ self._http = http
12
+
13
+ async def list(self, params: CategoryListParams | None = None) -> Paginated[Category]:
14
+ query = _build_query(params) if params else {}
15
+ raw = await self._http.request("/categories", query=query)
16
+ return Paginated(
17
+ data=[_deserialize(c, Category) for c in raw["data"]],
18
+ pagination=_deserialize(raw["pagination"], Pagination),
19
+ )
20
+
21
+ async def get_by_slug(self, slug: str) -> Category:
22
+ raw = await self._http.request(f"/categories/{quote(slug, safe='')}")
23
+ return _deserialize(raw, Category)
24
+
25
+
26
+ class SyncCategoriesResource:
27
+ def __init__(self, http: SyncHttpClient) -> None:
28
+ self._http = http
29
+
30
+ def list(self, params: CategoryListParams | None = None) -> Paginated[Category]:
31
+ query = _build_query(params) if params else {}
32
+ raw = self._http.request("/categories", query=query)
33
+ return Paginated(
34
+ data=[_deserialize(c, Category) for c in raw["data"]],
35
+ pagination=_deserialize(raw["pagination"], Pagination),
36
+ )
37
+
38
+ def get_by_slug(self, slug: str) -> Category:
39
+ raw = self._http.request(f"/categories/{quote(slug, safe='')}")
40
+ return _deserialize(raw, Category)
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from urllib.parse import quote
4
+
5
+ from ..http import AsyncHttpClient, SyncHttpClient, _build_query, _deserialize
6
+ from ..types import Media, MediaCreateInput, MediaListParams, Paginated, Pagination
7
+
8
+
9
+ class AsyncMediaResource:
10
+ def __init__(self, http: AsyncHttpClient) -> None:
11
+ self._http = http
12
+
13
+ async def list(self, params: MediaListParams | None = None) -> Paginated[Media]:
14
+ query = _build_query(params) if params else {}
15
+ raw = await self._http.request("/media", query=query)
16
+ return Paginated(
17
+ data=[_deserialize(m, Media) for m in raw["data"]],
18
+ pagination=_deserialize(raw["pagination"], Pagination),
19
+ )
20
+
21
+ async def create(self, input: MediaCreateInput) -> Media:
22
+ raw = await self._http.request("/media", method="POST", body=input)
23
+ return _deserialize(raw, Media)
24
+
25
+ async def delete(self, id: str) -> None:
26
+ await self._http.request(f"/media/{quote(id, safe='')}", method="DELETE")
27
+
28
+
29
+ class SyncMediaResource:
30
+ def __init__(self, http: SyncHttpClient) -> None:
31
+ self._http = http
32
+
33
+ def list(self, params: MediaListParams | None = None) -> Paginated[Media]:
34
+ query = _build_query(params) if params else {}
35
+ raw = self._http.request("/media", query=query)
36
+ return Paginated(
37
+ data=[_deserialize(m, Media) for m in raw["data"]],
38
+ pagination=_deserialize(raw["pagination"], Pagination),
39
+ )
40
+
41
+ def create(self, input: MediaCreateInput) -> Media:
42
+ raw = self._http.request("/media", method="POST", body=input)
43
+ return _deserialize(raw, Media)
44
+
45
+ def delete(self, id: str) -> None:
46
+ self._http.request(f"/media/{quote(id, safe='')}", method="DELETE")
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ from urllib.parse import quote
4
+
5
+ from ..http import AsyncHttpClient, SyncHttpClient, _build_query, _deserialize
6
+ from ..types import (
7
+ Author,
8
+ AuthorListParams,
9
+ Paginated,
10
+ Pagination,
11
+ Post,
12
+ PostCreateInput,
13
+ PostListParams,
14
+ PostUpdateInput,
15
+ )
16
+
17
+
18
+ class AsyncPostsResource:
19
+ def __init__(self, http: AsyncHttpClient) -> None:
20
+ self._http = http
21
+
22
+ async def list(self, params: PostListParams | None = None) -> Paginated[Post]:
23
+ query = _build_query(params) if params else {}
24
+ raw = await self._http.request("/posts", query=query)
25
+ return Paginated(
26
+ data=[_deserialize(p, Post) for p in raw["data"]],
27
+ pagination=_deserialize(raw["pagination"], Pagination),
28
+ )
29
+
30
+ async def get(self, id: str) -> Post:
31
+ raw = await self._http.request(f"/posts/{quote(id, safe='')}")
32
+ return _deserialize(raw, Post)
33
+
34
+ async def get_by_slug(self, slug: str) -> Post:
35
+ raw = await self._http.request(f"/posts/slug/{quote(slug, safe='')}")
36
+ return _deserialize(raw, Post)
37
+
38
+ async def create(self, input: PostCreateInput) -> Post:
39
+ raw = await self._http.request("/posts", method="POST", body=input)
40
+ return _deserialize(raw, Post)
41
+
42
+ async def update(self, id: str, input: PostUpdateInput) -> Post:
43
+ raw = await self._http.request(f"/posts/{quote(id, safe='')}", method="PATCH", body=input)
44
+ return _deserialize(raw, Post)
45
+
46
+ async def delete(self, id: str) -> None:
47
+ await self._http.request(f"/posts/{quote(id, safe='')}", method="DELETE")
48
+
49
+
50
+ class SyncPostsResource:
51
+ def __init__(self, http: SyncHttpClient) -> None:
52
+ self._http = http
53
+
54
+ def list(self, params: PostListParams | None = None) -> Paginated[Post]:
55
+ query = _build_query(params) if params else {}
56
+ raw = self._http.request("/posts", query=query)
57
+ return Paginated(
58
+ data=[_deserialize(p, Post) for p in raw["data"]],
59
+ pagination=_deserialize(raw["pagination"], Pagination),
60
+ )
61
+
62
+ def get(self, id: str) -> Post:
63
+ raw = self._http.request(f"/posts/{quote(id, safe='')}")
64
+ return _deserialize(raw, Post)
65
+
66
+ def get_by_slug(self, slug: str) -> Post:
67
+ raw = self._http.request(f"/posts/slug/{quote(slug, safe='')}")
68
+ return _deserialize(raw, Post)
69
+
70
+ def create(self, input: PostCreateInput) -> Post:
71
+ raw = self._http.request("/posts", method="POST", body=input)
72
+ return _deserialize(raw, Post)
73
+
74
+ def update(self, id: str, input: PostUpdateInput) -> Post:
75
+ raw = self._http.request(f"/posts/{quote(id, safe='')}", method="PATCH", body=input)
76
+ return _deserialize(raw, Post)
77
+
78
+ def delete(self, id: str) -> None:
79
+ self._http.request(f"/posts/{quote(id, safe='')}", method="DELETE")
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from ..http import AsyncHttpClient, SyncHttpClient, _build_query, _deserialize
4
+ from ..types import Paginated, Pagination, Tag, TagListParams
5
+
6
+
7
+ class AsyncTagsResource:
8
+ def __init__(self, http: AsyncHttpClient) -> None:
9
+ self._http = http
10
+
11
+ async def list(self, params: TagListParams | None = None) -> Paginated[Tag]:
12
+ query = _build_query(params) if params else {}
13
+ raw = await self._http.request("/tags", query=query)
14
+ return Paginated(
15
+ data=[_deserialize(t, Tag) for t in raw["data"]],
16
+ pagination=_deserialize(raw["pagination"], Pagination),
17
+ )
18
+
19
+
20
+ class SyncTagsResource:
21
+ def __init__(self, http: SyncHttpClient) -> None:
22
+ self._http = http
23
+
24
+ def list(self, params: TagListParams | None = None) -> Paginated[Tag]:
25
+ query = _build_query(params) if params else {}
26
+ raw = self._http.request("/tags", query=query)
27
+ return Paginated(
28
+ data=[_deserialize(t, Tag) for t in raw["data"]],
29
+ pagination=_deserialize(raw["pagination"], Pagination),
30
+ )
@@ -0,0 +1,198 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+ from typing import Generic, Literal, TypeVar
6
+
7
+ T = TypeVar("T")
8
+
9
+
10
+ class PostStatus(str, Enum):
11
+ DRAFT = "draft"
12
+ SCHEDULED = "scheduled"
13
+ PUBLISHED = "published"
14
+ ARCHIVED = "archived"
15
+
16
+
17
+ @dataclass
18
+ class Seo:
19
+ title: str | None = None
20
+ description: str | None = None
21
+ canonical_url: str | None = None
22
+ og_image: str | None = None
23
+
24
+
25
+ @dataclass
26
+ class Author:
27
+ id: str
28
+ name: str
29
+ email: str | None = None
30
+ bio: str | None = None
31
+ avatar_url: str | None = None
32
+ created_at: str | None = None
33
+ updated_at: str | None = None
34
+
35
+
36
+ @dataclass
37
+ class Category:
38
+ id: str
39
+ name: str
40
+ slug: str
41
+ description: str | None = None
42
+
43
+
44
+ @dataclass
45
+ class Tag:
46
+ id: str
47
+ name: str
48
+ slug: str
49
+
50
+
51
+ @dataclass
52
+ class Media:
53
+ id: str
54
+ url: str
55
+ filename: str
56
+ mime_type: str
57
+ size: int
58
+ width: int | None = None
59
+ height: int | None = None
60
+ alt: str | None = None
61
+ created_at: str | None = None
62
+
63
+
64
+ @dataclass
65
+ class AuthorRef:
66
+ id: str
67
+ name: str
68
+
69
+
70
+ @dataclass
71
+ class CategoryRef:
72
+ id: str
73
+ name: str
74
+ slug: str
75
+
76
+
77
+ @dataclass
78
+ class Post:
79
+ id: str
80
+ title: str
81
+ slug: str
82
+ content: str
83
+ excerpt: str | None = None
84
+ cover_image: str | None = None
85
+ status: PostStatus = PostStatus.DRAFT
86
+ published_at: str | None = None
87
+ scheduled_for: str | None = None
88
+ author: AuthorRef | None = None
89
+ category: CategoryRef | None = None
90
+ tags: list[str] = field(default_factory=list)
91
+ seo: Seo = field(default_factory=Seo)
92
+ created_at: str = ""
93
+ updated_at: str = ""
94
+
95
+
96
+ @dataclass
97
+ class Pagination:
98
+ page: int
99
+ limit: int
100
+ total: int
101
+ total_pages: int
102
+ has_next_page: bool
103
+ has_previous_page: bool
104
+
105
+
106
+ @dataclass
107
+ class Paginated(Generic[T]):
108
+ data: list[T]
109
+ pagination: Pagination
110
+
111
+
112
+ @dataclass
113
+ class PostCreateInput:
114
+ title: str
115
+ content: str | None = None
116
+ slug: str | None = None
117
+ excerpt: str | None = None
118
+ cover_image: str | None = None
119
+ status: PostStatus | None = None
120
+ author_id: str | None = None
121
+ category_id: str | None = None
122
+ tags: list[str] | None = None
123
+ seo_title: str | None = None
124
+ seo_description: str | None = None
125
+ canonical_url: str | None = None
126
+ og_image: str | None = None
127
+ published_at: str | None = None
128
+ scheduled_for: str | None = None
129
+
130
+
131
+ @dataclass
132
+ class PostUpdateInput:
133
+ title: str | None = None
134
+ content: str | None = None
135
+ slug: str | None = None
136
+ excerpt: str | None = None
137
+ cover_image: str | None = None
138
+ status: PostStatus | None = None
139
+ author_id: str | None = None
140
+ category_id: str | None = None
141
+ tags: list[str] | None = None
142
+ seo_title: str | None = None
143
+ seo_description: str | None = None
144
+ canonical_url: str | None = None
145
+ og_image: str | None = None
146
+ published_at: str | None = None
147
+ scheduled_for: str | None = None
148
+
149
+
150
+ @dataclass
151
+ class PostListParams:
152
+ status: PostStatus | None = None
153
+ category: str | None = None
154
+ tag: str | None = None
155
+ author: str | None = None
156
+ search: str | None = None
157
+ page: int | None = None
158
+ limit: int | None = None
159
+ sort: str | None = None
160
+
161
+
162
+ @dataclass
163
+ class CategoryListParams:
164
+ page: int | None = None
165
+ limit: int | None = None
166
+ sort: str | None = None
167
+
168
+
169
+ @dataclass
170
+ class TagListParams:
171
+ page: int | None = None
172
+ limit: int | None = None
173
+ sort: str | None = None
174
+
175
+
176
+ @dataclass
177
+ class AuthorListParams:
178
+ page: int | None = None
179
+ limit: int | None = None
180
+ sort: str | None = None
181
+
182
+
183
+ @dataclass
184
+ class MediaListParams:
185
+ page: int | None = None
186
+ limit: int | None = None
187
+ sort: str | None = None
188
+
189
+
190
+ @dataclass
191
+ class MediaCreateInput:
192
+ url: str
193
+ filename: str
194
+ mime_type: str
195
+ size: int
196
+ width: int | None = None
197
+ height: int | None = None
198
+ alt: str | None = None
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from pagekit import Pagekit, PagekitError
6
+ from pagekit.http import DEFAULT_BASE_URL
7
+
8
+
9
+ def test_throws_when_no_api_key():
10
+ with pytest.raises(PagekitError, match="api_key is required"):
11
+ Pagekit(api_key="")
12
+
13
+
14
+ def test_creates_instance_with_valid_options():
15
+ client = Pagekit(api_key="pk_test_123")
16
+ assert client._http.api_key == "pk_test_123"
17
+ assert client._http.base_url == DEFAULT_BASE_URL
18
+ client.close()
19
+
20
+
21
+ def test_uses_custom_base_url():
22
+ client = Pagekit(api_key="pk_test_123", base_url="https://my-api.example.com/v1/")
23
+ assert client._http.base_url == "https://my-api.example.com/v1"
24
+ client.close()
25
+
26
+
27
+ def test_strips_trailing_slashes():
28
+ client = Pagekit(api_key="pk_test_123", base_url="https://example.com/v1///")
29
+ assert client._http.base_url == "https://example.com/v1"
30
+ client.close()
31
+
32
+
33
+ def test_exposes_resource_namespaces():
34
+ client = Pagekit(api_key="pk_test_123")
35
+ assert client.posts is not None
36
+ assert client.authors is not None
37
+ assert client.categories is not None
38
+ assert client.tags is not None
39
+ assert client.media is not None
40
+ client.close()
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from pagekit import PagekitError
4
+
5
+
6
+ def test_has_correct_defaults():
7
+ error = PagekitError("test")
8
+ assert str(error) == "test"
9
+ assert error.status == 0
10
+ assert error.code == "api_error"
11
+
12
+
13
+ def test_accepts_custom_status_and_code():
14
+ error = PagekitError("not found", status=404, code="not_found")
15
+ assert error.status == 404
16
+ assert error.code == "not_found"
17
+
18
+
19
+ def test_stores_details():
20
+ details = {"field": "title"}
21
+ error = PagekitError("validation", status=422, code="validation_error", details=details)
22
+ assert error.details == details
23
+
24
+
25
+ def test_is_auth_error_true_for_401():
26
+ assert PagekitError("unauthorized", status=401).is_auth_error is True
27
+
28
+
29
+ def test_is_auth_error_true_for_403():
30
+ assert PagekitError("forbidden", status=403).is_auth_error is True
31
+
32
+
33
+ def test_is_auth_error_false_for_other():
34
+ assert PagekitError("not found", status=404).is_auth_error is False
35
+ assert PagekitError("error", status=500).is_auth_error is False
36
+
37
+
38
+ def test_is_rate_limited_true_for_429():
39
+ assert PagekitError("rate limited", status=429).is_rate_limited is True
40
+
41
+
42
+ def test_is_rate_limited_false_for_other():
43
+ assert PagekitError("error", status=400).is_rate_limited is False
44
+
45
+
46
+ def test_is_server_error_true_for_5xx():
47
+ assert PagekitError("error", status=500).is_server_error is True
48
+ assert PagekitError("error", status=503).is_server_error is True
49
+ assert PagekitError("error", status=599).is_server_error is True
50
+
51
+
52
+ def test_is_server_error_false_for_non_5xx():
53
+ assert PagekitError("error", status=400).is_server_error is False
54
+ assert PagekitError("error", status=404).is_server_error is False
@@ -0,0 +1,185 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+ import respx
5
+
6
+ from pagekit import Pagekit, PagekitError
7
+ from pagekit.types import (
8
+ AuthorListParams,
9
+ MediaCreateInput,
10
+ PostCreateInput,
11
+ PostListParams,
12
+ PostStatus,
13
+ PostUpdateInput,
14
+ )
15
+
16
+ API_KEY = "pk_test_123"
17
+
18
+
19
+ @respx.mock
20
+ def test_posts_list():
21
+ respx.get("https://api.pagekit.app/v1/posts").mock(
22
+ return_value=httpx.Response(200, json={"data": [], "pagination": {"page": 1, "limit": 10, "total": 0, "totalPages": 0, "hasNextPage": False, "hasPreviousPage": False}})
23
+ )
24
+ client = Pagekit(api_key=API_KEY)
25
+ result = client.posts.list(PostListParams(status=PostStatus.PUBLISHED, limit=5))
26
+ assert result.data == []
27
+ assert result.pagination.page == 1
28
+ client.close()
29
+
30
+
31
+ @respx.mock
32
+ def test_posts_get():
33
+ respx.get("https://api.pagekit.app/v1/posts/123").mock(
34
+ return_value=httpx.Response(200, json={"id": "123", "title": "Test", "slug": "test", "content": "", "status": "published", "createdAt": "2024-01-01", "updatedAt": "2024-01-01", "tags": [], "seo": {}})
35
+ )
36
+ client = Pagekit(api_key=API_KEY)
37
+ post = client.posts.get("123")
38
+ assert post.id == "123"
39
+ assert post.title == "Test"
40
+ client.close()
41
+
42
+
43
+ @respx.mock
44
+ def test_posts_get_by_slug():
45
+ respx.get("https://api.pagekit.app/v1/posts/slug/hello").mock(
46
+ return_value=httpx.Response(200, json={"id": "1", "title": "Hello", "slug": "hello", "content": "", "status": "published", "createdAt": "2024-01-01", "updatedAt": "2024-01-01", "tags": [], "seo": {}})
47
+ )
48
+ client = Pagekit(api_key=API_KEY)
49
+ post = client.posts.get_by_slug("hello")
50
+ assert post.slug == "hello"
51
+ client.close()
52
+
53
+
54
+ @respx.mock
55
+ def test_posts_create():
56
+ respx.post("https://api.pagekit.app/v1/posts").mock(
57
+ return_value=httpx.Response(200, json={"id": "new", "title": "New Post", "slug": "new-post", "content": "Hello", "status": "draft", "createdAt": "2024-01-01", "updatedAt": "2024-01-01", "tags": [], "seo": {}})
58
+ )
59
+ client = Pagekit(api_key=API_KEY)
60
+ post = client.posts.create(PostCreateInput(title="New Post", content="Hello"))
61
+ assert post.id == "new"
62
+ client.close()
63
+
64
+
65
+ @respx.mock
66
+ def test_posts_update():
67
+ respx.patch("https://api.pagekit.app/v1/posts/123").mock(
68
+ return_value=httpx.Response(200, json={"id": "123", "title": "Updated", "slug": "test", "content": "", "status": "draft", "createdAt": "2024-01-01", "updatedAt": "2024-01-01", "tags": [], "seo": {}})
69
+ )
70
+ client = Pagekit(api_key=API_KEY)
71
+ post = client.posts.update("123", PostUpdateInput(title="Updated"))
72
+ assert post.title == "Updated"
73
+ client.close()
74
+
75
+
76
+ @respx.mock
77
+ def test_posts_delete():
78
+ respx.delete("https://api.pagekit.app/v1/posts/123").mock(
79
+ return_value=httpx.Response(204)
80
+ )
81
+ client = Pagekit(api_key=API_KEY)
82
+ client.posts.delete("123")
83
+ client.close()
84
+
85
+
86
+ @respx.mock
87
+ def test_authors_list():
88
+ respx.get("https://api.pagekit.app/v1/authors").mock(
89
+ return_value=httpx.Response(200, json={"data": [], "pagination": {"page": 1, "limit": 10, "total": 0, "totalPages": 0, "hasNextPage": False, "hasPreviousPage": False}})
90
+ )
91
+ client = Pagekit(api_key=API_KEY)
92
+ result = client.authors.list()
93
+ assert result.data == []
94
+ client.close()
95
+
96
+
97
+ @respx.mock
98
+ def test_authors_get():
99
+ respx.get("https://api.pagekit.app/v1/authors/a1").mock(
100
+ return_value=httpx.Response(200, json={"id": "a1", "name": "Alice"})
101
+ )
102
+ client = Pagekit(api_key=API_KEY)
103
+ author = client.authors.get("a1")
104
+ assert author.id == "a1"
105
+ client.close()
106
+
107
+
108
+ @respx.mock
109
+ def test_categories_list():
110
+ respx.get("https://api.pagekit.app/v1/categories").mock(
111
+ return_value=httpx.Response(200, json={"data": [], "pagination": {"page": 1, "limit": 10, "total": 0, "totalPages": 0, "hasNextPage": False, "hasPreviousPage": False}})
112
+ )
113
+ client = Pagekit(api_key=API_KEY)
114
+ result = client.categories.list()
115
+ assert result.data == []
116
+ client.close()
117
+
118
+
119
+ @respx.mock
120
+ def test_categories_get_by_slug():
121
+ respx.get("https://api.pagekit.app/v1/categories/engineering").mock(
122
+ return_value=httpx.Response(200, json={"id": "c1", "name": "Engineering", "slug": "engineering"})
123
+ )
124
+ client = Pagekit(api_key=API_KEY)
125
+ cat = client.categories.get_by_slug("engineering")
126
+ assert cat.slug == "engineering"
127
+ client.close()
128
+
129
+
130
+ @respx.mock
131
+ def test_tags_list():
132
+ respx.get("https://api.pagekit.app/v1/tags").mock(
133
+ return_value=httpx.Response(200, json={"data": [], "pagination": {"page": 1, "limit": 10, "total": 0, "totalPages": 0, "hasNextPage": False, "hasPreviousPage": False}})
134
+ )
135
+ client = Pagekit(api_key=API_KEY)
136
+ result = client.tags.list()
137
+ assert result.data == []
138
+ client.close()
139
+
140
+
141
+ @respx.mock
142
+ def test_media_list():
143
+ respx.get("https://api.pagekit.app/v1/media").mock(
144
+ return_value=httpx.Response(200, json={"data": [], "pagination": {"page": 1, "limit": 10, "total": 0, "totalPages": 0, "hasNextPage": False, "hasPreviousPage": False}})
145
+ )
146
+ client = Pagekit(api_key=API_KEY)
147
+ result = client.media.list()
148
+ assert result.data == []
149
+ client.close()
150
+
151
+
152
+ @respx.mock
153
+ def test_media_create():
154
+ respx.post("https://api.pagekit.app/v1/media").mock(
155
+ return_value=httpx.Response(200, json={"id": "m1", "url": "https://example.com/img.png", "filename": "img.png", "mimeType": "image/png", "size": 1024})
156
+ )
157
+ client = Pagekit(api_key=API_KEY)
158
+ media = client.media.create(MediaCreateInput(url="https://example.com/img.png", filename="img.png", mime_type="image/png", size=1024))
159
+ assert media.id == "m1"
160
+ client.close()
161
+
162
+
163
+ @respx.mock
164
+ def test_media_delete():
165
+ respx.delete("https://api.pagekit.app/v1/media/m1").mock(
166
+ return_value=httpx.Response(204)
167
+ )
168
+ client = Pagekit(api_key=API_KEY)
169
+ client.media.delete("m1")
170
+ client.close()
171
+
172
+
173
+ @respx.mock
174
+ def test_error_response():
175
+ respx.get("https://api.pagekit.app/v1/posts/999").mock(
176
+ return_value=httpx.Response(404, json={"error": {"message": "Not found", "code": "not_found"}})
177
+ )
178
+ client = Pagekit(api_key=API_KEY)
179
+ try:
180
+ client.posts.get("999")
181
+ assert False, "Should have raised"
182
+ except PagekitError as e:
183
+ assert e.status == 404
184
+ assert e.code == "not_found"
185
+ client.close()