markstitch 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 (60) hide show
  1. markstitch-0.1.0/.gitignore +13 -0
  2. markstitch-0.1.0/LICENSE +21 -0
  3. markstitch-0.1.0/PKG-INFO +365 -0
  4. markstitch-0.1.0/README.md +351 -0
  5. markstitch-0.1.0/docs/compatibility.md +142 -0
  6. markstitch-0.1.0/docs/releasing.md +47 -0
  7. markstitch-0.1.0/examples/live_validation.py +211 -0
  8. markstitch-0.1.0/examples/parse_document.py +18 -0
  9. markstitch-0.1.0/examples/tracker.py +37 -0
  10. markstitch-0.1.0/markstitch/__init__.py +153 -0
  11. markstitch-0.1.0/markstitch/attributes.py +56 -0
  12. markstitch-0.1.0/markstitch/blocks.py +236 -0
  13. markstitch-0.1.0/markstitch/core.py +245 -0
  14. markstitch-0.1.0/markstitch/extensions.py +323 -0
  15. markstitch-0.1.0/markstitch/inline.py +211 -0
  16. markstitch-0.1.0/markstitch/parser/__init__.py +2 -0
  17. markstitch-0.1.0/markstitch/parser/_directives.py +311 -0
  18. markstitch-0.1.0/markstitch/parser/_engine.py +324 -0
  19. markstitch-0.1.0/markstitch/parser/_inline.py +327 -0
  20. markstitch-0.1.0/markstitch/parser/_rules.py +154 -0
  21. markstitch-0.1.0/markstitch/parser/_syntax.py +216 -0
  22. markstitch-0.1.0/markstitch/parser/_tables.py +175 -0
  23. markstitch-0.1.0/markstitch/parser/parse.py +18 -0
  24. markstitch-0.1.0/markstitch/py.typed +0 -0
  25. markstitch-0.1.0/markstitch/tables.py +222 -0
  26. markstitch-0.1.0/markstitch/templates.py +118 -0
  27. markstitch-0.1.0/package-lock.json +1218 -0
  28. markstitch-0.1.0/package.json +11 -0
  29. markstitch-0.1.0/pyproject.toml +166 -0
  30. markstitch-0.1.0/tests/parser/test_code_boundaries.py +74 -0
  31. markstitch-0.1.0/tests/parser/test_depth_limits.py +46 -0
  32. markstitch-0.1.0/tests/parser/test_image_alt.py +53 -0
  33. markstitch-0.1.0/tests/parser/test_inline_boundaries.py +30 -0
  34. markstitch-0.1.0/tests/parser/test_link_destinations.py +191 -0
  35. markstitch-0.1.0/tests/parser/test_malformed_directives.py +54 -0
  36. markstitch-0.1.0/tests/parser/test_nested_fallback.py +86 -0
  37. markstitch-0.1.0/tests/parser/test_optional_dependency.py +18 -0
  38. markstitch-0.1.0/tests/parser/test_parser.py +74 -0
  39. markstitch-0.1.0/tests/parser/test_parser_edges.py +281 -0
  40. markstitch-0.1.0/tests/parser/test_parser_yfm.py +262 -0
  41. markstitch-0.1.0/tests/parser/test_reference_definitions.py +209 -0
  42. markstitch-0.1.0/tests/parser/test_script_spacing.py +24 -0
  43. markstitch-0.1.0/tests/parser/test_table_code.py +66 -0
  44. markstitch-0.1.0/tests/parser/test_table_styles.py +49 -0
  45. markstitch-0.1.0/tests/render.cjs +22 -0
  46. markstitch-0.1.0/tests/test_attributes.py +78 -0
  47. markstitch-0.1.0/tests/test_convenience.py +34 -0
  48. markstitch-0.1.0/tests/test_document.py +13 -0
  49. markstitch-0.1.0/tests/test_extensions.py +138 -0
  50. markstitch-0.1.0/tests/test_grid_sort.py +47 -0
  51. markstitch-0.1.0/tests/test_options.py +45 -0
  52. markstitch-0.1.0/tests/test_reference_labels.py +41 -0
  53. markstitch-0.1.0/tests/test_render.py +326 -0
  54. markstitch-0.1.0/tests/test_spans.py +39 -0
  55. markstitch-0.1.0/tests/test_tables.py +50 -0
  56. markstitch-0.1.0/tests/test_templates.py +92 -0
  57. markstitch-0.1.0/tests/test_tracker.py +93 -0
  58. markstitch-0.1.0/tests/test_tree.py +96 -0
  59. markstitch-0.1.0/tests/test_validation.py +81 -0
  60. markstitch-0.1.0/uv.lock +943 -0
