Skip to main content

Markstitch

Compose, parse, and edit YFM documents in Python.

Build documents for Yandex Tracker, Yandex Wiki, and Diplodoc using Python objects. Requires Python 3.12+. Import the library as markstitch. Document generation has no runtime dependencies; parsing requires the parser extra:

git clone https://github.com/LerikP/markstitch.git
cd markstitch
uv sync --locked --extra parser

The package is not published on PyPI yet. To install from a local checkout:

pip install ".[parser]"

Quick start

From the project directory:

uv sync --locked
uv run python examples/tracker.py
from markstitch import YFM, Header3, Link, NumberedList

document = YFM(
    Header3("Test1"),
    NumberedList("One", "Two", "Three"),
    Link(text="link", href="example.com"),
)

text = document.to_yfm()

Output:

### Test1

1. One
2. Two
3. Three

[link](example.com)

Pass text to any description or comment field that accepts YFM. The library produces a string; network requests and attachment uploads are the caller's responsibility.

Composition

from markstitch import (
    YFM,
    Bold,
    BulletList,
    Cell,
    Checklist,
    CodeBlock,
    Cut,
    Header2,
    Link,
    ListItem,
    Note,
    Paragraph,
    Row,
    Tab,
    Tabs,
    Task,
    YFMTable,
)

document = YFM(
    Header2("Results", anchor="results", collapsible=True),
    Paragraph("Status: ", Bold("ready"), ". ", Link("Report", "https://example.com")),
    Note("Verify after deployment", kind="warning", title="Important"),
    Cut(
        "Details",
        BulletList(ListItem("First stage", BulletList("Substep")), "Second stage"),
        CodeBlock("print('hello')", language="python", line_numbers=True),
    ),
    Tabs(Tab("Linux", "Instructions"), Tab("macOS", "Instructions")),
    Checklist(Task("Implementation", checked=True), Task("Verification")),
    YFMTable(
        Row(Bold("Stage"), Bold("Steps")),
        Row("Run", Cell(BulletList("Install", "Execute"))),
    ),
)
  • YFM(*elements) and block containers separate their children with a blank line.
  • Paragraph(*parts) and Text(*parts) join inline elements without inserting spaces.
  • ListItem(*blocks) nests multiple blocks inside a single list item.
  • Heading(text, level=1) and Header1 through Header6 accept inline content, anchor=, and collapsible=.
  • NumberedList(*items, start=1) calculates indentation from the number's width, including after 9..
  • Table(headers, rows, align=...) creates a simple table; YFMTable(Row(...), ...) creates a multiline table.
  • All nodes support .to_yfm(profile=...). str(element) uses the Tracker profile.
  • The returned string has no automatically appended trailing newline.
  • Repeated rendering does not mutate the tree.

Parsing and tree traversal

The optional parser extra uses markdown-it-py>=4.0.0. When working from source, run uv sync --locked --extra parser.

from markstitch import Link, parse

document = parse(
    "### Report\n\nSee **[result](/old)**",
    profile="tracker",
    strict=True,
)

# Immediate children: Heading and Paragraph.
top_level = list(document)

# Depth-first traversal, visiting each parent before its children.
for node in document.walk():
    if isinstance(node, Link):
        node.href = "/new"

updated = document.to_yfm()

The equivalent class method is YFM.from_yfm(source, profile="wiki", strict=False). Both return a regular YFM document, using the same node types as document construction. The profile is stored in document.profile and used by .to_yfm() unless explicitly overridden.

Tree behavior

  • iter(element) / element.iter_children() return immediate child elements. walk() visits all elements in depth-first preorder, including the root.
  • Traversal returns the original objects. Changing Link.href, Heading.text, Task.checked, or other fields affects subsequent serialization.
  • Traversal includes inline content in headings and links, YFMTable rows and cells, regular Table headers and cells, and both branches of If / InlineIf.
  • URLs, CSS attributes, and other metadata are not child nodes. Strings passed to constructors remain scalar values on their owning node. Parsed plain text becomes Text nodes visible to walk(), with its parts stored in Text.children.
  • Row is an iterable structural node whose .to_yfm() returns a table row fragment. Full span validation happens when serializing the entire YFMTable.
  • A shared node used twice is visited twice. Cycles raise ValueError. Traversal is iterative, so tree depth does not consume the Python call stack.
  • Custom nodes can override iter_children() to participate in traversal.

Parser behavior

