wagapi 0.2.2__tar.gz → 0.3.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.
- {wagapi-0.2.2 → wagapi-0.3.0}/PKG-INFO +9 -7
- {wagapi-0.2.2 → wagapi-0.3.0}/README.md +8 -6
- {wagapi-0.2.2 → wagapi-0.3.0}/pyproject.toml +1 -1
- {wagapi-0.2.2 → wagapi-0.3.0}/tests/conftest.py +17 -0
- {wagapi-0.2.2 → wagapi-0.3.0}/tests/test_commands.py +66 -2
- wagapi-0.3.0/tests/test_formatting.py +173 -0
- wagapi-0.3.0/wagapi/__init__.py +1 -0
- {wagapi-0.2.2 → wagapi-0.3.0}/wagapi/cli.py +6 -1
- {wagapi-0.2.2 → wagapi-0.3.0}/wagapi/commands/pages.py +22 -3
- {wagapi-0.2.2 → wagapi-0.3.0}/wagapi/formatting/output.py +54 -5
- wagapi-0.2.2/wagapi/__init__.py +0 -1
- {wagapi-0.2.2 → wagapi-0.3.0}/.github/workflows/publish.yml +0 -0
- {wagapi-0.2.2 → wagapi-0.3.0}/.gitignore +0 -0
- {wagapi-0.2.2 → wagapi-0.3.0}/tests/__init__.py +0 -0
- {wagapi-0.2.2 → wagapi-0.3.0}/tests/test_client.py +0 -0
- {wagapi-0.2.2 → wagapi-0.3.0}/tests/test_config.py +0 -0
- {wagapi-0.2.2 → wagapi-0.3.0}/tests/test_markdown.py +0 -0
- {wagapi-0.2.2 → wagapi-0.3.0}/wagapi/__main__.py +0 -0
- {wagapi-0.2.2 → wagapi-0.3.0}/wagapi/client.py +0 -0
- {wagapi-0.2.2 → wagapi-0.3.0}/wagapi/commands/__init__.py +0 -0
- {wagapi-0.2.2 → wagapi-0.3.0}/wagapi/commands/images.py +0 -0
- {wagapi-0.2.2 → wagapi-0.3.0}/wagapi/commands/init.py +0 -0
- {wagapi-0.2.2 → wagapi-0.3.0}/wagapi/commands/schema.py +0 -0
- {wagapi-0.2.2 → wagapi-0.3.0}/wagapi/config.py +0 -0
- {wagapi-0.2.2 → wagapi-0.3.0}/wagapi/exceptions.py +0 -0
- {wagapi-0.2.2 → wagapi-0.3.0}/wagapi/formatting/__init__.py +0 -0
- {wagapi-0.2.2 → wagapi-0.3.0}/wagapi/formatting/markdown.py +0 -0
- {wagapi-0.2.2 → wagapi-0.3.0}/wagapi/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wagapi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: CLI client for the Wagtail Write API, optimised for LLM orchestration
|
|
5
5
|
Project-URL: Repository, https://github.com/tomdyson/wagapi
|
|
6
6
|
Project-URL: Issues, https://github.com/tomdyson/wagapi/issues
|
|
@@ -64,10 +64,10 @@ uv tool install wagapi
|
|
|
64
64
|
|
|
65
65
|
### 1. Set up a Wagtail site with wagtail-write-api
|
|
66
66
|
|
|
67
|
-
Follow the [example app guide](https://tomdyson.github.io/wagtail-write-api/development/example-app/) to get a local Wagtail instance running:
|
|
67
|
+
Follow the [example app guide](https://tomdyson.github.io/wagtail-write-api/development/example-app/) to get a local Wagtail instance running with [wagtail-write-api](https://github.com/tomdyson/wagtail-write-api):
|
|
68
68
|
|
|
69
69
|
```bash
|
|
70
|
-
cd example
|
|
70
|
+
cd wagtail-write-api/example
|
|
71
71
|
uv run python manage.py migrate
|
|
72
72
|
uv run python manage.py seed_demo
|
|
73
73
|
uv run python manage.py runserver
|
|
@@ -168,7 +168,7 @@ wagapi pages delete <ID>
|
|
|
168
168
|
wagapi -v pages get <ID>
|
|
169
169
|
|
|
170
170
|
# Preview without executing
|
|
171
|
-
wagapi --dry-run pages create testapp.SimplePage --parent /
|
|
171
|
+
wagapi --dry-run pages create testapp.SimplePage --parent / --title "Test"
|
|
172
172
|
```
|
|
173
173
|
|
|
174
174
|
### 8. Pipe-friendly JSON output
|
|
@@ -255,8 +255,8 @@ wagapi pages list [OPTIONS]
|
|
|
255
255
|
| Option | Description |
|
|
256
256
|
|---|---|
|
|
257
257
|
| `--type TYPE` | Filter by page type, e.g. `testapp.BlogPage` |
|
|
258
|
-
| `--parent
|
|
259
|
-
| `--descendant-of
|
|
258
|
+
| `--parent ID_OR_PATH` | Direct children of page ID or URL path (e.g. `5` or `/blog/`) |
|
|
259
|
+
| `--descendant-of ID_OR_PATH` | All descendants of page ID or URL path |
|
|
260
260
|
| `--status STATUS` | `draft`, `live`, or `live+draft` |
|
|
261
261
|
| `--slug SLUG` | Exact slug match |
|
|
262
262
|
| `--path PATH` | Exact URL path match, e.g. `/blog/my-post/` |
|
|
@@ -288,7 +288,9 @@ wagapi pages create <type> --parent ID_OR_PATH --title TITLE [OPTIONS]
|
|
|
288
288
|
| `--publish` | Publish immediately (default: create as draft) |
|
|
289
289
|
| `--raw` | Treat field values as raw JSON (no auto-wrapping) |
|
|
290
290
|
|
|
291
|
-
**Markdown body (auto-
|
|
291
|
+
**Markdown body (auto-detected: StreamField blocks or RichTextField HTML):**
|
|
292
|
+
|
|
293
|
+
`--body` checks the page type schema to determine the field type. For StreamField fields, markdown is converted to blocks. For RichTextField fields, markdown is sent as-is for server-side conversion to HTML.
|
|
292
294
|
|
|
293
295
|
```bash
|
|
294
296
|
wagapi pages create testapp.BlogPage --parent /blog/ \
|
|
@@ -34,10 +34,10 @@ uv tool install wagapi
|
|
|
34
34
|
|
|
35
35
|
### 1. Set up a Wagtail site with wagtail-write-api
|
|
36
36
|
|
|
37
|
-
Follow the [example app guide](https://tomdyson.github.io/wagtail-write-api/development/example-app/) to get a local Wagtail instance running:
|
|
37
|
+
Follow the [example app guide](https://tomdyson.github.io/wagtail-write-api/development/example-app/) to get a local Wagtail instance running with [wagtail-write-api](https://github.com/tomdyson/wagtail-write-api):
|
|
38
38
|
|
|
39
39
|
```bash
|
|
40
|
-
cd example
|
|
40
|
+
cd wagtail-write-api/example
|
|
41
41
|
uv run python manage.py migrate
|
|
42
42
|
uv run python manage.py seed_demo
|
|
43
43
|
uv run python manage.py runserver
|
|
@@ -138,7 +138,7 @@ wagapi pages delete <ID>
|
|
|
138
138
|
wagapi -v pages get <ID>
|
|
139
139
|
|
|
140
140
|
# Preview without executing
|
|
141
|
-
wagapi --dry-run pages create testapp.SimplePage --parent /
|
|
141
|
+
wagapi --dry-run pages create testapp.SimplePage --parent / --title "Test"
|
|
142
142
|
```
|
|
143
143
|
|
|
144
144
|
### 8. Pipe-friendly JSON output
|
|
@@ -225,8 +225,8 @@ wagapi pages list [OPTIONS]
|
|
|
225
225
|
| Option | Description |
|
|
226
226
|
|---|---|
|
|
227
227
|
| `--type TYPE` | Filter by page type, e.g. `testapp.BlogPage` |
|
|
228
|
-
| `--parent
|
|
229
|
-
| `--descendant-of
|
|
228
|
+
| `--parent ID_OR_PATH` | Direct children of page ID or URL path (e.g. `5` or `/blog/`) |
|
|
229
|
+
| `--descendant-of ID_OR_PATH` | All descendants of page ID or URL path |
|
|
230
230
|
| `--status STATUS` | `draft`, `live`, or `live+draft` |
|
|
231
231
|
| `--slug SLUG` | Exact slug match |
|
|
232
232
|
| `--path PATH` | Exact URL path match, e.g. `/blog/my-post/` |
|
|
@@ -258,7 +258,9 @@ wagapi pages create <type> --parent ID_OR_PATH --title TITLE [OPTIONS]
|
|
|
258
258
|
| `--publish` | Publish immediately (default: create as draft) |
|
|
259
259
|
| `--raw` | Treat field values as raw JSON (no auto-wrapping) |
|
|
260
260
|
|
|
261
|
-
**Markdown body (auto-
|
|
261
|
+
**Markdown body (auto-detected: StreamField blocks or RichTextField HTML):**
|
|
262
|
+
|
|
263
|
+
`--body` checks the page type schema to determine the field type. For StreamField fields, markdown is converted to blocks. For RichTextField fields, markdown is sent as-is for server-side conversion to HTML.
|
|
262
264
|
|
|
263
265
|
```bash
|
|
264
266
|
wagapi pages create testapp.BlogPage --parent /blog/ \
|
|
@@ -51,6 +51,23 @@ SAMPLE_PAGE_TYPE_SCHEMA = {
|
|
|
51
51
|
},
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
SAMPLE_RICHTEXT_PAGE_TYPE_SCHEMA = {
|
|
55
|
+
"name": "home.SimplePage",
|
|
56
|
+
"verbose_name": "simple page",
|
|
57
|
+
"create_schema": {
|
|
58
|
+
"required": ["type", "parent", "title"],
|
|
59
|
+
"properties": {
|
|
60
|
+
"type": {"type": "string"},
|
|
61
|
+
"parent": {"type": "integer"},
|
|
62
|
+
"title": {"type": "string"},
|
|
63
|
+
"body": {"type": "string", "description": "RichTextField body content"},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
"allowed_parents": ["wagtailcore.Page"],
|
|
67
|
+
"allowed_children": [],
|
|
68
|
+
"streamfield_blocks": {},
|
|
69
|
+
}
|
|
70
|
+
|
|
54
71
|
SAMPLE_PAGE = {
|
|
55
72
|
"id": 42,
|
|
56
73
|
"title": "Hello",
|
|
@@ -158,7 +158,12 @@ def test_pages_create(runner):
|
|
|
158
158
|
|
|
159
159
|
|
|
160
160
|
@respx.mock
|
|
161
|
-
def
|
|
161
|
+
def test_pages_create_with_body_streamfield(runner):
|
|
162
|
+
"""Body is converted to StreamField blocks when the field is a StreamField."""
|
|
163
|
+
schema = {"streamfield_blocks": {"body": [{"type": "heading"}, {"type": "paragraph"}]}}
|
|
164
|
+
respx.get(f"{BASE_URL}/schema/blog.BlogPage/").mock(
|
|
165
|
+
return_value=Response(200, json=schema)
|
|
166
|
+
)
|
|
162
167
|
data = {
|
|
163
168
|
"id": 43,
|
|
164
169
|
"title": "Iris",
|
|
@@ -179,13 +184,45 @@ def test_pages_create_with_body(runner):
|
|
|
179
184
|
],
|
|
180
185
|
)
|
|
181
186
|
assert result.exit_code == 0
|
|
182
|
-
# Verify the request body contained streamfield blocks
|
|
183
187
|
request_body = json.loads(route.calls[0].request.content)
|
|
184
188
|
assert isinstance(request_body["body"], list)
|
|
185
189
|
assert request_body["body"][0]["type"] == "heading"
|
|
186
190
|
assert request_body["body"][1]["type"] == "paragraph"
|
|
187
191
|
|
|
188
192
|
|
|
193
|
+
@respx.mock
|
|
194
|
+
def test_pages_create_with_body_richtext(runner):
|
|
195
|
+
"""Body is sent as richtext format dict when the field is a RichTextField."""
|
|
196
|
+
schema = {"streamfield_blocks": {}}
|
|
197
|
+
respx.get(f"{BASE_URL}/schema/home.SimplePage/").mock(
|
|
198
|
+
return_value=Response(200, json=schema)
|
|
199
|
+
)
|
|
200
|
+
data = {
|
|
201
|
+
"id": 50,
|
|
202
|
+
"title": "About",
|
|
203
|
+
"slug": "about",
|
|
204
|
+
"meta": {"type": "home.SimplePage", "live": False, "parent_id": 3},
|
|
205
|
+
}
|
|
206
|
+
route = respx.post(f"{BASE_URL}/pages/").mock(
|
|
207
|
+
return_value=Response(201, json=data)
|
|
208
|
+
)
|
|
209
|
+
with mock.patch.dict("os.environ", ENV):
|
|
210
|
+
result = runner.invoke(
|
|
211
|
+
cli,
|
|
212
|
+
[
|
|
213
|
+
"pages", "create", "home.SimplePage",
|
|
214
|
+
"--parent", "3",
|
|
215
|
+
"--title", "About",
|
|
216
|
+
"--body", "Hello **world**",
|
|
217
|
+
],
|
|
218
|
+
)
|
|
219
|
+
assert result.exit_code == 0
|
|
220
|
+
request_body = json.loads(route.calls[0].request.content)
|
|
221
|
+
assert isinstance(request_body["body"], dict)
|
|
222
|
+
assert request_body["body"]["format"] == "markdown"
|
|
223
|
+
assert request_body["body"]["content"] == "Hello **world**"
|
|
224
|
+
|
|
225
|
+
|
|
189
226
|
@respx.mock
|
|
190
227
|
def test_pages_create_with_path_parent(runner):
|
|
191
228
|
data = {
|
|
@@ -245,6 +282,33 @@ def test_pages_update(runner):
|
|
|
245
282
|
assert "Updated" in result.output
|
|
246
283
|
|
|
247
284
|
|
|
285
|
+
@respx.mock
|
|
286
|
+
def test_pages_update_with_body_richtext(runner):
|
|
287
|
+
"""Update fetches the page type and sends richtext for RichTextField."""
|
|
288
|
+
# Mock GET to fetch the page (to learn its type)
|
|
289
|
+
page_data = {
|
|
290
|
+
"id": 42, "title": "Old", "body": "<p>old</p>",
|
|
291
|
+
"meta": {"type": "home.SimplePage", "live": True},
|
|
292
|
+
}
|
|
293
|
+
respx.get(f"{BASE_URL}/pages/42/").mock(return_value=Response(200, json=page_data))
|
|
294
|
+
# Mock schema lookup
|
|
295
|
+
schema = {"streamfield_blocks": {}}
|
|
296
|
+
respx.get(f"{BASE_URL}/schema/home.SimplePage/").mock(
|
|
297
|
+
return_value=Response(200, json=schema)
|
|
298
|
+
)
|
|
299
|
+
# Mock PATCH
|
|
300
|
+
updated = {**page_data, "body": "<p>new</p>"}
|
|
301
|
+
route = respx.patch(f"{BASE_URL}/pages/42/").mock(
|
|
302
|
+
return_value=Response(200, json=updated)
|
|
303
|
+
)
|
|
304
|
+
with mock.patch.dict("os.environ", ENV):
|
|
305
|
+
result = runner.invoke(cli, ["pages", "update", "42", "--body", "new content"])
|
|
306
|
+
assert result.exit_code == 0
|
|
307
|
+
request_body = json.loads(route.calls[0].request.content)
|
|
308
|
+
assert isinstance(request_body["body"], dict)
|
|
309
|
+
assert request_body["body"]["format"] == "markdown"
|
|
310
|
+
|
|
311
|
+
|
|
248
312
|
@respx.mock
|
|
249
313
|
def test_pages_delete(runner):
|
|
250
314
|
respx.delete(f"{BASE_URL}/pages/42/").mock(return_value=Response(204))
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Tests for wagapi.formatting.output — human-readable formatters."""
|
|
2
|
+
|
|
3
|
+
from wagapi.formatting.output import (
|
|
4
|
+
_richtext_preview,
|
|
5
|
+
_streamfield_preview,
|
|
6
|
+
format_page_detail,
|
|
7
|
+
format_page_list,
|
|
8
|
+
format_schema_detail,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestStreamfieldPreview:
|
|
13
|
+
def test_heading_and_paragraph(self):
|
|
14
|
+
blocks = [
|
|
15
|
+
{"type": "heading", "value": {"text": "Hello", "size": "h2"}},
|
|
16
|
+
{"type": "paragraph", "value": "<p>World <b>bold</b></p>"},
|
|
17
|
+
]
|
|
18
|
+
assert _streamfield_preview(blocks) == "Hello / World bold"
|
|
19
|
+
|
|
20
|
+
def test_plain_text_heading(self):
|
|
21
|
+
blocks = [{"type": "heading", "value": "Just text"}]
|
|
22
|
+
assert _streamfield_preview(blocks) == "Just text"
|
|
23
|
+
|
|
24
|
+
def test_truncation(self):
|
|
25
|
+
blocks = [{"type": "paragraph", "value": "<p>" + "x" * 300 + "</p>"}]
|
|
26
|
+
result = _streamfield_preview(blocks, max_len=50)
|
|
27
|
+
assert len(result) == 50
|
|
28
|
+
assert result.endswith("…")
|
|
29
|
+
|
|
30
|
+
def test_empty_blocks(self):
|
|
31
|
+
assert _streamfield_preview([]) == ""
|
|
32
|
+
|
|
33
|
+
def test_unknown_block_types_skipped(self):
|
|
34
|
+
blocks = [{"type": "image", "value": 42}]
|
|
35
|
+
assert _streamfield_preview(blocks) == ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TestRichtextPreview:
|
|
39
|
+
def test_strips_html(self):
|
|
40
|
+
assert _richtext_preview("<p>Hello <b>world</b></p>") == "Hello world"
|
|
41
|
+
|
|
42
|
+
def test_truncation(self):
|
|
43
|
+
result = _richtext_preview("<p>" + "x" * 300 + "</p>", max_len=50)
|
|
44
|
+
assert len(result) == 50
|
|
45
|
+
assert result.endswith("…")
|
|
46
|
+
|
|
47
|
+
def test_empty(self):
|
|
48
|
+
assert _richtext_preview("") == ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TestFormatPageList:
|
|
52
|
+
def test_includes_url_path(self):
|
|
53
|
+
data = {
|
|
54
|
+
"meta": {"total_count": 1},
|
|
55
|
+
"items": [
|
|
56
|
+
{
|
|
57
|
+
"id": 6,
|
|
58
|
+
"title": "Blog Post",
|
|
59
|
+
"meta": {
|
|
60
|
+
"type": "blog.BlogPage",
|
|
61
|
+
"live": True,
|
|
62
|
+
"url_path": "/blog/blog-post/",
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
],
|
|
66
|
+
}
|
|
67
|
+
result = format_page_list(data)
|
|
68
|
+
assert "/blog/blog-post/" in result
|
|
69
|
+
|
|
70
|
+
def test_no_url_path_still_works(self):
|
|
71
|
+
data = {
|
|
72
|
+
"meta": {"total_count": 1},
|
|
73
|
+
"items": [
|
|
74
|
+
{
|
|
75
|
+
"id": 1,
|
|
76
|
+
"title": "Root",
|
|
77
|
+
"meta": {"type": "wagtailcore.Page", "live": True},
|
|
78
|
+
}
|
|
79
|
+
],
|
|
80
|
+
}
|
|
81
|
+
result = format_page_list(data)
|
|
82
|
+
assert "Root" in result
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class TestFormatPageDetail:
|
|
86
|
+
def test_shows_url_path(self):
|
|
87
|
+
data = {
|
|
88
|
+
"id": 6,
|
|
89
|
+
"title": "Blog Post",
|
|
90
|
+
"slug": "blog-post",
|
|
91
|
+
"meta": {
|
|
92
|
+
"type": "blog.BlogPage",
|
|
93
|
+
"live": True,
|
|
94
|
+
"url_path": "/blog/blog-post/",
|
|
95
|
+
"parent_id": 5,
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
result = format_page_detail(data)
|
|
99
|
+
assert "Path: /blog/blog-post/" in result
|
|
100
|
+
|
|
101
|
+
def test_streamfield_body_preview(self):
|
|
102
|
+
data = {
|
|
103
|
+
"id": 6,
|
|
104
|
+
"title": "Post",
|
|
105
|
+
"slug": "post",
|
|
106
|
+
"meta": {"type": "blog.BlogPage", "live": True},
|
|
107
|
+
"body": [
|
|
108
|
+
{"type": "heading", "value": {"text": "Intro", "size": "h2"}},
|
|
109
|
+
{"type": "paragraph", "value": "<p>Some content here.</p>"},
|
|
110
|
+
],
|
|
111
|
+
}
|
|
112
|
+
result = format_page_detail(data)
|
|
113
|
+
assert "body: Intro / Some content here." in result
|
|
114
|
+
|
|
115
|
+
def test_richtext_body_preview(self):
|
|
116
|
+
data = {
|
|
117
|
+
"id": 4,
|
|
118
|
+
"title": "About",
|
|
119
|
+
"slug": "about",
|
|
120
|
+
"meta": {"type": "home.SimplePage", "live": True},
|
|
121
|
+
"body": "<p>About us page content</p>",
|
|
122
|
+
}
|
|
123
|
+
result = format_page_detail(data)
|
|
124
|
+
assert "body: About us page content" in result
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class TestFormatSchemaDetail:
|
|
128
|
+
def test_streamfield_type_annotation(self):
|
|
129
|
+
data = {
|
|
130
|
+
"type": "blog.BlogPage",
|
|
131
|
+
"create_schema": {
|
|
132
|
+
"required": ["body"],
|
|
133
|
+
"properties": {
|
|
134
|
+
"body": {"type": "array", "title": "Body"},
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
"streamfield_blocks": {"body": [{"type": "paragraph"}]},
|
|
138
|
+
"richtext_fields": [],
|
|
139
|
+
}
|
|
140
|
+
result = format_schema_detail(data)
|
|
141
|
+
assert "StreamField" in result
|
|
142
|
+
|
|
143
|
+
def test_richtext_type_annotation(self):
|
|
144
|
+
data = {
|
|
145
|
+
"type": "home.SimplePage",
|
|
146
|
+
"create_schema": {
|
|
147
|
+
"required": [],
|
|
148
|
+
"properties": {
|
|
149
|
+
"body": {"type": "string", "title": "Body"},
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
"streamfield_blocks": {},
|
|
153
|
+
"richtext_fields": ["body"],
|
|
154
|
+
}
|
|
155
|
+
result = format_schema_detail(data)
|
|
156
|
+
assert "RichText" in result
|
|
157
|
+
|
|
158
|
+
def test_regular_string_not_annotated(self):
|
|
159
|
+
data = {
|
|
160
|
+
"type": "blog.BlogPage",
|
|
161
|
+
"create_schema": {
|
|
162
|
+
"required": [],
|
|
163
|
+
"properties": {
|
|
164
|
+
"slug": {"type": "string", "title": "Slug"},
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
"streamfield_blocks": {},
|
|
168
|
+
"richtext_fields": [],
|
|
169
|
+
}
|
|
170
|
+
result = format_schema_detail(data)
|
|
171
|
+
assert "RichText" not in result
|
|
172
|
+
assert "StreamField" not in result
|
|
173
|
+
assert "string" in result
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
|
@@ -52,7 +52,8 @@ def handle_api_errors(fn):
|
|
|
52
52
|
return wrapper
|
|
53
53
|
|
|
54
54
|
|
|
55
|
-
@click.group()
|
|
55
|
+
@click.group(invoke_without_command=True)
|
|
56
|
+
@click.version_option(package_name="wagapi")
|
|
56
57
|
@click.option("--url", envvar="WAGAPI_URL", default=None, help="API base URL")
|
|
57
58
|
@click.option("--token", envvar="WAGAPI_TOKEN", default=None, help="API token")
|
|
58
59
|
@click.option("--json", "output_json", is_flag=True, help="Force JSON output")
|
|
@@ -62,6 +63,10 @@ def handle_api_errors(fn):
|
|
|
62
63
|
@click.pass_context
|
|
63
64
|
def cli(ctx: click.Context, url, token, output_json, output_human, verbose, dry_run):
|
|
64
65
|
"""wagapi — CLI client for the Wagtail Write API."""
|
|
66
|
+
if ctx.invoked_subcommand is None:
|
|
67
|
+
click.echo(ctx.get_help())
|
|
68
|
+
return
|
|
69
|
+
|
|
65
70
|
obj = Context()
|
|
66
71
|
obj.force_json = output_json
|
|
67
72
|
obj.force_human = output_human
|
|
@@ -7,7 +7,7 @@ import click
|
|
|
7
7
|
|
|
8
8
|
from wagapi.cli import Context, handle_api_errors, pass_ctx
|
|
9
9
|
from wagapi.exceptions import UsageError
|
|
10
|
-
from wagapi.formatting.markdown import markdown_to_streamfield
|
|
10
|
+
from wagapi.formatting.markdown import markdown_to_richtext, markdown_to_streamfield
|
|
11
11
|
from wagapi.formatting.output import (
|
|
12
12
|
format_page_created,
|
|
13
13
|
format_page_deleted,
|
|
@@ -27,6 +27,15 @@ def _require_client(ctx: Context):
|
|
|
27
27
|
)
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
def _is_streamfield(ctx: Context, page_type: str, field_name: str) -> bool:
|
|
31
|
+
"""Check if a field is a StreamField by looking for it in streamfield_blocks."""
|
|
32
|
+
try:
|
|
33
|
+
schema = ctx.client.get_page_type_schema(page_type)
|
|
34
|
+
return field_name in schema.get("streamfield_blocks", {})
|
|
35
|
+
except Exception:
|
|
36
|
+
return True # default to StreamField if schema lookup fails
|
|
37
|
+
|
|
38
|
+
|
|
30
39
|
def _parse_parent(value: str) -> int | str:
|
|
31
40
|
"""Parse parent as int ID or string path."""
|
|
32
41
|
try:
|
|
@@ -175,7 +184,11 @@ def create(ctx: Context, page_type, parent, title, slug, fields, body, publish,
|
|
|
175
184
|
except json.JSONDecodeError:
|
|
176
185
|
data["body"] = body
|
|
177
186
|
else:
|
|
178
|
-
|
|
187
|
+
# Check if body is a StreamField or RichTextField
|
|
188
|
+
if _is_streamfield(ctx, page_type, "body"):
|
|
189
|
+
data["body"] = markdown_to_streamfield(body)
|
|
190
|
+
else:
|
|
191
|
+
data["body"] = markdown_to_richtext(body)
|
|
179
192
|
|
|
180
193
|
if publish:
|
|
181
194
|
data["action"] = "publish"
|
|
@@ -225,7 +238,13 @@ def update(ctx: Context, page_id, title, slug, fields, body, publish, raw):
|
|
|
225
238
|
except json.JSONDecodeError:
|
|
226
239
|
data["body"] = body
|
|
227
240
|
else:
|
|
228
|
-
|
|
241
|
+
# Fetch page to determine its type, then check schema
|
|
242
|
+
page_data = ctx.client.get_page(page_id)
|
|
243
|
+
page_type = page_data.get("meta", {}).get("type", "")
|
|
244
|
+
if _is_streamfield(ctx, page_type, "body"):
|
|
245
|
+
data["body"] = markdown_to_streamfield(body)
|
|
246
|
+
else:
|
|
247
|
+
data["body"] = markdown_to_richtext(body)
|
|
229
248
|
|
|
230
249
|
if publish:
|
|
231
250
|
data["action"] = "publish"
|
|
@@ -65,6 +65,36 @@ def format_page_unpublished(data: dict) -> str:
|
|
|
65
65
|
return f'✓ Unpublished page {data.get("id", "?")} "{data.get("title", "")}"'
|
|
66
66
|
|
|
67
67
|
|
|
68
|
+
def _streamfield_preview(blocks: list[dict], max_len: int = 200) -> str:
|
|
69
|
+
"""Extract a plain-text preview from StreamField blocks."""
|
|
70
|
+
import re
|
|
71
|
+
|
|
72
|
+
parts = []
|
|
73
|
+
for block in blocks:
|
|
74
|
+
btype = block.get("type", "")
|
|
75
|
+
val = block.get("value", "")
|
|
76
|
+
if btype == "heading":
|
|
77
|
+
text = val.get("text", "") if isinstance(val, dict) else str(val)
|
|
78
|
+
parts.append(text)
|
|
79
|
+
elif btype in ("paragraph", "text"):
|
|
80
|
+
text = re.sub(r"<[^>]+>", "", str(val))
|
|
81
|
+
parts.append(text)
|
|
82
|
+
preview = " / ".join(parts)
|
|
83
|
+
if len(preview) > max_len:
|
|
84
|
+
preview = preview[: max_len - 1] + "…"
|
|
85
|
+
return preview
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _richtext_preview(html: str, max_len: int = 200) -> str:
|
|
89
|
+
"""Extract a plain-text preview from HTML rich text."""
|
|
90
|
+
import re
|
|
91
|
+
|
|
92
|
+
text = re.sub(r"<[^>]+>", "", str(html)).strip()
|
|
93
|
+
if len(text) > max_len:
|
|
94
|
+
text = text[: max_len - 1] + "…"
|
|
95
|
+
return text
|
|
96
|
+
|
|
97
|
+
|
|
68
98
|
def format_page_detail(data: dict) -> str:
|
|
69
99
|
meta = data.get("meta", {})
|
|
70
100
|
status = "live" if meta.get("live") else "draft"
|
|
@@ -73,7 +103,9 @@ def format_page_detail(data: dict) -> str:
|
|
|
73
103
|
f' Type: {meta.get("type", "unknown")}',
|
|
74
104
|
f' Slug: {data.get("slug", "")}',
|
|
75
105
|
]
|
|
76
|
-
if meta.get("
|
|
106
|
+
if meta.get("url_path"):
|
|
107
|
+
lines.append(f' Path: {meta["url_path"]}')
|
|
108
|
+
elif meta.get("html_url"):
|
|
77
109
|
lines.append(f' URL: {meta["html_url"]}')
|
|
78
110
|
if meta.get("parent_id"):
|
|
79
111
|
lines.append(f' Parent: {meta["parent_id"]}')
|
|
@@ -84,13 +116,20 @@ def format_page_detail(data: dict) -> str:
|
|
|
84
116
|
if key in skip or value is None:
|
|
85
117
|
continue
|
|
86
118
|
if isinstance(value, list) and value and isinstance(value[0], dict) and "type" in value[0]:
|
|
87
|
-
# StreamField —
|
|
88
|
-
|
|
89
|
-
|
|
119
|
+
# StreamField — show preview
|
|
120
|
+
preview = _streamfield_preview(value)
|
|
121
|
+
if preview:
|
|
122
|
+
lines.append(f" {key}: {preview}")
|
|
123
|
+
else:
|
|
124
|
+
block_types = [b.get("type", "?") for b in value]
|
|
125
|
+
lines.append(f" {key}: [{', '.join(block_types)}] ({len(value)} blocks)")
|
|
90
126
|
elif isinstance(value, list) and value:
|
|
91
127
|
lines.append(f" {key}: {len(value)} items")
|
|
92
128
|
elif isinstance(value, list):
|
|
93
129
|
pass # skip empty lists
|
|
130
|
+
elif isinstance(value, str) and value.startswith("<"):
|
|
131
|
+
# RichTextField HTML — show preview
|
|
132
|
+
lines.append(f" {key}: {_richtext_preview(value)}")
|
|
94
133
|
else:
|
|
95
134
|
lines.append(f" {key}: {value}")
|
|
96
135
|
|
|
@@ -105,9 +144,11 @@ def format_page_list(data: dict) -> str:
|
|
|
105
144
|
for page in items:
|
|
106
145
|
meta = page.get("meta", {})
|
|
107
146
|
status = "live" if meta.get("live") else "draft"
|
|
147
|
+
url_path = meta.get("url_path", "")
|
|
148
|
+
path_col = f" {url_path}" if url_path else ""
|
|
108
149
|
lines.append(
|
|
109
150
|
f' {page["id"]:>5} {page.get("title", "Untitled"):<40} '
|
|
110
|
-
f'{meta.get("type", ""):<30} {status}'
|
|
151
|
+
f'{meta.get("type", ""):<30} {status}{path_col}'
|
|
111
152
|
)
|
|
112
153
|
total = data.get("meta", {}).get("total_count", len(items))
|
|
113
154
|
header = f"Pages ({total} total):\n"
|
|
@@ -147,6 +188,9 @@ def format_schema_detail(data: dict) -> str:
|
|
|
147
188
|
properties = create_schema.get("properties", {})
|
|
148
189
|
required_fields = set(create_schema.get("required", []))
|
|
149
190
|
|
|
191
|
+
streamfield_fields = set(data.get("streamfield_blocks", {}).keys())
|
|
192
|
+
richtext_fields = set(data.get("richtext_fields", []))
|
|
193
|
+
|
|
150
194
|
if properties:
|
|
151
195
|
req_lines = []
|
|
152
196
|
opt_lines = []
|
|
@@ -157,6 +201,11 @@ def format_schema_detail(data: dict) -> str:
|
|
|
157
201
|
types = [t.get("type", t.get("format", "")) for t in field_schema["anyOf"] if t.get("type") != "null"]
|
|
158
202
|
ftype = types[0] if types else "unknown"
|
|
159
203
|
ftype = ftype or "unknown"
|
|
204
|
+
# Use Wagtail-specific type names for clarity
|
|
205
|
+
if field_name in streamfield_fields:
|
|
206
|
+
ftype = "StreamField"
|
|
207
|
+
elif field_name in richtext_fields:
|
|
208
|
+
ftype = "RichText"
|
|
160
209
|
desc = field_schema.get("description", field_schema.get("title", ""))
|
|
161
210
|
entry = f" {field_name:<20} {ftype:<15} {desc}"
|
|
162
211
|
if field_name in required_fields:
|
wagapi-0.2.2/wagapi/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.2"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|