@@ -0,0 +1,13 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .mypy_cache/
7
+ .coverage
8
+ htmlcov/
9
+ dist/
10
+ build/
11
+ *.egg-info/
12
+ node_modules/
13
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Valery Pavlov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,365 @@
1
+ Metadata-Version: 2.5
2
+ Name: markstitch
3
+ Version: 0.1.0
4
+ Summary: Compose, parse, and edit YFM documents in Python
5
+ Project-URL: Repository, https://github.com/LerikP/markstitch
6
+ Project-URL: Issues, https://github.com/LerikP/markstitch/issues
7
+ Author: Valery Pavlov
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Requires-Python: <4.0,>=3.12
11
+ Provides-Extra: parser
12
+ Requires-Dist: markdown-it-py>=4.0.0; extra == 'parser'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # Markstitch
16
+
17
+ Compose, parse, and edit YFM documents in Python.
18
+
19
+ Build documents for **Yandex Tracker**, Yandex Wiki, and Diplodoc using Python objects.
20
+ Requires Python 3.12+. Import the library as `markstitch`. Document generation has no
21
+ runtime dependencies; parsing requires the `parser` extra:
22
+
23
+ ```sh
24
+ git clone https://github.com/LerikP/markstitch.git
25
+ cd markstitch
26
+ uv sync --locked --extra parser
27
+ ```
28
+
29
+ The package is not published on PyPI yet. To install from a local checkout:
30
+
31
+ ```sh
32
+ pip install ".[parser]"
33
+ ```
34
+
35
+ ## Quick start
36
+
37
+ From the project directory:
38
+
39
+ ```sh
40
+ uv sync --locked
41
+ uv run python examples/tracker.py
42
+ ```
43
+
44
+ ```python
45
+ from markstitch import YFM, Header3, Link, NumberedList
46
+
47
+ document = YFM(
48
+ Header3("Test1"),
49
+ NumberedList("One", "Two", "Three"),
50
+ Link(text="link", href="example.com"),
51
+ )
52
+
53
+ text = document.to_yfm()
54
+ ```
55
+
56
+ Output:
57
+
58
+ ```markdown
59
+ ### Test1
60
+
61
+ 1. One
62
+ 2. Two
63
+ 3. Three
64
+
65
+ [link](example.com)
66
+ ```
67
+
68
+ Pass `text` to any description or comment field that accepts YFM.
69
+ The library produces a string; network requests and attachment uploads are the caller's responsibility.
70
+
71
+ ## Composition
72
+
73
+ ```python
74
+ from markstitch import (
75
+ YFM,
76
+ Bold,
77
+ BulletList,
78
+ Cell,
79
+ Checklist,
80
+ CodeBlock,
81
+ Cut,
82
+ Header2,
83
+ Link,
84
+ ListItem,
85
+ Note,
86
+ Paragraph,
87
+ Row,
88
+ Tab,
89
+ Tabs,
90
+ Task,
91
+ YFMTable,
92
+ )
93
+
94
+ document = YFM(
95
+ Header2("Results", anchor="results", collapsible=True),
96
+ Paragraph("Status: ", Bold("ready"), ". ", Link("Report", "https://example.com")),
97
+ Note("Verify after deployment", kind="warning", title="Important"),
98
+ Cut(
99
+ "Details",
100
+ BulletList(ListItem("First stage", BulletList("Substep")), "Second stage"),
101
+ CodeBlock("print('hello')", language="python", line_numbers=True),
102
+ ),
103
+ Tabs(Tab("Linux", "Instructions"), Tab("macOS", "Instructions")),
104
+ Checklist(Task("Implementation", checked=True), Task("Verification")),
105
+ YFMTable(
106
+ Row(Bold("Stage"), Bold("Steps")),
107
+ Row("Run", Cell(BulletList("Install", "Execute"))),
108
+ ),
109
+ )
110
+ ```
111
+
112
+ - `YFM(*elements)` and block containers separate their children with a blank line.
113
+ - `Paragraph(*parts)` and `Text(*parts)` join inline elements **without inserting spaces**.
114
+ - `ListItem(*blocks)` nests multiple blocks inside a single list item.
115
+ - `Heading(text, level=1)` and `Header1` through `Header6` accept inline content,
116
+ `anchor=`, and `collapsible=`.
117
+ - `NumberedList(*items, start=1)` calculates indentation from the number's width, including after `9.`.
118
+ - `Table(headers, rows, align=...)` creates a simple table; `YFMTable(Row(...), ...)` creates a multiline table.
119
+ - All nodes support `.to_yfm(profile=...)`. `str(element)` uses the Tracker profile.
120
+ - The returned string has no automatically appended trailing newline.
121
+ - Repeated rendering does not mutate the tree.
122
+
123
+ ## Parsing and tree traversal
124
+
125
+ The optional `parser` extra uses `markdown-it-py>=4.0.0`.
126
+ When working from source, run `uv sync --locked --extra parser`.
127
+
128
+ ```python
129
+ from markstitch import Link, parse
130
+
131
+ document = parse(
132
+ "### Report\n\nSee **[result](/old)**",
133
+ profile="tracker",
134
+ strict=True,
135
+ )
136
+
137
+ # Immediate children: Heading and Paragraph.
138
+ top_level = list(document)
139
+
140
+ # Depth-first traversal, visiting each parent before its children.
141
+ for node in document.walk():
142
+ if isinstance(node, Link):
143
+ node.href = "/new"
144
+
145
+ updated = document.to_yfm()
146
+ ```
147
+
148
+ The equivalent class method is `YFM.from_yfm(source, profile="wiki", strict=False)`.
149
+ Both return a regular `YFM` document, using the same node types as document construction.
150
+ The profile is stored in `document.profile` and used by `.to_yfm()` unless explicitly overridden.
151
+
152
+ ### Tree behavior
153
+
154
+ - `iter(element)` / `element.iter_children()` return immediate child **elements**.
155
+ `walk()` visits all elements in depth-first preorder, including the root.
156
+ - Traversal returns the original objects. Changing `Link.href`, `Heading.text`, `Task.checked`,
157
+ or other fields affects subsequent serialization.
158
+ - Traversal includes inline content in headings and links, `YFMTable` rows and cells,
159
+ regular `Table` headers and cells, and both branches of `If` / `InlineIf`.
160
+ - URLs, CSS attributes, and other metadata are not child nodes. Strings passed to constructors
161
+ remain scalar values on their owning node. Parsed plain text becomes `Text` nodes visible
162
+ to `walk()`, with its parts stored in `Text.children`.
163
+ - `Row` is an iterable structural node whose `.to_yfm()` returns a table row fragment.
164
+ Full span validation happens when serializing the entire `YFMTable`.
165
+ - A shared node used twice is visited twice. Cycles raise `ValueError`.
166
+ Traversal is iterative, so tree depth does not consume the Python call stack.
167
+ - Custom nodes can override `iter_children()` to participate in traversal.
168
+
169
+ ### Parser behavior
170
+
171
+ The parser supports CommonMark blocks and inline formatting, the library's YFM node syntax,
172
+ both table types, spans and attributes, profile-specific macros, block and inline templates,
173
+ link definitions, terms, and footnotes.
174
+
175
+ - By default, unknown or profile-unsupported constructs are preserved as `Raw` / `RawInline`.
176
+ If an unknown argument prevents part of a block from being parsed correctly, the entire
177
+ original block is preserved.
178
+ - With `strict=True`, these cases raise `ParseError` instead. The message includes the reason
179
+ and, for block errors, the line number within the parsed fragment.
180
+ - The parser normalizes list and formatting markers, item numbers, indentation, blank lines,
181
+ line endings, and attributes. Original formatting is **not preserved byte for byte**.
182
+ Unknown raw fragments retain their internal text; separators follow the usual `YFM` behavior.
183
+ - Tight and loose lists remain distinct through
184
+ `BulletList(..., loose=True)` / `NumberedList(..., loose=True)`.
185
+ - A shortcut reference `[label]` with a definition resolves to `Link`;
186
+ the explicit form `[text][label]` remains a `ReferenceLink`.
187
+ - Recursive parsing is limited to 64 internal levels. Exceeding this limit raises `ParseError`,
188
+ even in non-strict mode.
189
+ - The parser does not execute templates or fetch includes, images, or files.
190
+ **Parsing is not sanitization:** preserved `Raw` nodes may contain original HTML/YFM markup.
191
+ Safe display is the responsibility of the target renderer.
192
+
193
+ Runnable example: `uv run --extra parser python examples/parse_document.py`.
194
+
195
+ ## Profiles
196
+
197
+ ```python
198
+ from markstitch import Profile
199
+
200
+ text = document.to_yfm() # Tracker by default
201
+ text = document.to_yfm(profile=Profile.WIKI) # or "wiki"
202
+ text = document.to_yfm(profile=Profile.DIPLODOC) # or "diplodoc"
203
+ ```
204
+
205
+ Profiles select syntax and validate extension support. For example, Tracker/Wiki image
206
+ dimensions appear inside the link as `=100x200`, while Diplodoc uses
207
+ `{width=100 height=200}` attributes. Checklist serialization also differs.
208
+
209
+ Features without confirmed support in a profile raise `UnsupportedFeatureError`,
210
+ including in deeply nested nodes. Invalid values raise `ValueError`; incompatible content
211
+ types raise `TypeError`. Validation happens during `.to_yfm()`, not node construction.
212
+
213
+ **Profiles follow published documentation; they do not detect server capabilities.**
214
+ The target service's editor version and enabled plugins may differ.
215
+
216
+ The `Grid.sort` parameter is an optional **column slug**, for example
217
+ `Grid(grid_id, sort="case")`. It defaults to `None`, which omits the attribute.
218
+ Boolean values are rejected: the Wiki widget interprets `sort="1"` as sorting by a column
219
+ named `1`, not as enabling sorting.
220
+
221
+ Dynamic cell formatting is configured separately on the Wiki resource: a string column
222
+ needs `format="yfm"` when created. `format=null` means plain text, even if the entire grid
223
+ has `rich_text_format="yfm"`. `Grid` embeds a resource by ID without changing its column schema.
224
+
225
+ ## Text and raw markup
226
+
227
+ Plain strings are escaped. Use nodes such as `Bold`, `Link`, `Color`, and `Code` for formatting.
228
+ Use `Raw` (block) or `RawInline` (inline) for trusted, preformatted markup.
229
+ `RawHTML` wraps trusted HTML/CSS in a Tracker/Wiki HTML block; it is **not a sanitizer**.
230
+
231
+ `If.expression` is a Diplodoc condition explicitly written by the document author.
232
+ `Variable`, `For`, `If`, and `Include` only generate syntax: they neither execute templates
233
+ nor read files. Do not pass untrusted input to `Raw`, `RawInline`, `RawHTML`, or `If.expression`.
234
+ Rendering, allowed iframe domains, and HTML policies are controlled by the target service.
235
+
236
+ URLs may be relative or use the `http`, `https`, or `mailto` scheme.
237
+ Other schemes, including `tel`, are rejected when serializing typed nodes.
238
+
239
+ Place `TermDefinition` nodes at the end of the document, as required by Diplodoc.
240
+ `ReferenceLink` / `ReferenceImage` nodes need a matching `LinkDefinition`.
241
+
242
+ ## Table attributes and SVG (Diplodoc)
243
+
244
+ ```python
245
+ from markstitch import Attributes, Cell, Image, Row, YFMTable
246
+
247
+ table = YFMTable(
248
+ Row(
249
+ Cell("Value", align="center", attributes=Attributes(style={"width": "300px"})),
250
+ attributes=Attributes(classes=("summary",)),
251
+ ),
252
+ attributes=Attributes(id="report", extra={"data-kind": "summary"}),
253
+ )
254
+ text = table.to_yfm(profile="diplodoc")
255
+ image = Image("_images/icon.svg", width=40, inline=False)
256
+ ```
257
+
258
+ `Attributes` supports `id`, `classes`, CSS declarations in `style`, and additional
259
+ `data-*`, `aria-*`, and `title` attributes through `extra`. CSS values can be keywords,
260
+ dimensions, or colors; functions, URLs, embedded declarations, and markup delimiters
261
+ are rejected. Attribute handling in HTML depends on the renderer version:
262
+ see [pinned renderer limitations](docs/compatibility.md#renderer).
263
+
264
+ The document author should supply trusted values for `classes` and `style`. Do not pass
265
+ user input to them: CSS classes may activate JavaScript behavior in the target service,
266
+ and CSS syntax validation does not make arbitrary styling safe.
267
+ `Attributes` is not a sanitizer for user-supplied CSS or HTML attributes.
268
+
269
+ Use `Span.LEFT` and `Span.ABOVE` to merge cells. To continue a multi-column rectangle
270
+ on the next row, put `Span.ABOVE` in its first column and `Span.LEFT` in the remaining columns.
271
+ The entire grid and each merged rectangle are validated: partial continuations, wider
272
+ lower rows, and L-shaped regions raise `ValueError`.
273
+
274
+ ## Inline templates and slicing (Diplodoc)
275
+
276
+ ```python
277
+ from markstitch import Bold, InlineFor, InlineIf, Paragraph, Slice, Variable
278
+
279
+ template = Paragraph(
280
+ "Role: ",
281
+ InlineIf("user.admin == true", Bold("admin"), otherwise="guest"),
282
+ "; names: ",
283
+ InlineFor("user", "users", Variable(Slice("user.name", 0, 3)), ";"),
284
+ )
285
+ text = template.to_yfm(profile="diplodoc")
286
+ ```
287
+
288
+ `InlineIf` and `InlineFor` accept only inline elements. Pass an `InlineIf` as `otherwise`
289
+ to nest conditions. `Slice(source, begin, end=None)` supports integer indices, including
290
+ negative values, and another `Slice` as its source. Pass it to `Variable` with or without
291
+ filters, such as `filters=("length",)`. Diplodoc executes the template; the library only serializes it.
292
+
293
+ ## Syntax coverage
294
+
295
+ See the [compatibility matrix and references](docs/compatibility.md) for built-in constructs,
296
+ optional plugins, and remaining limitations. Custom nodes can represent third-party syntax:
297
+
298
+ ```python
299
+ from markstitch import Element, Profile
300
+
301
+
302
+ class CustomBlock(Element):
303
+ def _render(self, profile: Profile) -> str:
304
+ return "trusted preformatted markup"
305
+ ```
306
+
307
+ Inherit from `Inline` to define a custom inline element.
308
+
309
+ ## Checks and builds
310
+
311
+ ```sh
312
+ uv sync --locked --extra parser
313
+ uv run ruff format --check .
314
+ uv run ruff check .
315
+ uv run --extra parser mypy markstitch
316
+ uv run --extra parser ty check markstitch
317
+ uv run --extra parser pyrefly check markstitch
318
+ # Python behavior without Node.js:
319
+ uv run --extra parser pytest -m 'not renderer'
320
+
321
+ # Integration with the pinned YFM renderer (requires Node.js 24+):
322
+ npm ci --registry=https://registry.npmjs.org/ --ignore-scripts --no-audit --no-fund
323
+ uv run --extra parser pytest
324
+
325
+ uv build
326
+ uv run twine check --strict dist/*
327
+ ```
328
+
329
+ Renderer tests use `@diplodoc/transform` to check HTML structure, nesting, spans,
330
+ attribute parsing, and escaping. Template tests explicitly enable Liquid and cover
331
+ both conditional branches, loops, slicing, and filters. Wiki-specific macros and
332
+ Tracker editor syntax are checked against their text contracts.
333
+
334
+ Ruff, mypy, ty, Pyrefly, pytest, and Twine are development dependencies with versions pinned in `uv.lock`.
335
+ Python dependencies come from PyPI; the renderer comes from npm.
336
+
337
+ ## CI and publishing
338
+
339
+ GitHub Actions runs the full test suite on Python 3.12, 3.13, and 3.14 for pull requests
340
+ and pushes to `main`. It also checks formatting, lint, and types with mypy, ty, and Pyrefly, builds the wheel and
341
+ source distribution, validates their metadata, and smoke-tests both installed distributions
342
+ with and without the parser extra. Successful builds expose a `python-distributions` artifact.
343
+
344
+ Publishing a GitHub Release runs the same checks and uploads the resulting distributions
345
+ to PyPI through Trusted Publishing. The release tag must match the package version exactly,
346
+ for example `v0.1.0` for version `0.1.0`. Pushing a commit or tag alone does not publish a package.
347
+
348
+ See [releasing](docs/releasing.md) for the one-time PyPI setup and release procedure.
349
+
350
+ ## Package structure
351
+
352
+ - `core.py`: node interfaces, profiles, escaping, links, and document composition.
353
+ - `inline.py`: inline formatting, images, and inline code.
354
+ - `blocks.py`: headings, lists, code blocks, cuts, notes, and tabs.
355
+ - `tables.py`: simple and multiline tables.
356
+ - `attributes.py`: typed attributes for tables, rows, and cells.
357
+ - `extensions.py`: service macros, link definitions, terms, and footnotes.
358
+ - `templates.py`: Diplodoc template syntax generation.
359
+ - `parser/`: CommonMark/YFM tokenization and conversion to public nodes.
360
+
361
+ The API is inspired by [SnakeMD](https://www.snakemd.io/en/latest/); no source code was copied.
362
+
363
+ ## License
364
+
365
+ Markstitch is licensed under the [MIT License](LICENSE).