bookstack-cli 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.
Files changed (32) hide show
  1. bookstack_cli-0.1.0/PKG-INFO +227 -0
  2. bookstack_cli-0.1.0/README.md +197 -0
  3. bookstack_cli-0.1.0/bookstack_cli/__init__.py +3 -0
  4. bookstack_cli-0.1.0/bookstack_cli/client.py +211 -0
  5. bookstack_cli-0.1.0/bookstack_cli/config.py +131 -0
  6. bookstack_cli-0.1.0/bookstack_cli/exceptions.py +45 -0
  7. bookstack_cli-0.1.0/bookstack_cli/main.py +840 -0
  8. bookstack_cli-0.1.0/bookstack_cli/models.py +276 -0
  9. bookstack_cli-0.1.0/bookstack_cli/resources/__init__.py +1 -0
  10. bookstack_cli-0.1.0/bookstack_cli/resources/attachments.py +90 -0
  11. bookstack_cli-0.1.0/bookstack_cli/resources/books.py +64 -0
  12. bookstack_cli-0.1.0/bookstack_cli/resources/chapters.py +46 -0
  13. bookstack_cli-0.1.0/bookstack_cli/resources/pages.py +365 -0
  14. bookstack_cli-0.1.0/bookstack_cli/resources/revisions.py +30 -0
  15. bookstack_cli-0.1.0/bookstack_cli/resources/roles.py +33 -0
  16. bookstack_cli-0.1.0/bookstack_cli/resources/search.py +21 -0
  17. bookstack_cli-0.1.0/bookstack_cli/resources/shelves.py +55 -0
  18. bookstack_cli-0.1.0/bookstack_cli/resources/tags.py +15 -0
  19. bookstack_cli-0.1.0/bookstack_cli/resources/users.py +39 -0
  20. bookstack_cli-0.1.0/bookstack_cli.egg-info/PKG-INFO +227 -0
  21. bookstack_cli-0.1.0/bookstack_cli.egg-info/SOURCES.txt +30 -0
  22. bookstack_cli-0.1.0/bookstack_cli.egg-info/dependency_links.txt +1 -0
  23. bookstack_cli-0.1.0/bookstack_cli.egg-info/entry_points.txt +2 -0
  24. bookstack_cli-0.1.0/bookstack_cli.egg-info/requires.txt +11 -0
  25. bookstack_cli-0.1.0/bookstack_cli.egg-info/top_level.txt +1 -0
  26. bookstack_cli-0.1.0/pyproject.toml +62 -0
  27. bookstack_cli-0.1.0/setup.cfg +4 -0
  28. bookstack_cli-0.1.0/tests/test_client.py +284 -0
  29. bookstack_cli-0.1.0/tests/test_config.py +235 -0
  30. bookstack_cli-0.1.0/tests/test_main.py +812 -0
  31. bookstack_cli-0.1.0/tests/test_models.py +241 -0
  32. bookstack_cli-0.1.0/tests/test_resources.py +542 -0