The parser supports CommonMark blocks and inline formatting, the library's YFM node syntax, both table types, spans and attributes, profile-specific macros, block and inline templates, link definitions, terms, and footnotes.

  • By default, unknown or profile-unsupported constructs are preserved as Raw / RawInline. If an unknown argument prevents part of a block from being parsed correctly, the entire original block is preserved.
  • With strict=True, these cases raise ParseError instead. The message includes the reason and, for block errors, the line number within the parsed fragment.
  • The parser normalizes list and formatting markers, item numbers, indentation, blank lines, line endings, and attributes. Original formatting is not preserved byte for byte. Unknown raw fragments retain their internal text; separators follow the usual YFM behavior.
  • Tight and loose lists remain distinct through BulletList(..., loose=True) / NumberedList(..., loose=True).
  • A shortcut reference [label] with a definition resolves to Link; the explicit form [text][label] remains a ReferenceLink.
  • Recursive parsing is limited to 64 internal levels. Exceeding this limit raises ParseError, even in non-strict mode.
  • The parser does not execute templates or fetch includes, images, or files. Parsing is not sanitization: preserved Raw nodes may contain original HTML/YFM markup. Safe display is the responsibility of the target renderer.

Runnable example: uv run --extra parser python examples/parse_document.py.

Profiles

from markstitch import Profile

text = document.to_yfm()  # Tracker by default
text = document.to_yfm(profile=Profile.WIKI)  # or "wiki"
text = document.to_yfm(profile=Profile.DIPLODOC)  # or "diplodoc"

Profiles select syntax and validate extension support. For example, Tracker/Wiki image dimensions appear inside the link as =100x200, while Diplodoc uses {width=100 height=200} attributes. Checklist serialization also differs.

Features without confirmed support in a profile raise UnsupportedFeatureError, including in deeply nested nodes. Invalid values raise ValueError; incompatible content types raise TypeError. Validation happens during .to_yfm(), not node construction.

Profiles follow published documentation; they do not detect server capabilities. The target service's editor version and enabled plugins may differ.

The Grid.sort parameter is an optional column slug, for example Grid(grid_id, sort="case"). It defaults to None, which omits the attribute. Boolean values are rejected: the Wiki widget interprets sort="1" as sorting by a column named 1, not as enabling sorting.

Dynamic cell formatting is configured separately on the Wiki resource: a string column needs format="yfm" when created. format=null means plain text, even if the entire grid has rich_text_format="yfm". Grid embeds a resource by ID without changing its column schema.

Text and raw markup

Plain strings are escaped. Use nodes such as Bold, Link, Color, and Code for formatting. Use Raw (block) or RawInline (inline) for trusted, preformatted markup. RawHTML wraps trusted HTML/CSS in a Tracker/Wiki HTML block; it is not a sanitizer.

If.expression is a Diplodoc condition explicitly written by the document author. Variable, For, If, and Include only generate syntax: they neither execute templates nor read files. Do not pass untrusted input to Raw, RawInline, RawHTML, or If.expression. Rendering, allowed iframe domains, and HTML policies are controlled by the target service.

URLs may be relative or use the http, https, or mailto scheme. Other schemes, including tel, are rejected when serializing typed nodes.

Place TermDefinition nodes at the end of the document, as required by Diplodoc. ReferenceLink / ReferenceImage nodes need a matching LinkDefinition.

Table attributes and SVG (Diplodoc)

from markstitch import Attributes, Cell, Image, Row, YFMTable

table = YFMTable(
    Row(
        Cell("Value", align="center", attributes=Attributes(style={"width": "300px"})),
        attributes=Attributes(classes=("summary",)),
    ),
    attributes=Attributes(id="report", extra={"data-kind": "summary"}),
)
text = table.to_yfm(profile="diplodoc")
image = Image("_images/icon.svg", width=40, inline=False)

Attributes supports id, classes, CSS declarations in style, and additional data-*, aria-*, and title attributes through extra. CSS values can be keywords, dimensions, or colors; functions, URLs, embedded declarations, and markup delimiters are rejected. Attribute handling in HTML depends on the renderer version: see pinned renderer limitations.

The document author should supply trusted values for classes and style. Do not pass user input to them: CSS classes may activate JavaScript behavior in the target service, and CSS syntax validation does not make arbitrary styling safe. Attributes is not a sanitizer for user-supplied CSS or HTML attributes.

Use Span.LEFT and Span.ABOVE to merge cells. To continue a multi-column rectangle on the next row, put Span.ABOVE in its first column and Span.LEFT in the remaining columns. The entire grid and each merged rectangle are validated: partial continuations, wider lower rows, and L-shaped regions raise ValueError.

Inline templates and slicing (Diplodoc)

from markstitch import Bold, InlineFor, InlineIf, Paragraph, Slice, Variable

