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.
- pagekit_core-0.1.0/.gitignore +13 -0
- pagekit_core-0.1.0/PKG-INFO +97 -0
- pagekit_core-0.1.0/README.md +70 -0
- pagekit_core-0.1.0/pyproject.toml +40 -0
- pagekit_core-0.1.0/src/pagekit/__init__.py +46 -0
- pagekit_core-0.1.0/src/pagekit/client.py +81 -0
- pagekit_core-0.1.0/src/pagekit/errors.py +32 -0
- pagekit_core-0.1.0/src/pagekit/http.py +241 -0
- pagekit_core-0.1.0/src/pagekit/resources/__init__.py +20 -0
- pagekit_core-0.1.0/src/pagekit/resources/authors.py +40 -0
- pagekit_core-0.1.0/src/pagekit/resources/categories.py +40 -0
- pagekit_core-0.1.0/src/pagekit/resources/media.py +46 -0
- pagekit_core-0.1.0/src/pagekit/resources/posts.py +79 -0
- pagekit_core-0.1.0/src/pagekit/resources/tags.py +30 -0
- pagekit_core-0.1.0/src/pagekit/types.py +198 -0
- pagekit_core-0.1.0/tests/test_client.py +40 -0
- pagekit_core-0.1.0/tests/test_errors.py +54 -0
- pagekit_core-0.1.0/tests/test_resources.py +185 -0
|
@@ -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()
|