bookstack-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bookstack_cli/__init__.py +3 -0
- bookstack_cli/client.py +211 -0
- bookstack_cli/config.py +131 -0
- bookstack_cli/exceptions.py +45 -0
- bookstack_cli/main.py +840 -0
- bookstack_cli/models.py +276 -0
- bookstack_cli/resources/__init__.py +1 -0
- bookstack_cli/resources/attachments.py +90 -0
- bookstack_cli/resources/books.py +64 -0
- bookstack_cli/resources/chapters.py +46 -0
- bookstack_cli/resources/pages.py +365 -0
- bookstack_cli/resources/revisions.py +30 -0
- bookstack_cli/resources/roles.py +33 -0
- bookstack_cli/resources/search.py +21 -0
- bookstack_cli/resources/shelves.py +55 -0
- bookstack_cli/resources/tags.py +15 -0
- bookstack_cli/resources/users.py +39 -0
- bookstack_cli-0.1.0.dist-info/METADATA +227 -0
- bookstack_cli-0.1.0.dist-info/RECORD +22 -0
- bookstack_cli-0.1.0.dist-info/WHEEL +5 -0
- bookstack_cli-0.1.0.dist-info/entry_points.txt +2 -0
- bookstack_cli-0.1.0.dist-info/top_level.txt +1 -0
bookstack_cli/client.py
ADDED
|
@@ -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)
|
bookstack_cli/config.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Configuration loader for BookStack connection settings.
|
|
2
|
+
|
|
3
|
+
Config file: ~/.config/bookstack-cli/config.toml
|
|
4
|
+
|
|
5
|
+
```toml
|
|
6
|
+
[connection]
|
|
7
|
+
url = "http://10.0.0.1:8080" # API endpoint (internal)
|
|
8
|
+
resolve_url = "https://wiki.example.com" # Public web URL (optional, falls back to url)
|
|
9
|
+
token_id = "<your-token-id>"
|
|
10
|
+
token_secret = "<your-token-secret>"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Env vars override file values:
|
|
14
|
+
- BOOKSTACK_URL
|
|
15
|
+
- BOOKSTACK_RESOLVE_URL
|
|
16
|
+
- BOOKSTACK_TOKEN_ID
|
|
17
|
+
- BOOKSTACK_TOKEN_SECRET
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import tomllib
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import NamedTuple
|
|
24
|
+
|
|
25
|
+
from bookstack_cli.exceptions import BookStackConfigError
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BookStackConfig(NamedTuple):
|
|
29
|
+
"""Connection configuration for a BookStack instance."""
|
|
30
|
+
|
|
31
|
+
url: str
|
|
32
|
+
token_id: str
|
|
33
|
+
token_secret: str
|
|
34
|
+
resolve_url: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
CONFIG_DIR = Path.home() / ".config" / "bookstack-cli"
|
|
38
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _load_env() -> BookStackConfig | None:
|
|
42
|
+
"""Load config from environment variables."""
|
|
43
|
+
url = os.environ.get("BOOKSTACK_URL")
|
|
44
|
+
token_id = os.environ.get("BOOKSTACK_TOKEN_ID")
|
|
45
|
+
token_secret = os.environ.get("BOOKSTACK_TOKEN_SECRET")
|
|
46
|
+
if url and token_id and token_secret:
|
|
47
|
+
resolve_url = os.environ.get("BOOKSTACK_RESOLVE_URL") or url
|
|
48
|
+
return BookStackConfig(
|
|
49
|
+
url=url.rstrip("/"),
|
|
50
|
+
token_id=token_id,
|
|
51
|
+
token_secret=token_secret,
|
|
52
|
+
resolve_url=resolve_url.rstrip("/"),
|
|
53
|
+
)
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _load_toml() -> BookStackConfig | None:
|
|
58
|
+
"""Load config from ~/.config/bookstack-cli/config.toml."""
|
|
59
|
+
if not CONFIG_FILE.exists():
|
|
60
|
+
return None
|
|
61
|
+
with open(CONFIG_FILE, "rb") as f:
|
|
62
|
+
data = tomllib.load(f)
|
|
63
|
+
conn = data.get("connection", {})
|
|
64
|
+
url = conn.get("url")
|
|
65
|
+
token_id = conn.get("token_id")
|
|
66
|
+
token_secret = conn.get("token_secret")
|
|
67
|
+
if url and token_id and token_secret:
|
|
68
|
+
resolve_url = conn.get("resolve_url") or url
|
|
69
|
+
return BookStackConfig(
|
|
70
|
+
url=url.rstrip("/"),
|
|
71
|
+
token_id=token_id,
|
|
72
|
+
token_secret=token_secret,
|
|
73
|
+
resolve_url=resolve_url.rstrip("/"),
|
|
74
|
+
)
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_config() -> BookStackConfig:
|
|
79
|
+
"""Load config from env vars (priority) or TOML file.
|
|
80
|
+
|
|
81
|
+
Precedence:
|
|
82
|
+
1. BOOKSTACK_URL, BOOKSTACK_RESOLVE_URL, BOOKSTACK_TOKEN_ID,
|
|
83
|
+
BOOKSTACK_TOKEN_SECRET env vars
|
|
84
|
+
2. ~/.config/bookstack-cli/config.toml
|
|
85
|
+
|
|
86
|
+
Raises BookStackConfigError if neither source has complete config.
|
|
87
|
+
"""
|
|
88
|
+
env_cfg = _load_env()
|
|
89
|
+
if env_cfg:
|
|
90
|
+
return env_cfg
|
|
91
|
+
|
|
92
|
+
toml_cfg = _load_toml()
|
|
93
|
+
if toml_cfg:
|
|
94
|
+
return toml_cfg
|
|
95
|
+
|
|
96
|
+
raise BookStackConfigError(
|
|
97
|
+
"BookStack config not found. "
|
|
98
|
+
"Run `bookstack auth` or set BOOKSTACK_URL, BOOKSTACK_TOKEN_ID, "
|
|
99
|
+
"BOOKSTACK_TOKEN_SECRET env vars."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def save_config(url: str, token_id: str, token_secret: str, resolve_url: str | None = None) -> Path:
|
|
104
|
+
"""Save connection to ~/.config/bookstack-cli/config.toml.
|
|
105
|
+
|
|
106
|
+
Creates parent directories if needed.
|
|
107
|
+
Returns the path to the written file.
|
|
108
|
+
"""
|
|
109
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
lines = [
|
|
111
|
+
'[connection]',
|
|
112
|
+
f'url = "{_escape_toml(url.rstrip("/"))}"',
|
|
113
|
+
]
|
|
114
|
+
if resolve_url:
|
|
115
|
+
lines.append(f'resolve_url = "{_escape_toml(resolve_url.rstrip("/"))}"')
|
|
116
|
+
lines.extend([
|
|
117
|
+
f'token_id = "{_escape_toml(token_id)}"',
|
|
118
|
+
f'token_secret = "{_escape_toml(token_secret)}"',
|
|
119
|
+
])
|
|
120
|
+
CONFIG_FILE.write_text("\n".join(lines) + "\n")
|
|
121
|
+
return CONFIG_FILE
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _escape_toml(value: str) -> str:
|
|
125
|
+
"""Escape special chars for TOML basic string."""
|
|
126
|
+
return (
|
|
127
|
+
value.replace("\\", "\\\\")
|
|
128
|
+
.replace('"', '\\"')
|
|
129
|
+
.replace("\n", "\\n")
|
|
130
|
+
.replace("\t", "\\t")
|
|
131
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Custom exceptions for BookStack API errors."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class BookStackError(Exception):
|
|
5
|
+
"""Base exception for all BookStack API errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BookStackAuthError(BookStackError):
|
|
9
|
+
"""Authentication failed (401)."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BookStackNotFoundError(BookStackError):
|
|
13
|
+
"""Resource not found (404)."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BookStackRateLimitError(BookStackError):
|
|
17
|
+
"""Rate limit exceeded (429)."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BookStackServerError(BookStackError):
|
|
21
|
+
"""Server error (5xx)."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BookStackValidationError(BookStackError):
|
|
25
|
+
"""Validation error (422)."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BookStackConfigError(BookStackError):
|
|
29
|
+
"""Configuration error (missing URL or credentials)."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
STATUS_ERROR_MAP: dict[int, type[BookStackError]] = {
|
|
33
|
+
401: BookStackAuthError,
|
|
34
|
+
404: BookStackNotFoundError,
|
|
35
|
+
422: BookStackValidationError,
|
|
36
|
+
429: BookStackRateLimitError,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def map_status_to_error(status_code: int, message: str) -> BookStackError:
|
|
41
|
+
"""Map HTTP status code to appropriate exception."""
|
|
42
|
+
exc_cls = STATUS_ERROR_MAP.get(status_code, BookStackError)
|
|
43
|
+
if status_code >= 500:
|
|
44
|
+
return BookStackServerError(message)
|
|
45
|
+
return exc_cls(message)
|