template = Paragraph(
    "Role: ",
    InlineIf("user.admin == true", Bold("admin"), otherwise="guest"),
    "; names: ",
    InlineFor("user", "users", Variable(Slice("user.name", 0, 3)), ";"),
)
text = template.to_yfm(profile="diplodoc")

InlineIf and InlineFor accept only inline elements. Pass an InlineIf as otherwise to nest conditions. Slice(source, begin, end=None) supports integer indices, including negative values, and another Slice as its source. Pass it to Variable with or without filters, such as filters=("length",). Diplodoc executes the template; the library only serializes it.

Syntax coverage

See the compatibility matrix and references for built-in constructs, optional plugins, and remaining limitations. Custom nodes can represent third-party syntax:

from markstitch import Element, Profile


class CustomBlock(Element):
    def _render(self, profile: Profile) -> str:
        return "trusted preformatted markup"

Inherit from Inline to define a custom inline element.

Checks and builds

uv sync --locked --extra parser
uv run ruff format --check .
uv run ruff check .
uv run --extra parser mypy markstitch
uv run --extra parser ty check markstitch
uv run --extra parser pyrefly check markstitch
# Python behavior without Node.js:
uv run --extra parser pytest -m 'not renderer'

# Integration with the pinned YFM renderer (requires Node.js 24+):
npm ci --registry=https://registry.npmjs.org/ --ignore-scripts --no-audit --no-fund
uv run --extra parser pytest

uv build
uv run twine check --strict dist/*

Renderer tests use @diplodoc/transform to check HTML structure, nesting, spans, attribute parsing, and escaping. Template tests explicitly enable Liquid and cover both conditional branches, loops, slicing, and filters. Wiki-specific macros and Tracker editor syntax are checked against their text contracts.

Ruff, mypy, ty, Pyrefly, pytest, and Twine are development dependencies with versions pinned in uv.lock. Python dependencies come from PyPI; the renderer comes from npm.

CI and publishing

GitHub Actions runs the full test suite on Python 3.12, 3.13, and 3.14 for pull requests and pushes to main. It also checks formatting, lint, and types with mypy, ty, and Pyrefly, builds the wheel and source distribution, validates their metadata, and smoke-tests both installed distributions with and without the parser extra. Successful builds expose a python-distributions artifact.

Publishing a GitHub Release runs the same checks and uploads the resulting distributions to PyPI through Trusted Publishing. The release tag must match the package version exactly, for example v0.1.0 for version 0.1.0. Pushing a commit or tag alone does not publish a package.

See releasing for the one-time PyPI setup and release procedure.

Package structure

  • core.py: node interfaces, profiles, escaping, links, and document composition.
  • inline.py: inline formatting, images, and inline code.
  • blocks.py: headings, lists, code blocks, cuts, notes, and tabs.
  • tables.py: simple and multiline tables.
  • attributes.py: typed attributes for tables, rows, and cells.
  • extensions.py: service macros, link definitions, terms, and footnotes.
  • templates.py: Diplodoc template syntax generation.
  • parser/: CommonMark/YFM tokenization and conversion to public nodes.

The API is inspired by SnakeMD; no source code was copied.

License

Markstitch is licensed under the MIT License.

Metadata

Release files for markstitch 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for markstitch 0.1.0
File Size Uploaded Provenance Actions
markstitch-0.1.0.tar.gz 126.7 kB With provenance Details for markstitch-0.1.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for markstitch 0.1.0
File Interpreter ABI Platform Actions
markstitch-0.1.0-py3-none-any.whl Python 3 none any Details for markstitch-0.1.0-py3-none-any.whl

Total release size: 165.9 kB

Release files / markstitch-0.1.0.tar.gz

Download URL markstitch-0.1.0.tar.gz
Size 126.7 kB
Tags Source
SHA-256 checksum
How to use checksums
facfc117c7f71248a06d2105a4513c140e415dade3f829536a6357d4e8bcab30
BLAKE2b-256 checksum
How to use checksums
2dfc81f3d269f19d11ff96fa3acc6857f2a5cc6e597e3489f62835b13aeb1e95
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 8, 2026.

Transparency log

Release files / markstitch-0.1.0-py3-none-any.whl

Download URL markstitch-0.1.0-py3-none-any.whl
Size 39.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
031f98f369f9f1e71ca1ba4e3d76330cb300ec5d429511f22f3b686b61b2ea87
BLAKE2b-256 checksum
How to use checksums
038a758d32e1228dd58ef49c96ecfd2514c121e28597716b6cbe004ba74267c6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 8, 2026.

Transparency log

Security for markstitch 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

Both files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for markstitch-0.1.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 8, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page