@@ -0,0 +1,227 @@
1
+ Metadata-Version: 2.4
2
+ Name: bookstack-cli
3
+ Version: 0.1.0
4
+ Summary: CLI for coding agents to interact with a BookStack wiki via its REST API
5
+ Author: Michael Zehrer
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/mzehrer/bookstack-cli
8
+ Project-URL: Source, https://github.com/mzehrer/bookstack-cli
9
+ Project-URL: Issues, https://github.com/mzehrer/bookstack-cli/issues
10
+ Project-URL: Documentation, https://github.com/mzehrer/bookstack-cli#readme
11
+ Keywords: bookstack,wiki,cli,api,documentation
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: Documentation
17
+ Classifier: Topic :: Utilities
18
+ Requires-Python: >=3.14
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: httpx>=0.28
21
+ Requires-Dist: pydantic>=2.0
22
+ Requires-Dist: typer>=0.15
23
+ Requires-Dist: rich>=13.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=9.1.1; extra == "dev"
26
+ Requires-Dist: pytest-asyncio>=1.4.0; extra == "dev"
27
+ Requires-Dist: pytest-cov>=7.1.0; extra == "dev"
28
+ Requires-Dist: pytest-httpx>=0.36.2; extra == "dev"
29
+ Requires-Dist: ruff>=0.15.19; extra == "dev"
30
+
31
+ # bookstack-cli
32
+
33
+ [![Python](https://img.shields.io/badge/python-≥3.14-blue)]()
34
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)]()
35
+
36
+ CLI for coding agents to interact with a [BookStack](https://www.bookstackapp.com/) wiki via its REST API. All output is JSON — built for LLM pipelines, not humans.
37
+
38
+ ```bash
39
+ bookstack books list | jq '. | length'
40
+ bookstack pages get 42 | jq '.html[:200]'
41
+ bookstack search query "api docs" | jq '.[] | {name, type, score}'
42
+ bookstack test
43
+ ```
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ # One-liner (no clone needed)
49
+ uv tool install bookstack-cli # from PyPI, once published
50
+ # or from source:
51
+ # uv tool install git+https://github.com/mzehrer/bookstack-cli.git
52
+
53
+ # Or clone for development
54
+ cd bookstack-cli
55
+ make init # or: uv sync
56
+ ```
57
+
58
+ ## Setup
59
+
60
+ ```bash
61
+ bookstack auth # interactive — prompts for URL, token, secret
62
+
63
+ # If public web URL differs from API (e.g. behind OAuth proxy):
64
+ bookstack auth --resolve-url https://wiki.public.example.com
65
+ ```
66
+
67
+ ### Config File
68
+
69
+ Saved to `~/.config/bookstack-cli/config.toml`:
70
+
71
+ ```toml
72
+ [connection]
73
+ url = "http://10.0.0.1:8080" # API endpoint (internal)
74
+ resolve_url = "https://wiki.public.example.com" # public web URL (optional)
75
+ token_id = "<your-token-id>"
76
+ token_secret = "<your-token-secret>"
77
+ ```
78
+
79
+ `resolve_url` is optional — defaults to `url` if not set.
80
+
81
+ ### Env Vars (override file)
82
+
83
+ ```bash
84
+ export BOOKSTACK_URL=http://10.0.0.1:8080
85
+ export BOOKSTACK_RESOLVE_URL=https://wiki.example.com
86
+ export BOOKSTACK_TOKEN_ID=<your-token-id>
87
+ export BOOKSTACK_TOKEN_SECRET=<your-token-secret>
88
+ ```
89
+
90
+ Precedence: env vars > config file > error.
91
+
92
+ [See auth docs →](docs/authentication.md)
93
+
94
+ ## Usage
95
+
96
+ ```
97
+ $ bookstack --help
98
+
99
+ ╭─ Commands ───────────────────────────────────────╮
100
+ │ auth Save connection credentials. │
101
+ │ config Manage connection config. │
102
+ │ test Test connection to BookStack. │
103
+ │ shelves Manage bookshelves. │
104
+ │ books Manage books. │
105
+ │ chapters Manage chapters. │
106
+ │ pages Manage pages. │
107
+ │ attachments Manage attachments. │
108
+ │ users Manage users (admin). │
109
+ │ roles Manage roles (admin). │
110
+ │ search Search content. │
111
+ ╰───────────────────────────────────────────────────╯
112
+ ```
113
+
114
+ ### Common Workflows
115
+
116
+ ```bash
117
+ # Test connection
118
+ bookstack test
119
+
120
+ # List all books
121
+ bookstack books list
122
+
123
+ # Get a specific page
124
+ bookstack pages get 42
125
+
126
+ # Create a page from file
127
+ bookstack pages create "My Page" --book-id 1 --markdown-file content.md
128
+
129
+ # Pipe multi-line content
130
+ cat content.md | bookstack pages create "Piped Page" --book-id 1
131
+
132
+ # Append text to existing page
133
+ bookstack pages update 42 --append "New section at the end"
134
+
135
+ # Resolve web URL to page
136
+ bookstack pages resolve-url "https://wiki/books/my-book/page/my-page"
137
+
138
+ # Import markdown with images
139
+ bookstack pages import --file article.md --book-id 1 --name "Article"
140
+
141
+ # Search across all content
142
+ bookstack search query "installation guide"
143
+
144
+ # List attachments on a page
145
+ bookstack attachments list --page-id 10
146
+
147
+ # Upload a file attachment
148
+ bookstack attachments upload --name "Report" --page-id 42 --file report.pdf
149
+
150
+ # Create a shelf and assign books
151
+ bookstack shelves create "Dev Docs"
152
+ bookstack shelves update 1 "Dev Docs" --books "10,20,30"
153
+
154
+ # Update entity
155
+ bookstack books update 1 "New Title"
156
+ ```
157
+
158
+ ## Features
159
+
160
+ | Feature | Status |
161
+ |---|---|
162
+ | Shelves CRUD (+ book assignment) | ✅ |
163
+ | Books CRUD | ✅ |
164
+ | Chapters CRUD | ✅ |
165
+ | Pages CRUD (partial update, append, move) | ✅ |
166
+ | Markdown import with image handling | ✅ |
167
+ | Web URL → API ID resolution | ✅ |
168
+ | Attachments (link + file upload) | ✅ |
169
+ | Search across content | ✅ |
170
+ | Users/Roles (admin) | ✅ |
171
+ | Async HTTP with retry/backoff | ✅ |
172
+ | Auto-pagination (client-side filtering) | ✅ |
173
+ | Config test / connection check | ✅ |
174
+ | JSON-only output | ✅ |
175
+
176
+ ## Project Layout
177
+
178
+ ```
179
+ bookstack-cli/
180
+ ├── bookstack_cli/
181
+ │ ├── client.py # HTTP client, auth, rate-limit, pagination
182
+ │ ├── config.py # Env vars → ~/.config/bookstack-cli/config.toml
183
+ │ ├── exceptions.py # Typed error hierarchy
184
+ │ ├── models.py # Pydantic models for all entities
185
+ │ ├── main.py # Typer CLI entry point
186
+ │ └── resources/ # One module per entity
187
+ │ ├── books.py
188
+ │ ├── chapters.py
189
+ │ ├── pages.py
190
+ │ ├── shelves.py
191
+ │ ├── attachments.py
192
+ │ ├── search.py
193
+ │ ├── users.py
194
+ │ ├── roles.py
195
+ │ ├── revisions.py
196
+ │ └── tags.py
197
+ ├── tests/ # 130+ tests
198
+ ├── docs/ # Detailed docs
199
+ ├── skill/ # Pi agent skill
200
+ ├── Makefile # init/test/lint/format/run
201
+ └── pyproject.toml
202
+ ```
203
+
204
+ ## Documentation
205
+
206
+ | File | What |
207
+ |---|---|
208
+ | [docs/overview.md](docs/overview.md) | Architecture, goals, scope |
209
+ | [docs/authentication.md](docs/authentication.md) | Token setup, env config, security |
210
+ | [docs/api-reference.md](docs/api-reference.md) | All endpoints, schemas, pagination |
211
+ | [docs/integration-guide.md](docs/integration-guide.md) | Hacking BookStack, injections, webhooks |
212
+ | [docs/research.md](docs/research.md) | Raw API research findings |
213
+ | [skill/SKILL.md](skill/SKILL.md) | Agent skill for pi/coding agents |
214
+ | [AGENT.md](AGENT.md) | TDD protocol for this project |
215
+
216
+ ## Design
217
+
218
+ - **Async from day one** — `httpx.AsyncClient` with retry + exponential backoff on 429s
219
+ - **Pydantic v2** — typed models for every entity, validated responses
220
+ - **Agent-friendly output** — everything is JSON via stdout, no interactive prompts
221
+ - **Resource-per-file** — one module per entity, consistent `list/get/create/update/delete` signatures
222
+ - **Config cascade** — env vars > `~/.config/bookstack-cli/config.toml` > error
223
+ - **TDD** — 130 tests, red/green/refactor cycle (see [AGENT.md](AGENT.md))
224
+
225
+ ## License
226
+
227
+ MIT
@@ -0,0 +1,197 @@
1
+ # bookstack-cli
2
+
3
+ [![Python](https://img.shields.io/badge/python-≥3.14-blue)]()
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)]()
5
+
6
+ CLI for coding agents to interact with a [BookStack](https://www.bookstackapp.com/) wiki via its REST API. All output is JSON — built for LLM pipelines, not humans.
7
+
8
+ ```bash
9
+ bookstack books list | jq '. | length'
10
+ bookstack pages get 42 | jq '.html[:200]'
11
+ bookstack search query "api docs" | jq '.[] | {name, type, score}'
12
+ bookstack test
13
+ ```
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ # One-liner (no clone needed)
19
+ uv tool install bookstack-cli # from PyPI, once published
20
+ # or from source:
21
+ # uv tool install git+https://github.com/mzehrer/bookstack-cli.git
22
+
23
+ # Or clone for development
24
+ cd bookstack-cli
25
+ make init # or: uv sync
26
+ ```
27
+
28
+ ## Setup
29
+
30
+ ```bash
31
+ bookstack auth # interactive — prompts for URL, token, secret
32
+
33
+ # If public web URL differs from API (e.g. behind OAuth proxy):
34
+ bookstack auth --resolve-url https://wiki.public.example.com
35
+ ```
36
+
37
+ ### Config File
38
+
39
+ Saved to `~/.config/bookstack-cli/config.toml`:
40
+
41
+ ```toml
42
+ [connection]
43
+ url = "http://10.0.0.1:8080" # API endpoint (internal)
44
+ resolve_url = "https://wiki.public.example.com" # public web URL (optional)
45
+ token_id = "<your-token-id>"
46
+ token_secret = "<your-token-secret>"
47
+ ```
48
+
49
+ `resolve_url` is optional — defaults to `url` if not set.
50
+
51
+ ### Env Vars (override file)
52
+
53
+ ```bash
54
+ export BOOKSTACK_URL=http://10.0.0.1:8080
55
+ export BOOKSTACK_RESOLVE_URL=https://wiki.example.com
56
+ export BOOKSTACK_TOKEN_ID=<your-token-id>
57
+ export BOOKSTACK_TOKEN_SECRET=<your-token-secret>
58
+ ```
59
+
60
+ Precedence: env vars > config file > error.
61
+
62
+ [See auth docs →](docs/authentication.md)
63
+
64
+ ## Usage
65
+
66
+ ```
67
+ $ bookstack --help
68
+
69
+ ╭─ Commands ───────────────────────────────────────╮
70
+ │ auth Save connection credentials. │
71
+ │ config Manage connection config. │
72
+ │ test Test connection to BookStack. │
73
+ │ shelves Manage bookshelves. │
74
+ │ books Manage books. │
75
+ │ chapters Manage chapters. │
76
+ │ pages Manage pages. │
77
+ │ attachments Manage attachments. │
78
+ │ users Manage users (admin). │
79
+ │ roles Manage roles (admin). │
80
+ │ search Search content. │
81
+ ╰───────────────────────────────────────────────────╯
82
+ ```
83
+
84
+ ### Common Workflows
85
+
86
+ ```bash
87
+ # Test connection
88
+ bookstack test
89
+
90
+ # List all books
91
+ bookstack books list
92
+
93
+ # Get a specific page
94
+ bookstack pages get 42
95
+
96
+ # Create a page from file
97
+ bookstack pages create "My Page" --book-id 1 --markdown-file content.md
98
+
99
+ # Pipe multi-line content
100
+ cat content.md | bookstack pages create "Piped Page" --book-id 1
101
+
102
+ # Append text to existing page
103
+ bookstack pages update 42 --append "New section at the end"
104
+
105
+ # Resolve web URL to page
106
+ bookstack pages resolve-url "https://wiki/books/my-book/page/my-page"
107
+
108
+ # Import markdown with images
109
+ bookstack pages import --file article.md --book-id 1 --name "Article"
110
+
111
+ # Search across all content
112
+ bookstack search query "installation guide"
113
+
114
+ # List attachments on a page
115
+ bookstack attachments list --page-id 10
116
+
117
+ # Upload a file attachment
118
+ bookstack attachments upload --name "Report" --page-id 42 --file report.pdf
119
+
120
+ # Create a shelf and assign books
121
+ bookstack shelves create "Dev Docs"
122
+ bookstack shelves update 1 "Dev Docs" --books "10,20,30"
123
+
124
+ # Update entity
125
+ bookstack books update 1 "New Title"
126
+ ```
127
+
128
+ ## Features
129
+
130
+ | Feature | Status |
131
+ |---|---|
132
+ | Shelves CRUD (+ book assignment) | ✅ |
133
+ | Books CRUD | ✅ |
134
+ | Chapters CRUD | ✅ |
135
+ | Pages CRUD (partial update, append, move) | ✅ |
136
+ | Markdown import with image handling | ✅ |
137
+ | Web URL → API ID resolution | ✅ |
138
+ | Attachments (link + file upload) | ✅ |
139
+ | Search across content | ✅ |
140
+ | Users/Roles (admin) | ✅ |
141
+ | Async HTTP with retry/backoff | ✅ |
142
+ | Auto-pagination (client-side filtering) | ✅ |
143
+ | Config test / connection check | ✅ |
144
+ | JSON-only output | ✅ |
145
+
146
+ ## Project Layout
147
+
148
+ ```
149
+ bookstack-cli/
150
+ ├── bookstack_cli/
151
+ │ ├── client.py # HTTP client, auth, rate-limit, pagination
152
+ │ ├── config.py # Env vars → ~/.config/bookstack-cli/config.toml
153
+ │ ├── exceptions.py # Typed error hierarchy
154
+ │ ├── models.py # Pydantic models for all entities
155
+ │ ├── main.py # Typer CLI entry point
156
+ │ └── resources/ # One module per entity
157
+ │ ├── books.py
158
+ │ ├── chapters.py
159
+ │ ├── pages.py
160
+ │ ├── shelves.py
161
+ │ ├── attachments.py
162
+ │ ├── search.py
163
+ │ ├── users.py
164
+ │ ├── roles.py
165
+ │ ├── revisions.py
166
+ │ └── tags.py
167
+ ├── tests/ # 130+ tests
168
+ ├── docs/ # Detailed docs
169
+ ├── skill/ # Pi agent skill
170
+ ├── Makefile # init/test/lint/format/run
171
+ └── pyproject.toml
172
+ ```
173
+
174
+ ## Documentation
175
+
176
+ | File | What |
177
+ |---|---|
178
+ | [docs/overview.md](docs/overview.md) | Architecture, goals, scope |
179
+ | [docs/authentication.md](docs/authentication.md) | Token setup, env config, security |
180
+ | [docs/api-reference.md](docs/api-reference.md) | All endpoints, schemas, pagination |
181
+ | [docs/integration-guide.md](docs/integration-guide.md) | Hacking BookStack, injections, webhooks |
182
+ | [docs/research.md](docs/research.md) | Raw API research findings |
183
+ | [skill/SKILL.md](skill/SKILL.md) | Agent skill for pi/coding agents |
184
+ | [AGENT.md](AGENT.md) | TDD protocol for this project |
185
+
186
+ ## Design
187
+
188
+ - **Async from day one** — `httpx.AsyncClient` with retry + exponential backoff on 429s
189
+ - **Pydantic v2** — typed models for every entity, validated responses
190
+ - **Agent-friendly output** — everything is JSON via stdout, no interactive prompts
191
+ - **Resource-per-file** — one module per entity, consistent `list/get/create/update/delete` signatures
192
+ - **Config cascade** — env vars > `~/.config/bookstack-cli/config.toml` > error
193
+ - **TDD** — 130 tests, red/green/refactor cycle (see [AGENT.md](AGENT.md))
194
+
195
+ ## License
196
+
197
+ MIT
@@ -0,0 +1,3 @@
1
+ """bookstack-cli: CLI tool for coding agents to interact with BookStack wiki."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,211 @@
1
+ """Async HTTP client for BookStack API with auth, retry, pagination."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from collections.abc import AsyncIterator
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from bookstack_cli.config import get_config
11
+ from bookstack_cli.exceptions import (
12
+ BookStackRateLimitError,
13
+ map_status_to_error,
14
+ )
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ BASE_DELAY = 1.0
19
+ MAX_RETRIES = 5
20
+ MAX_PAGE_SIZE = 500
21
+
22
+
23
+ class BookStackClient:
24
+ """Async HTTP client for BookStack REST API.
25
+
26
+ Handles:
27
+ - Auth header injection (Token token_id:token_secret)
28
+ - Rate-limit retry with exponential backoff
29
+ - Auto-pagination via async generator
30
+ - Error mapping to typed exceptions
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ base_url: str | None = None,
36
+ token_id: str | None = None,
37
+ token_secret: str | None = None,
38
+ timeout: float = 30.0,
39
+ ) -> None:
40
+ if base_url and token_id and token_secret:
41
+ self._base_url = base_url.rstrip("/")
42
+ self._token_id = token_id
43
+ self._token_secret = token_secret
44
+ else:
45
+ cfg = get_config()
46
+ self._base_url = cfg.url
47
+ self._token_id = cfg.token_id
48
+ self._token_secret = cfg.token_secret
49
+
50
+ auth_header_value = f"Token {self._token_id}:{self._token_secret}"
51
+
52
+ self._client = httpx.AsyncClient(
53
+ base_url=self._base_url,
54
+ headers={
55
+ "Authorization": auth_header_value,
56
+ "Accept": "application/json",
57
+ "Content-Type": "application/json",
58
+ },
59
+ timeout=httpx.Timeout(timeout),
60
+ )
61
+
62
+ async def close(self) -> None:
63
+ """Close the underlying HTTP client."""
64
+ await self._client.aclose()
65
+
66
+ async def __aenter__(self) -> "BookStackClient":
67
+ return self
68
+
69
+ async def __aexit__(self, *args: Any) -> None:
70
+ await self.close()
71
+
72
+ def __enter__(self) -> "BookStackClient":
73
+ return self
74
+
75
+ def __exit__(self, *args: Any) -> None:
76
+ import asyncio
77
+ try:
78
+ loop = asyncio.get_event_loop()
79
+ except RuntimeError:
80
+ loop = asyncio.new_event_loop()
81
+ asyncio.set_event_loop(loop)
82
+ loop.run_until_complete(self.close())
83
+
84
+ # ------------------------------------------------------------------
85
+ # Request with retry
86
+ # ------------------------------------------------------------------
87
+
88
+ async def _request(
89
+ self,
90
+ method: str,
91
+ path: str,
92
+ retry_count: int = 0,
93
+ **kwargs: Any,
94
+ ) -> httpx.Response:
95
+ """Send HTTP request with rate-limit retry."""
96
+ url = f"/api/{path.lstrip('/')}"
97
+ # Remove Content-Type for multipart (httpx sets correct boundary)
98
+ has_files = "files" in kwargs
99
+ if has_files:
100
+ old_ct = self._client.headers.pop("Content-Type", None)
101
+ try:
102
+ response = await self._client.request(method, url, **kwargs)
103
+ finally:
104
+ if has_files and old_ct is not None:
105
+ self._client.headers["Content-Type"] = old_ct
106
+
107
+ if response.status_code == 429 and retry_count < MAX_RETRIES:
108
+ retry_after = _parse_retry_after(response)
109
+ delay = max(retry_after, BASE_DELAY * (2**retry_count))
110
+ logger.warning(
111
+ "Rate limited. Retry %d/%d after %.1fs",
112
+ retry_count + 1,
113
+ MAX_RETRIES,
114
+ delay,
115
+ )
116
+ await asyncio.sleep(delay)
117
+ return await self._request(method, path, retry_count + 1, **kwargs)
118
+
119
+ if response.is_error:
120
+ _raise_for_status(response)
121
+
122
+ return response
123
+
124
+ # ------------------------------------------------------------------
125
+ # CRUD helpers
126
+ # ------------------------------------------------------------------
127
+
128
+ async def get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
129
+ """GET request returning parsed JSON."""
130
+ response = await self._request("GET", path, params=params)
131
+ return response.json()
132
+
133
+ async def get_raw(self, path: str, params: dict[str, Any] | None = None) -> httpx.Response:
134
+ """GET request returning raw response (e.g. for binary downloads)."""
135
+ return await self._request("GET", path, params=params)
136
+
137
+ async def post(
138
+ self, path: str, json: dict[str, Any] | None = None, data: dict[str, Any] | None = None
139
+ ) -> dict[str, Any]:
140
+ """POST request returning parsed JSON."""
141
+ response = await self._request("POST", path, json=json, data=data)
142
+ return response.json()
143
+
144
+ async def put(self, path: str, json: dict[str, Any] | None = None) -> dict[str, Any]:
145
+ """PUT request returning parsed JSON."""
146
+ response = await self._request("PUT", path, json=json)
147
+ return response.json()
148
+
149
+ async def delete(self, path: str) -> None:
150
+ """DELETE request."""
151
+ await self._request("DELETE", path)
152
+
153
+ # ------------------------------------------------------------------
154
+ # Pagination
155
+ # ------------------------------------------------------------------
156
+
157
+ async def paginate(
158
+ self,
159
+ path: str,
160
+ params: dict[str, Any] | None = None,
161
+ page_size: int = 100,
162
+ ) -> AsyncIterator[dict[str, Any]]:
163
+ """Iterate over all pages of a list endpoint.
164
+
165
+ Yields individual items from ``data`` across all pages.
166
+ """
167
+ params = dict(params or {})
168
+ params.setdefault("count", min(page_size, MAX_PAGE_SIZE))
169
+ page = 1
170
+
171
+ while True:
172
+ params["page"] = page
173
+ data = await self.get(path, params=params)
174
+ items: list[dict[str, Any]] = data.get("data", [])
175
+ for item in items:
176
+ yield item
177
+
178
+ total: int = data.get("total", 0)
179
+ per_page = data.get("per_page")
180
+ if per_page is None:
181
+ per_page = len(items) or page_size
182
+ if page * per_page >= total:
183
+ break
184
+ page += 1
185
+
186
+
187
+ def _parse_retry_after(response: httpx.Response) -> float:
188
+ """Extract Retry-After header value as float."""
189
+ val = response.headers.get("Retry-After", "1")
190
+ try:
191
+ return float(val)
192
+ except ValueError:
193
+ return 1.0
194
+
195
+
196
+ def _raise_for_status(response: httpx.Response) -> None:
197
+ """Map HTTP status to typed BookStack exception."""
198
+ try:
199
+ body = response.json()
200
+ error = body.get("error", {})
201
+ message = str(error.get("message", response.reason_phrase))
202
+ validation = error.get("validation")
203
+ if validation:
204
+ details = "; ".join(
205
+ f"{k}: {', '.join(v)}" for k, v in validation.items()
206
+ )
207
+ message = f"{message} ({details})"
208
+ except Exception:
209
+ message = response.reason_phrase or "Unknown error"
210
+
211
+ raise map_status_to_error(response.status_code, message)