formwork-sp 0.7.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.
formwork/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Formwork: declarative SharePoint modern pages, and a page copier."""
2
+
3
+ #: The single version literal for the package. pyproject.toml reads it via
4
+ #: tool.setuptools.dynamic, and release-please updates this line (the
5
+ #: x-release-please-version annotation); the paste-in goldens embed it.
6
+ __version__ = "0.7.0" # x-release-please-version
7
+ BUNDLE_SCHEMA = "formwork.bundle/v1"
formwork/bundle.py ADDED
@@ -0,0 +1,91 @@
1
+ """Extraction bundle parsing and validation.
2
+
3
+ A bundle is the JSON file the extract paste-in downloads from the source
4
+ page: schema envelope, source identity, page fields and section plan.
5
+ Everything downstream (processing, apply) reads the bundle through the
6
+ typed view built here. The page's web parts are not a bundle field: they
7
+ are read from the canvas itself (:mod:`formwork.refs`), which is the one
8
+ place they exist.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ from formwork import BUNDLE_SCHEMA
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Source:
22
+ """Identity of the page the bundle was extracted from."""
23
+
24
+ web_url: str
25
+ web_path: str
26
+ page_path: str
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Bundle:
31
+ """Typed view over an extraction bundle."""
32
+
33
+ schema: str
34
+ extracted_at: str
35
+ source: Source
36
+ meta: dict[str, Any] = field(default_factory=dict)
37
+ page: dict[str, Any] = field(default_factory=dict)
38
+ sections: list[dict[str, Any]] = field(default_factory=list)
39
+ raw: dict[str, Any] = field(default_factory=dict)
40
+
41
+ @property
42
+ def canvas_html(self) -> str | None:
43
+ """Classic page layout HTML, when the source page carries one."""
44
+ return self.page.get("CanvasContent1")
45
+
46
+
47
+ def _require(data: dict[str, Any], key: str) -> None:
48
+ if key not in data:
49
+ raise ValueError(f"bundle is missing required key: {key}")
50
+
51
+
52
+ def parse_bundle(data: dict[str, Any] | str) -> Bundle:
53
+ """Validate an extraction bundle and return its typed view.
54
+
55
+ Accepts an already-parsed dict or a raw JSON string (as downloaded
56
+ by the extract paste-in).
57
+ """
58
+ if isinstance(data, str):
59
+ data = json.loads(data)
60
+ if not isinstance(data, dict):
61
+ # ValueError, not TypeError: this is data content, and the tests pin it.
62
+ raise ValueError("bundle must be a JSON object")
63
+
64
+ _require(data, "schema")
65
+ if data["schema"] != BUNDLE_SCHEMA:
66
+ raise ValueError(
67
+ f"unsupported bundle schema: {data['schema']!r} (expected {BUNDLE_SCHEMA!r})"
68
+ )
69
+
70
+ _require(data, "page")
71
+ source = data.get("source", {})
72
+ for key in ("webUrl", "webPath", "pagePath"):
73
+ if key not in source:
74
+ raise ValueError(f"bundle source is missing required key: {key}")
75
+
76
+ # Bundles from 0.3.0 and earlier carry a "webParts" key (always the empty
77
+ # list the extract paste-in wrote). It is ignored: the canvas is the
78
+ # source of the web parts (architecture review P1-2, 2026-09-06).
79
+ return Bundle(
80
+ schema=data["schema"],
81
+ extracted_at=data.get("extractedAt", ""),
82
+ source=Source(
83
+ web_url=source["webUrl"],
84
+ web_path=source["webPath"],
85
+ page_path=source["pagePath"],
86
+ ),
87
+ meta=data.get("meta", {}),
88
+ page=data["page"],
89
+ sections=data.get("sections", []),
90
+ raw=data,
91
+ )
formwork/canvas.py ADDED
@@ -0,0 +1,288 @@
1
+ """CanvasContent1 parsing and rendering.
2
+
3
+ SharePoint stores a modern page's layout in CanvasContent1 as HTML-encoded
4
+ canvas markup. Each control is a ``<div data-sp-canvascontrol ...>`` whose
5
+ ``data-sp-controldata`` attribute holds entity-escaped JSON giving the
6
+ control's position; web-part controls additionally carry a
7
+ ``data-sp-webpartdata`` attribute with the web part's id, title, properties
8
+ and serverProcessedContent.
9
+
10
+ Measured escaping style (CollabHome.aspx, shauntestazure sandbox, 2026-09-05):
11
+ ``&`` -> ``&amp;`` first, then ``< > "`` as named entities and ``{ } :`` as
12
+ numeric entities. Slashes and single quotes are NOT escaped. Because the
13
+ parser keeps every control's raw attribute text, rendering a parsed, untouched
14
+ canvas is byte-exact; only controls marked dirty are re-serialised.
15
+
16
+ This module is the one serialiser for canvas attributes (architecture
17
+ review 2026-09-06 P2-7): :func:`encode_attribute` is the JSON-then-escape
18
+ the renderer and the compiler both emit, :func:`decode_attribute` the
19
+ unescape-then-JSON the parser and the catalogue both read, and
20
+ :meth:`Control.web_part` / :meth:`Control.text` the two control shapes the
21
+ compiler writes. Nothing else in the package spells the attribute grammar.
22
+ """
23
+
24
+ import html
25
+ import json
26
+ import re
27
+ from dataclasses import dataclass, field
28
+ from typing import Any
29
+
30
+ _CONTROL_OPEN = re.compile(r'<div data-sp-canvascontrol=""[^>]*>', re.DOTALL)
31
+ _CONTROLDATA = re.compile(r'data-sp-controldata="([^"]*)"')
32
+ _WEBPARTDATA = re.compile(r'data-sp-webpartdata="([^"]*)"')
33
+
34
+ #: One ``data-sp-htmlproperties`` child whose text is a verbatim copy of a
35
+ #: ``serverProcessedContent.searchablePlainTexts`` value. Measured on
36
+ #: CollabHome.aspx (shauntestazure sandbox, 2026-09-05): a list title persists
37
+ #: beside the webpartdata attribute as
38
+ #: ``<div data-sp-prop-name="listTitle" data-sp-searchableplaintext="true">``.
39
+ #: The mirrors SharePoint derives rather than copies — a ``links`` href is
40
+ #: server-relative where the JSON value is absolute, and a ``links`` URL or a
41
+ #: ``componentDependencies`` GUID is re-spelled into an attribute — do not
42
+ #: match this shape and are never rewritten.
43
+ _MIRROR_TEXT = re.compile(
44
+ r'(<div data-sp-prop-name="(?P<name>[^"]+)" data-sp-searchableplaintext="true">)'
45
+ r'(?P<text>[^<]*)</div>'
46
+ )
47
+
48
+
49
+ def _plain_texts(data: Any) -> dict[str, Any]:
50
+ """The ``serverProcessedContent.searchablePlainTexts`` mapping, or empty."""
51
+ if not isinstance(data, dict):
52
+ return {}
53
+ processed = data.get("serverProcessedContent")
54
+ if not isinstance(processed, dict):
55
+ return {}
56
+ texts = processed.get("searchablePlainTexts")
57
+ return texts if isinstance(texts, dict) else {}
58
+
59
+
60
+ #: How SharePoint spells a colon inside canvas markup: in every attribute it
61
+ #: escapes (below) and, measured 2026-09-06, in the inner HTML of a text
62
+ #: control's ``data-sp-rte`` child on the item MERGE path
63
+ #: (:func:`formwork.dsl.stored_text_html`; folded back on the read side by
64
+ #: :mod:`formwork.catalogue`).
65
+ COLON_ENTITY = "&#58;"
66
+
67
+ #: The opening of every canvas control the compiler writes: the shape the
68
+ #: discover probe sends and the live canvas stores (data version 1.0).
69
+ _CONTROL_OPEN_PREFIX = '<div data-sp-canvascontrol="" data-sp-canvasdataversion="1.0" '
70
+
71
+
72
+ def escape_attribute(value: str) -> str:
73
+ """Escape a JSON string the way SharePoint escapes canvas attributes."""
74
+ return (
75
+ value.replace("&", "&amp;")
76
+ .replace("<", "&lt;")
77
+ .replace(">", "&gt;")
78
+ .replace('"', "&quot;")
79
+ .replace("{", "&#123;")
80
+ .replace("}", "&#125;")
81
+ .replace(":", COLON_ENTITY)
82
+ )
83
+
84
+
85
+ def encode_attribute(data: Any) -> str:
86
+ """A controldata/webpartdata attribute value: compact JSON, then escaped.
87
+
88
+ Non-ASCII stays literal (``ensure_ascii=False``): the live canvas stores
89
+ a curly apostrophe as the character, not ``\\u2019``, and the attribute
90
+ is entity-escaped anyway.
91
+ """
92
+ return escape_attribute(json.dumps(data, separators=(",", ":"), ensure_ascii=False))
93
+
94
+
95
+ def decode_attribute(raw: str) -> Any:
96
+ """The JSON value of a controldata/webpartdata attribute's raw text.
97
+
98
+ The inverse of :func:`encode_attribute` and of SharePoint's own
99
+ escaping; the same unescape-then-parse the discover probe does with
100
+ DOMParser and ``JSON.parse``. Raises ``ValueError`` (``JSONDecodeError``)
101
+ for an attribute that does not hold JSON.
102
+ """
103
+ return json.loads(html.unescape(raw))
104
+
105
+
106
+ @dataclass
107
+ class Control:
108
+ """One canvas control, holding both its raw and decoded forms."""
109
+
110
+ open_tag: str
111
+ control_data: dict[str, Any]
112
+ web_part_data: dict[str, Any] | None
113
+ controldata_raw: str
114
+ webpartdata_raw: str | None
115
+ body: str = ""
116
+ dirty: bool = field(default=False, repr=False)
117
+ # Mirror field names a render could not sync because the new value needs
118
+ # HTML escaping the mirror format cannot carry verbatim (review
119
+ # 2026-09-11 P2-2). Empty when every changed value was mirrored or the
120
+ # control has no mirror.
121
+ unsynced_mirrors: list[str] = field(default_factory=list, repr=False)
122
+
123
+ @classmethod
124
+ def web_part(cls, control_data: dict[str, Any], web_part_data: dict[str, Any]) -> "Control":
125
+ """A web-part control: the control div wrapping a webpartdata child.
126
+
127
+ The same shape the live canvas writes and the discover probe sends
128
+ (discover.js.j2 step 3): the web-part child carries an empty
129
+ ``data-sp-htmlproperties``, then the control div closes. The raw
130
+ attributes are the encoded dicts, so the control renders byte-for-
131
+ byte as a parsed-then-dirtied one would, without being dirty.
132
+ """
133
+ controldata = encode_attribute(control_data)
134
+ webpartdata = encode_attribute(web_part_data)
135
+ # The web-part child div, then the close of the control div itself.
136
+ body = f'<div data-sp-webpartdata="{webpartdata}" data-sp-htmlproperties=""></div></div>'
137
+ return cls(
138
+ open_tag=f'{_CONTROL_OPEN_PREFIX}data-sp-controldata="{controldata}">',
139
+ control_data=control_data,
140
+ web_part_data=web_part_data,
141
+ controldata_raw=controldata,
142
+ webpartdata_raw=webpartdata,
143
+ body=body,
144
+ )
145
+
146
+ @classmethod
147
+ def text(cls, control_data: dict[str, Any], inner_html: str) -> "Control":
148
+ """A text control: the control div wrapping a ``data-sp-rte`` child.
149
+
150
+ ``inner_html`` is stored as given; the caller spells it the way
151
+ SharePoint stores it (:func:`formwork.dsl.stored_text_html`).
152
+ """
153
+ controldata = encode_attribute(control_data)
154
+ return cls(
155
+ open_tag=f'{_CONTROL_OPEN_PREFIX}data-sp-controldata="{controldata}">',
156
+ control_data=control_data,
157
+ web_part_data=None,
158
+ controldata_raw=controldata,
159
+ webpartdata_raw=None,
160
+ body=f'<div data-sp-rte="">{inner_html}</div></div>',
161
+ )
162
+
163
+ @property
164
+ def web_part_title(self) -> str | None:
165
+ if self.web_part_data is None:
166
+ return None
167
+ title = self.web_part_data.get("title")
168
+ return title if isinstance(title, str) else None
169
+
170
+ def mark_dirty(self) -> None:
171
+ self.dirty = True
172
+
173
+ def render(self) -> str:
174
+ if not self.dirty:
175
+ return self.open_tag + self.body
176
+ # The controldata attribute sits on the control's own tag; the
177
+ # webpartdata attribute sits on a child div. Substitute over the
178
+ # whole block so both are covered.
179
+ #
180
+ # The replacement is a CALLABLE, not an f-string: re.sub parses
181
+ # backslash escapes in a string template, so re-escaped JSON
182
+ # (json.dumps emits \uXXXX for non-ASCII, \n, \\ …) either crashed
183
+ # the render with re.error: bad escape or silently corrupted the
184
+ # attribute bytes (found by the 2026-09-06 P1-fix re-review; a
185
+ # curly apostrophe in a Quick links title was enough).
186
+ full = self.open_tag + self.body
187
+ controldata = encode_attribute(self.control_data)
188
+ full = _CONTROLDATA.sub(lambda _m: f'data-sp-controldata="{controldata}"', full, count=1)
189
+ if self.web_part_data is not None:
190
+ webpartdata = encode_attribute(self.web_part_data)
191
+ full = _WEBPARTDATA.sub(
192
+ lambda _m: f'data-sp-webpartdata="{webpartdata}"', full, count=1
193
+ )
194
+ full = self._sync_mirrors(full)
195
+ return full
196
+
197
+ def _sync_mirrors(self, full: str) -> str:
198
+ """Rewrite the plain-text ``data-sp-htmlproperties`` mirrors this render changed.
199
+
200
+ SharePoint keeps a web part's values twice: in the ``data-sp-webpartdata``
201
+ attribute, and in a ``data-sp-htmlproperties`` child that mirrors them for
202
+ the page model (measured on CollabHome.aspx, 2026-09-05). :meth:`render`
203
+ rewrites the attribute; the mirror is inner content and would otherwise
204
+ keep the value the control was parsed with, so a page rewritten for
205
+ another site carried the source site's list title and hrefs (raised as P2
206
+ by the 2026-09-06 P1-fix re-review and left unactioned). A mirror is
207
+ rewritten only when it provably carried the old value verbatim and the new
208
+ value needs no HTML escaping, so a mirror whose spelling SharePoint
209
+ derives rather than copies (the ``links`` hrefs) and the byte-exact render
210
+ of an unchanged dirty control are both left exactly as they were.
211
+ """
212
+ if self.webpartdata_raw is None:
213
+ return full
214
+ old_texts = _plain_texts(decode_attribute(self.webpartdata_raw))
215
+ changed = {
216
+ name: value
217
+ for name, value in _plain_texts(self.web_part_data).items()
218
+ if isinstance(value, str) and old_texts.get(name) != value
219
+ }
220
+ if not changed:
221
+ return full
222
+
223
+ def mirror(match: re.Match[str]) -> str:
224
+ value = changed.get(match.group("name"))
225
+ if value is None or match.group("text") != old_texts.get(match.group("name")):
226
+ return match.group(0)
227
+ if any(character in value for character in "&<>"):
228
+ # The mirror is plain text: writing this value verbatim would
229
+ # need escaping whose exact form SharePoint derives, not
230
+ # copies (review 2026-09-11 P2-2). Leaving it stale in
231
+ # silence is the defect #26 was filed for, so report it.
232
+ self.unsynced_mirrors.append(match.group("name"))
233
+ return match.group(0)
234
+ return f"{match.group(1)}{value}</div>"
235
+
236
+ return _MIRROR_TEXT.sub(mirror, full)
237
+
238
+
239
+ @dataclass
240
+ class Canvas:
241
+ """A parsed CanvasContent1 string."""
242
+
243
+ controls: list[Control]
244
+ preamble: str = ""
245
+
246
+ @classmethod
247
+ def parse(cls, canvas_content: str) -> "Canvas":
248
+ controls: list[Control] = []
249
+ first = _CONTROL_OPEN.search(canvas_content)
250
+ preamble = canvas_content[: first.start()] if first else canvas_content
251
+ if first is None:
252
+ return cls(controls=[], preamble=preamble)
253
+ for match in _CONTROL_OPEN.finditer(canvas_content):
254
+ open_tag = match.group(0)
255
+ # Control extends to the next control's opening tag, or the end.
256
+ next_match = _CONTROL_OPEN.search(canvas_content, match.end())
257
+ end = next_match.start() if next_match else len(canvas_content)
258
+ body = canvas_content[match.end() : end]
259
+ block = open_tag + body
260
+
261
+ controldata_match = _CONTROLDATA.search(block)
262
+ webpartdata_match = _WEBPARTDATA.search(block)
263
+ control_data = (
264
+ decode_attribute(controldata_match.group(1)) if controldata_match else {}
265
+ )
266
+ web_part_data = (
267
+ decode_attribute(webpartdata_match.group(1)) if webpartdata_match else None
268
+ )
269
+ controls.append(
270
+ Control(
271
+ open_tag=open_tag,
272
+ control_data=control_data,
273
+ web_part_data=web_part_data,
274
+ controldata_raw=controldata_match.group(1) if controldata_match else "",
275
+ webpartdata_raw=webpartdata_match.group(1) if webpartdata_match else None,
276
+ body=body,
277
+ )
278
+ )
279
+ return cls(controls=controls, preamble=preamble)
280
+
281
+ def render(self) -> str:
282
+ parts = [control.render() for control in self.controls]
283
+ if self.preamble:
284
+ parts.insert(0, self.preamble)
285
+ return "".join(parts)
286
+
287
+ def web_part_controls(self) -> list[Control]:
288
+ return [control for control in self.controls if control.web_part_data is not None]