block-style-cms-sdk 0.0.1__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,10 @@
1
+ node_modules/
2
+ dist/
3
+ .venv/
4
+ **/__pycache__/
5
+ **/*.egg-info/
6
+ .pytest_cache/
7
+ .ruff_cache/
8
+ *.pyc
9
+ .DS_Store
10
+ coverage/
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.5
2
+ Name: block-style-cms-sdk
3
+ Version: 0.0.1
4
+ Summary: Python SDK for Block Style CMS
5
+ Author-email: Biscuit Zhou <biscuit_zhou@outlook.com>
6
+ License: MIT
7
+ Keywords: blocknote,cms,markdown,sdk
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: httpx>=0.27
19
+ Requires-Dist: pydantic>=2.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: ruff>=0.9; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # block-style-cms-sdk
25
+
26
+ Python SDK for [Block Style CMS](https://github.com/Byte-Biscuit/block-style-cms).
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install block-style-cms-sdk
32
+ # or: uv add block-style-cms-sdk
33
+ ```
34
+
35
+ ## Auth
36
+
37
+ Create an API token in the CMS admin UI. Pass it when constructing the client:
38
+
39
+ - Header: `x-api-key: <token>` (default), or
40
+ - `Authorization: Bearer <token>`
41
+
42
+ ## Publish from Markdown
43
+
44
+ 1. Collect relative images from markdown (e.g. `./assets/foo.png`)
45
+ 2. Resolve files under the required `assetsDir`
46
+ 3. Upload via `POST /api/m/images`
47
+ 4. Rewrite paths to `/images/{filename}`
48
+ 5. Submit via `POST /api/m/articles/from-markdown`
49
+
50
+ ```python
51
+ from block_style_cms_sdk import BlockStyleCmsClient
52
+
53
+ client = BlockStyleCmsClient(
54
+ base_url="https://cms.example.com",
55
+ api_key="bsc_...",
56
+ )
57
+
58
+ client.articles.publish_from_markdown(
59
+ title="Hello",
60
+ slug="hello-article-slug",
61
+ summary="A short summary of at least ten chars",
62
+ tags=["demo"],
63
+ language="en-US",
64
+ md_file="./posts/hello.md",
65
+ assets_dir="./posts/assets",
66
+ published=False,
67
+ )
68
+ ```
69
+
70
+ ## Content metadata
71
+
72
+ Before publishing, discover site configuration:
73
+
74
+ - `GET /api/m/locales` — supported article languages (`zh-CN`, `zh-TW`, `en-US`)
75
+ - `GET /api/m/channels` — site channels from CMS settings
76
+
77
+ ```python
78
+ locales = client.content.list_locales()
79
+ channels = client.content.list_channels()
80
+ ```
81
+
82
+ ## Develop
83
+
84
+ From `packages/python`:
85
+
86
+ ```bash
87
+ uv sync --extra dev
88
+ uv run ruff check .
89
+ uv run ruff format .
90
+ ```
91
+
92
+ Remote API check with the shared sample fixture ([`samples/`](../../samples/)):
93
+
94
+ ```bash
95
+ cd packages/python
96
+ BSC_BASE_URL=https://your-cms.example.com \
97
+ BSC_API_KEY=bsc_... \
98
+ uv run python tests/publish_sample_article.py
99
+ ```
100
+
101
+ ## Release
102
+
103
+ 1. Bump `version` in [`pyproject.toml`](pyproject.toml).
104
+ 2. Tag `py-vX.Y.Z`, push, and **Publish** a GitHub Release for that tag.
105
+ 3. GitHub Actions uploads to PyPI via [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC; no `PYPI_TOKEN` secret).
106
+
107
+ Example: version `0.0.1` → tag `py-v0.0.1` → GitHub Release → PyPI.
@@ -0,0 +1,84 @@
1
+ # block-style-cms-sdk
2
+
3
+ Python SDK for [Block Style CMS](https://github.com/Byte-Biscuit/block-style-cms).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install block-style-cms-sdk
9
+ # or: uv add block-style-cms-sdk
10
+ ```
11
+
12
+ ## Auth
13
+
14
+ Create an API token in the CMS admin UI. Pass it when constructing the client:
15
+
16
+ - Header: `x-api-key: <token>` (default), or
17
+ - `Authorization: Bearer <token>`
18
+
19
+ ## Publish from Markdown
20
+
21
+ 1. Collect relative images from markdown (e.g. `./assets/foo.png`)
22
+ 2. Resolve files under the required `assetsDir`
23
+ 3. Upload via `POST /api/m/images`
24
+ 4. Rewrite paths to `/images/{filename}`
25
+ 5. Submit via `POST /api/m/articles/from-markdown`
26
+
27
+ ```python
28
+ from block_style_cms_sdk import BlockStyleCmsClient
29
+
30
+ client = BlockStyleCmsClient(
31
+ base_url="https://cms.example.com",
32
+ api_key="bsc_...",
33
+ )
34
+
35
+ client.articles.publish_from_markdown(
36
+ title="Hello",
37
+ slug="hello-article-slug",
38
+ summary="A short summary of at least ten chars",
39
+ tags=["demo"],
40
+ language="en-US",
41
+ md_file="./posts/hello.md",
42
+ assets_dir="./posts/assets",
43
+ published=False,
44
+ )
45
+ ```
46
+
47
+ ## Content metadata
48
+
49
+ Before publishing, discover site configuration:
50
+
51
+ - `GET /api/m/locales` — supported article languages (`zh-CN`, `zh-TW`, `en-US`)
52
+ - `GET /api/m/channels` — site channels from CMS settings
53
+
54
+ ```python
55
+ locales = client.content.list_locales()
56
+ channels = client.content.list_channels()
57
+ ```
58
+
59
+ ## Develop
60
+
61
+ From `packages/python`:
62
+
63
+ ```bash
64
+ uv sync --extra dev
65
+ uv run ruff check .
66
+ uv run ruff format .
67
+ ```
68
+
69
+ Remote API check with the shared sample fixture ([`samples/`](../../samples/)):
70
+
71
+ ```bash
72
+ cd packages/python
73
+ BSC_BASE_URL=https://your-cms.example.com \
74
+ BSC_API_KEY=bsc_... \
75
+ uv run python tests/publish_sample_article.py
76
+ ```
77
+
78
+ ## Release
79
+
80
+ 1. Bump `version` in [`pyproject.toml`](pyproject.toml).
81
+ 2. Tag `py-vX.Y.Z`, push, and **Publish** a GitHub Release for that tag.
82
+ 3. GitHub Actions uploads to PyPI via [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC; no `PYPI_TOKEN` secret).
83
+
84
+ Example: version `0.0.1` → tag `py-v0.0.1` → GitHub Release → PyPI.
@@ -0,0 +1,49 @@
1
+ [project]
2
+ name = "block-style-cms-sdk"
3
+ version = "0.0.1"
4
+ description = "Python SDK for Block Style CMS"
5
+ authors=[
6
+ { name = "Biscuit Zhou", email = "biscuit_zhou@outlook.com" }
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.10"
10
+ license = { text = "MIT" }
11
+ keywords = ["cms", "blocknote", "markdown", "sdk"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = [
24
+ "httpx>=0.27",
25
+ "pydantic>=2.0",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ dev = [
30
+ "ruff>=0.9",
31
+ ]
32
+
33
+ [build-system]
34
+ requires = ["hatchling"]
35
+ build-backend = "hatchling.build"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/block_style_cms_sdk"]
39
+
40
+ [tool.ruff]
41
+ line-length = 88
42
+ target-version = "py310"
43
+ src = ["src", "tests"]
44
+
45
+ [tool.ruff.lint]
46
+ select = ["E", "F", "I", "UP", "B"]
47
+
48
+ [tool.ruff.format]
49
+ quote-style = "double"
@@ -0,0 +1,25 @@
1
+ """Block Style CMS Python SDK."""
2
+
3
+ from .client import ApiError, BlockStyleCmsClient, HttpClient
4
+ from .markdown.images import (
5
+ collect_relative_image_srcs,
6
+ is_relative_image_src,
7
+ is_skippable_image_src,
8
+ rewrite_all_image_srcs,
9
+ rewrite_image_src,
10
+ )
11
+ from .resources.articles import MissingAssetsError
12
+
13
+ __all__ = [
14
+ "ApiError",
15
+ "BlockStyleCmsClient",
16
+ "HttpClient",
17
+ "MissingAssetsError",
18
+ "collect_relative_image_srcs",
19
+ "is_relative_image_src",
20
+ "is_skippable_image_src",
21
+ "rewrite_all_image_srcs",
22
+ "rewrite_image_src",
23
+ ]
24
+
25
+ __version__ = "0.1.0"
@@ -0,0 +1,248 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import time
5
+ from pathlib import Path
6
+ from typing import Any, Literal
7
+
8
+ import httpx
9
+
10
+ from .models import (
11
+ ApiResult,
12
+ FromMarkdownPayload,
13
+ FromMarkdownRequest,
14
+ ImageMetaInfo,
15
+ ImageUploadOptions,
16
+ )
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class ApiError(Exception):
22
+ def __init__(self, message: str, code: int, payload: Any = None) -> None:
23
+ super().__init__(message)
24
+ self.code = code
25
+ self.payload = payload if payload is not None else {}
26
+
27
+
28
+ class HttpClient:
29
+ def __init__(
30
+ self,
31
+ *,
32
+ base_url: str,
33
+ api_key: str,
34
+ auth_style: Literal["api-key", "bearer"] = "api-key",
35
+ max_retries: int = 2,
36
+ timeout: float = 60.0,
37
+ ) -> None:
38
+ if not base_url or not base_url.strip():
39
+ raise ValueError("base_url is required")
40
+ if not api_key or not api_key.strip():
41
+ raise ValueError("api_key is required")
42
+ self.base_url = base_url.strip().rstrip("/")
43
+ self.api_key = api_key.strip()
44
+ self.auth_style = auth_style
45
+ self.max_retries = max_retries
46
+ self._client = httpx.Client(timeout=timeout)
47
+ logger.debug(
48
+ "HttpClient base_url=%s auth_style=%s max_retries=%d timeout=%s",
49
+ self.base_url,
50
+ auth_style,
51
+ max_retries,
52
+ timeout,
53
+ )
54
+
55
+ def close(self) -> None:
56
+ self._client.close()
57
+
58
+ def __enter__(self) -> HttpClient:
59
+ return self
60
+
61
+ def __exit__(self, *args: object) -> None:
62
+ self.close()
63
+
64
+ def _auth_headers(self) -> dict[str, str]:
65
+ if self.auth_style == "bearer":
66
+ return {"Authorization": f"Bearer {self.api_key}"}
67
+ return {"x-api-key": self.api_key}
68
+
69
+ def request_json(
70
+ self,
71
+ method: str,
72
+ path: str,
73
+ *,
74
+ json: dict[str, Any] | None = None,
75
+ data: dict[str, Any] | None = None,
76
+ files: Any = None,
77
+ headers: dict[str, str] | None = None,
78
+ ) -> ApiResult:
79
+ url = f"{self.base_url}{path if path.startswith('/') else '/' + path}"
80
+ hdrs = {**self._auth_headers(), **(headers or {})}
81
+ last_error: Exception | None = None
82
+ attempts = self.max_retries + 1
83
+
84
+ for attempt in range(attempts):
85
+ logger.debug("%s %s (attempt %d/%d)", method, url, attempt + 1, attempts)
86
+ try:
87
+ res = self._client.request(
88
+ method,
89
+ url,
90
+ json=json,
91
+ data=data,
92
+ files=files,
93
+ headers=hdrs,
94
+ )
95
+ body = res.json()
96
+ result = ApiResult.model_validate(body)
97
+ if res.status_code >= 500 or result.code >= 500:
98
+ if attempt < self.max_retries:
99
+ logger.warning(
100
+ "server error %s %s: HTTP %s code=%s "
101
+ "(attempt %d), retrying",
102
+ method,
103
+ url,
104
+ res.status_code,
105
+ result.code,
106
+ attempt + 1,
107
+ )
108
+ time.sleep(0.2 * (attempt + 1))
109
+ continue
110
+ if not res.is_success or result.code != 200:
111
+ logger.error(
112
+ "API error %s %s: HTTP %s code=%s message=%s",
113
+ method,
114
+ url,
115
+ res.status_code,
116
+ result.code,
117
+ result.message,
118
+ )
119
+ raise ApiError(
120
+ result.message or f"HTTP {res.status_code}",
121
+ result.code or res.status_code,
122
+ result.payload,
123
+ )
124
+ logger.debug(
125
+ "%s %s -> HTTP %s code=%s message=%s",
126
+ method,
127
+ url,
128
+ res.status_code,
129
+ result.code,
130
+ result.message,
131
+ )
132
+ return result
133
+ except ApiError:
134
+ raise
135
+ except Exception as err: # noqa: BLE001 — retry network blips
136
+ last_error = err
137
+ if attempt < self.max_retries:
138
+ logger.warning(
139
+ "request failed %s %s (attempt %d/%d): %s",
140
+ method,
141
+ url,
142
+ attempt + 1,
143
+ attempts,
144
+ err,
145
+ )
146
+ time.sleep(0.2 * (attempt + 1))
147
+ continue
148
+ logger.error(
149
+ "request failed %s %s after %d attempts: %s",
150
+ method,
151
+ url,
152
+ attempts,
153
+ err,
154
+ )
155
+ raise
156
+ assert last_error is not None
157
+ raise last_error
158
+
159
+ def upload_image(
160
+ self,
161
+ file_bytes: bytes,
162
+ filename: str,
163
+ options: ImageUploadOptions | None = None,
164
+ ) -> ImageMetaInfo:
165
+ logger.info("uploading image %s (%d bytes)", filename, len(file_bytes))
166
+ files = {"file": (filename, file_bytes)}
167
+ data = None
168
+ if options is not None:
169
+ data = {
170
+ "options": options.model_dump_json(exclude_none=True),
171
+ }
172
+ result = self.request_json(
173
+ "POST",
174
+ "/api/m/images",
175
+ data=data,
176
+ files=files,
177
+ )
178
+ meta = ImageMetaInfo.model_validate(result.payload)
179
+ logger.info("uploaded image %s -> /images/%s", filename, meta.filename)
180
+ return meta
181
+
182
+ def upload_image_file(
183
+ self,
184
+ file_path: str | Path,
185
+ options: ImageUploadOptions | None = None,
186
+ ) -> ImageMetaInfo:
187
+ path = Path(file_path)
188
+ logger.debug("reading image file %s", path)
189
+ return self.upload_image(path.read_bytes(), path.name, options)
190
+
191
+ def create_article_from_markdown(
192
+ self, body: FromMarkdownRequest
193
+ ) -> FromMarkdownPayload:
194
+ logger.info(
195
+ "creating article from markdown slug=%s language=%s published=%s",
196
+ body.slug,
197
+ body.language,
198
+ body.published,
199
+ )
200
+ payload = body.model_dump(by_alias=True, exclude_none=True)
201
+ result = self.request_json(
202
+ "POST",
203
+ "/api/m/articles/from-markdown",
204
+ json=payload,
205
+ )
206
+ article = FromMarkdownPayload.model_validate(result.payload)
207
+ logger.info(
208
+ "created article id=%s slug=%s locale=%s",
209
+ article.id,
210
+ article.slug,
211
+ article.locale,
212
+ )
213
+ return article
214
+
215
+
216
+ class BlockStyleCmsClient:
217
+ def __init__(
218
+ self,
219
+ *,
220
+ base_url: str,
221
+ api_key: str,
222
+ auth_style: Literal["api-key", "bearer"] = "api-key",
223
+ max_retries: int = 2,
224
+ timeout: float = 60.0,
225
+ ) -> None:
226
+ from .resources.articles import ArticlesResource
227
+ from .resources.content import ContentResource
228
+ from .resources.media import MediaResource
229
+
230
+ self.http = HttpClient(
231
+ base_url=base_url,
232
+ api_key=api_key,
233
+ auth_style=auth_style,
234
+ max_retries=max_retries,
235
+ timeout=timeout,
236
+ )
237
+ self.media = MediaResource(self.http)
238
+ self.articles = ArticlesResource(self.http)
239
+ self.content = ContentResource(self.http)
240
+
241
+ def close(self) -> None:
242
+ self.http.close()
243
+
244
+ def __enter__(self) -> BlockStyleCmsClient:
245
+ return self
246
+
247
+ def __exit__(self, *args: object) -> None:
248
+ self.close()
@@ -0,0 +1 @@
1
+ """Markdown image helpers."""
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ _MD_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
7
+ _HTML_IMG_RE = re.compile(r"<img\b[^>]*\bsrc=[\"']([^\"']+)[\"'][^>]*>", re.I)
8
+
9
+
10
+ def is_skippable_image_src(src: str) -> bool:
11
+ s = src.strip()
12
+ if not s:
13
+ return False
14
+ if s.startswith("/images/"):
15
+ return True
16
+ if re.match(r"^https?://", s, re.I):
17
+ return True
18
+ return False
19
+
20
+
21
+ def is_relative_image_src(src: str) -> bool:
22
+ s = src.strip()
23
+ if not s:
24
+ return True
25
+ if is_skippable_image_src(s):
26
+ return False
27
+ if s.startswith("/"):
28
+ return False
29
+ return True
30
+
31
+
32
+ def collect_relative_image_srcs(md: str) -> list[str]:
33
+ found: set[str] = set()
34
+ for match in _MD_IMAGE_RE.finditer(md):
35
+ src = match.group(2).strip()
36
+ if is_relative_image_src(src):
37
+ found.add(src)
38
+ for match in _HTML_IMG_RE.finditer(md):
39
+ src = match.group(1).strip()
40
+ if is_relative_image_src(src):
41
+ found.add(src)
42
+ return sorted(found)
43
+
44
+
45
+ def resolve_image_path(assets_dir: str | Path, src: str) -> Path:
46
+ cleaned = src[2:] if src.startswith("./") else src
47
+ under_assets = cleaned[7:] if cleaned.startswith("assets/") else cleaned
48
+ return (Path(assets_dir) / under_assets).resolve()
49
+
50
+
51
+ def rewrite_image_src(md: str, from_src: str, to_src: str) -> str:
52
+ escaped = re.escape(from_src)
53
+ md_re = re.compile(rf"(!\[[^\]]*\]\(){escaped}((?:\s+\"[^\"]*\")?\))")
54
+ out = md_re.sub(rf"\g<1>{to_src}\g<2>", md)
55
+ html_re = re.compile(
56
+ rf"(<img\b[^>]*\bsrc=[\"']){escaped}([\"'])",
57
+ re.I,
58
+ )
59
+ return html_re.sub(rf"\g<1>{to_src}\g<2>", out)
60
+
61
+
62
+ def rewrite_all_image_srcs(md: str, mapping: dict[str, str]) -> str:
63
+ out = md
64
+ for from_src, to_src in mapping.items():
65
+ out = rewrite_image_src(out, from_src, to_src)
66
+ return out
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ Locale = Literal["zh-CN", "zh-TW", "en-US"]
8
+
9
+
10
+ class ApiResult(BaseModel):
11
+ code: int
12
+ message: str
13
+ payload: Any = Field(default_factory=dict)
14
+ tt: float | None = None
15
+
16
+
17
+ class ImageUploadOptions(BaseModel):
18
+ quality: int | None = None
19
+ maxWidth: int | None = None
20
+ maxHeight: int | None = None
21
+ enableWebP: bool | None = None
22
+
23
+
24
+ class ImageMetaInfo(BaseModel):
25
+ filename: str
26
+ originalName: str
27
+ size: int
28
+ width: int
29
+ height: int
30
+ mimeType: str
31
+ uploadedAt: str
32
+ fullUrl: str | None = None
33
+ title: str | None = None
34
+ description: str | None = None
35
+ tags: list[str] | None = None
36
+ altText: str | None = None
37
+
38
+
39
+ class FromMarkdownRequest(BaseModel):
40
+ title: str
41
+ slug: str
42
+ language: Locale
43
+ summary: str = ""
44
+ tags: list[str] = Field(default_factory=list)
45
+ keywords: list[str] = Field(default_factory=list)
46
+ mdContent: str | None = None
47
+ cover: str = ""
48
+ published: bool = False
49
+ locale_param: str | None = Field(default=None, alias="__locale__")
50
+
51
+ model_config = {"populate_by_name": True}
52
+
53
+
54
+ class FromMarkdownPayload(BaseModel):
55
+ id: str
56
+ slug: str
57
+ locale: Locale
58
+
59
+
60
+ ChannelItemType = Literal["tag", "page"]
61
+
62
+
63
+ class ChannelItem(BaseModel):
64
+ id: str
65
+ type: ChannelItemType
66
+ href: str
67
+ tag: str | None = None
68
+ icon: str | None = None
69
+
70
+
71
+ class LocaleInfo(BaseModel):
72
+ code: Locale
73
+ name: str
74
+ nativeName: str
75
+ englishName: str
76
+ flag: str
@@ -0,0 +1 @@
1
+ """API resource modules."""