The Python library

stxt is the STXT parser for Python, published on PyPI: pure Python, no dependencies, 3.10 or later. It is a module-by-module port of the language's reference pseudocode, so it implements the five specifications —syntax, canonical tree, schemas, templates and resolution— and reports the same error codes as the TypeScript and Java libraries and the command line. It is a library: it ships no CLI; the command of the ecosystem is stxt.

This page is the usage guide: parsing, walking and building trees, validating against schemas and templates, resolving which grammar applies to a document, obtaining its canonical JSON and writing STXT back out. It has the same structure as the TypeScript and Java guides, and the API is the same with snake_case names (getCanonicalNameget_canonical_name): moving from one port to another is a matter of spelling.

Installation

pip install stxt

The package carries type annotations (py.typed), depends on nothing and touches neither the file system nor the environment by itself —except in the host adapters of resolution, which can be replaced—. Everything public is imported from the root package, from stxt import Parser, InlineNode, …; the version is in stxt.__version__.

Parsing

Parser has two entry points. parse_result(text) collects every error and also returns the nodes it managed to build; parse(text) raises a ParseException on the first one and returns list[Node] when there is none. The former is what an editor or a validator wants; the latter, a program for which an invalid document is simply an exception.

from stxt import Parser, ParseException

parser = Parser()
result = parser.parse_result(text)

if result.has_errors():
    for error in result.get_errors():
        print(f"line {error.line} [{error.code}]: {error.message}")
roots = result.get_nodes()   # the root nodes, in order; there may be several

# The "raise on first error" form
try:
    nodes = parser.parse(text)
except ParseException as e:
    print(e.line, e.code, e.message)

Every error is a ParseException with three attributes: line (the line of the document, starting at 1), code (stable, upper-case: INVALID_LINE, MIXED_INDENTATION, INDENTATION_LEVEL_NOT_VALID…) and message —also as get_line(), get_code() and get_message()—. Grammar errors are ValidationException, a subclass with the same attributes, so a single loop walks both and isinstance tells the severity apart. A document with a syntax error may still return nodes: the parser recovers and carries on, so that an editor can underline everything that is wrong at once.

The tree

Node is an abstract class with exactly two forms, and each one owns only what is really its own:

Class Syntax What it owns
InlineNode Name: value get_value()/set_value(), get_children(), get_child(name), get_children_by_name(name), add_child(), remove_child(), add_inline_node(), add_text_node()
TextNode Name >> get_text_lines(), set_text(), set_text_lines(), add_text_line(), clear_text()

What they share lives in Node: get_name() and get_canonical_name() (the canonical name of STXT-SPEC: lower-case, NFC, unified separators), get_declared_namespace() (what the node writes between parentheses, or "") and get_namespace() (the effective one, inherited through the chain of parents), get_line(), get_level() (derived from the depth), get_parent() (always an InlineNode, or None at the root), detach() and get_text() —the value of an inline node or the joined lines of a block—. Walking a tree means asking for the form with isinstance, the same way the canonical tree of STXT-TREE-SPEC has children only for inline nodes and lines only for blocks. The hierarchy is closed: InlineNode and TextNode cannot be subclassed.

A detail of this port: get_children() and get_text_lines() return tuples (read-only views); the tree is changed with add_child, remove_child and detach, never by mutating what they return.

With the document of the tutorial, a book record:

Book (com.acme.book):
	Title: Arquitectura de software moderna
	Authors:
		Author: María Pérez
		Author: Juan García
	ISBN: 978-84-123456-7-8
	Published: 2025-10-01
	Chapter: Introducción
		Content >>
			Conceptos básicos y objetivos del libro.
from stxt import Parser, InlineNode, TextNode, Node

book = Parser().parse_result(text).get_nodes()[0]

book.get_name()            # "Book"
book.get_canonical_name()  # "book"
book.get_namespace()       # "com.acme.book"
book.get_line()            # 1

if isinstance(book, InlineNode):
    book.get_child("Title").get_text()                 # "Arquitectura de software moderna"
    book.get_child("title").get_name()                 # "Title": lookups go by canonical name
    book.get_child("Publisher")                        # None: not there

    authors = book.get_child("Authors")
    [a.get_text() for a in authors.get_children_by_name("Author")]   # ['María Pérez', 'Juan García']
    authors.get_declared_namespace()                   # "": it declares none…
    authors.get_namespace()                            # "com.acme.book": …it inherits it

    content = book.get_child("Chapter").get_child("Content")
    if isinstance(content, TextNode):
        content.get_text_lines()   # ('Conceptos básicos y objetivos del libro.',)
        content.get_level()        # 2
        content.get_parent() is book.get_child("Chapter")   # True

# A generic walk
def walk(node: Node, depth: int = 0) -> None:
    print("  " * depth + node.get_name())
    if isinstance(node, InlineNode):
        for child in node.get_children():
            walk(child, depth + 1)

Building and editing

Trees are mutable and keep their own integrity: every node knows its parent, add_child links both ends and raises RuntimeException if the node already has a parent (NODE_ALREADY_ATTACHED) or is an ancestor (NODE_CYCLE); remove_child and detach() undo it. Levels are derived from the chain of parents, and the line is only set by the parser. In the constructors and factories with two strings, the second one is always the content (value or text); the namespace only appears in the three-argument form, InlineNode(name, namespace, value), and the keyword arguments value=, namespace= and text= are accepted too.

from stxt import InlineNode, TextNode

email = InlineNode("Email", "com.example.mail", "Weekly report")
email.add_inline_node("From", "Ana García <[email protected]>")
to = email.add_inline_node("To")
to.add_inline_node("Address", "[email protected]")
body = email.add_text_node("Body", "Hi Bob,\n\nSee attached.")

body.get_parent() is email    # True
body.get_level()              # 1
to.get_namespace()            # "com.example.mail", inherited

# Reorder: "To" first
to.detach()
email.add_child(to, 0)

# Edit
email.set_namespace("com.example.docs")   # the whole inheriting subtree follows
body.set_text("Hi Bob,\n\nSee the new attachment.")

Validating against a schema or a template

Grammars are STXT documents in the reserved namespaces @stxt.schema and @stxt.template; the template is the short authoring form and compiles to a schema when loaded. UnifiedSchemaProvider loads either kind with add_file(text): it parses, validates against the corresponding meta-schema and registers the schema by namespace. Validation is a Validator registered on the Parser that runs on each node as it is closed; ConditionalValidator wraps it so that only nodes with a namespace are validated, which is the rule of the language: a document without a namespace is not wrong, it just cannot be validated.

Template (@stxt.template): com.acme.book
	Structure >>
		Book:
			Title: (1)
			Authors: (1)
				Author: (+)
			ISBN: (1)
			Publisher: (?)
			Published: (?) DATE
			Summary: (?) TEXT
			Chapter: (+)
				Content: (?) TEXT
	Description >>
		Book: Template for publisher book records
from stxt import (
    Parser, UnifiedSchemaProvider, SchemaValidator, ConditionalValidator,
    ValidationException,
)

provider = UnifiedSchemaProvider()
provider.add_file(template_text)            # raises if the template does not validate against its meta-schema

parser = Parser()
parser.register_validator(ConditionalValidator(SchemaValidator(provider)))

result = parser.parse_result(document_text)
for error in result.get_errors():
    kind = "schema" if isinstance(error, ValidationException) else "syntax"
    print(f"{kind} line {error.line} [{error.code}]: {error.message}")

With a book that lacks its ISBN and whose date is not YYYY-MM-DD, the loop prints exactly what stxt validate would print:

schema line 5 [INVALID_VALUE]: Published: Invalid date (1 de octubre de 2025)
schema line 1 [INVALID_NUMBER]: 0 nodes of 'com.acme.book:isbn' and min is 1

Rules worth knowing:

  • A cardinality error is reported on the line of the parent, which is the one with zero ISBN.
  • If the document uses a namespace the provider does not know, each node produces a SCHEMA_NOT_FOUND; the provider never raises for a missing namespace (get_schema() returns None).
  • Nodes without a namespace are not validated (thanks to ConditionalValidator); a document that is itself a definition is always validated against its meta-schema.
  • add_file accepts files with several definitions, and get_all_schemas() lists them; clear() empties the provider.
  • The available value types are those of STXT-SCHEMA-SPEC: INLINE, BLOCK, TEXT, MARKDOWN, BOOLEAN, INTEGER, NATURAL, NUMBER, DATE, TIME, TIMESTAMP, UUID, EMAIL, URL, HEXADECIMAL, BINARY, BASE64, GROUP and ENUM.

The compiled schema can be inspected: provider.get_schema(ns) returns a Schema with get_namespace() and get_node_definition(name); each NodeDefinition has get_type(), get_children() (a dictionary of ChildDefinition by qualified name namespace:name, with get_min() / get_max()), get_values() for an ENUM and get_description().

Resolution: which grammar applies to a document

UnifiedSchemaProvider expects you to hand it the grammar text. Resolution answers the previous question: given this document, which definitions apply to it? DiscoveryResolver implements STXT-DISCOVERY-SPEC —the .stxt/ directories of the document and of all its ancestors, then ~/.stxt and /etc/stxt, per-namespace precedence, STXT_PATH replacing the chain—, just like the CLI and the extension, so all three agree by construction.

As in the TypeScript port, the resolver touches neither the file system nor the environment by itself: it receives a DiscoveryFileSystem and a DiscoveryEnvironment. The difference is that the package already ships the two host adaptersOsDiscoveryFileSystem over os and SystemDiscoveryEnvironment over STXT_PATH, ~/.stxt and /etc/stxt (or %ProgramData%\stxt)— and the stxt.discovery.resolve(document_dir) shortcut over them; a test can pass an in-memory tree instead. The chain is per document: pass the directory it lives in (None for standard input or an unsaved buffer, which starts the chain at the user level). Since DiscoveryResult implements SchemaProvider, resolving and validating are two steps:

from stxt import Parser, SchemaValidator, ConditionalValidator
from stxt.discovery import resolve

discovery = resolve("/home/ana/libros/docs")

discovery.get_chain()        # ['/home/ana/libros/.stxt']  (every ancestor, nearest first)

# Resolution errors are collected, never raised: report them and carry on
for error in discovery.get_errors():
    print(f"[{error.code}] {error.file}: {error.message}")

parser = Parser()
parser.register_validator(ConditionalValidator(SchemaValidator(discovery)))
result = parser.parse_result(document_text)

DiscoveryResult also tells you where each grammar came from, which is what an editor needs for "go to definition" or a diagnostic that explains itself:

definition = discovery.get_definition("com.acme.book")
definition.file        # '/home/ana/libros/.stxt/@stxt.template/com.acme.book.stxt'
definition.level_dir   # '/home/ana/libros/.stxt'  (the level that won)
definition.schema      # the compiled Schema

discovery.get_active_definitions()   # one per namespace, precedence applied
discovery.get_all_schemas()          # just the schemas of the above

To resolve many documents, keep a DiscoveryResolver of your own: it caches the levels by directory and reads each .stxt/ once; call clear_cache() when the definition files may have changed:

from stxt import DiscoveryResolver, OsDiscoveryFileSystem, SystemDiscoveryEnvironment

resolver = DiscoveryResolver(OsDiscoveryFileSystem(), SystemDiscoveryEnvironment())
discovery = resolver.resolve("/home/ana/libros/docs")
resolver.clear_cache()

The resolution error codes are DISCOVERY_DUPLICATE_NAMESPACE, DISCOVERY_NOT_A_DEFINITION, DISCOVERY_NOT_PARSEABLE and DISCOVERY_INVALID_DEFINITION; DiscoveryError is a data class (code, file, message, namespace), not an exception, because the spec wants a bad definition reported without stopping the load of the rest.

The canonical tree as JSON

to_canonical_tree(nodes) turns the root nodes into the JSON value of STXT-TREE-SPEC —a list of dictionaries, the same tree stxt describe emits— and to_canonical_json(nodes) serialises it with two-space indentation. It is an explicit function that emits only the normative fields, children for inline nodes and lines for blocks, with no positions or comments.

from stxt import Parser, to_canonical_tree, to_canonical_json

nodes = Parser().parse_result(text).get_nodes()
tree = to_canonical_tree(nodes)
tree[0]["name"]    # 'Book'
tree[0]["form"]    # 'inline'

print(to_canonical_json(nodes))

Writing STXT

NodeWriter makes the trip back: it serialises a node, or a list of documents, to STXT text in canonical form, with IndentStyle.TABS (default) or IndentStyle.SPACES_4. It writes the namespace where the node declares it, not where it differs from the parent: faithful to the source. Since it starts from the logical tree, a text that goes through parse and NodeWriter comes out without comments or blank lines outside blocks —that is what stxt format --clean does—; the reformatting that keeps them is the job of the CLI and the extension, not of the library.

from stxt import NodeWriter, IndentStyle

one = NodeWriter.to_stxt(email)                                          # one node, with tabs
all = NodeWriter.to_stxt_docs(result.get_nodes(), IndentStyle.SPACES_4)   # a whole document

The email built above, written with to_stxt:

Email (com.example.mail): Weekly report
	To:
		Address: [email protected]
	From: Ana García <[email protected]>
	Body >>
		Hi Bob,

		See attached.

Observing the parse

Observer is a base class with four empty callbacks —override the ones you need— that the parser calls while streaming: on_create(node, line_string) when a node is opened —already with its parent, effective namespace and level—, on_finish(node) when it is closed, on_comment(line_number, line_string) and on_text_line(node, line_number, line_string, line_indent) for each line of a block. Validator is the other hook: it runs on each node as it is closed and returns a list of ValidationException (it does not raise); SchemaValidator is just a built-in Validator.

from stxt import Parser, Observer, Node

class LoggingObserver(Observer):
    def on_create(self, node: Node, line_string: str) -> None:
        print("open", node.get_qualified_name())
    def on_finish(self, node: Node) -> None:
        print("close", node.get_qualified_name())

parser = Parser()
parser.register_observer(LoggingObserver())
parser.parse_result(text)

The API surface

Everything importable from stxt; the subpackages (stxt.schema, stxt.discovery…) are importable too:

Group Names
Parsing Parser, ParseResult, Node, InlineNode, TextNode, NO_LINE, LineIndent, parse_line, EMPTY_NAMESPACE
Errors ParseException, ValidationException, RuntimeException
Extension points Observer, Validator
Schemas Schema, NodeDefinition, ChildDefinition, SchemaProvider, SchemaProviderMemory, SchemaProviderMeta, SchemaValidator, transform_node_to_schema, SCHEMA_NAMESPACE
Templates MetaTemplateSchemaProvider, TemplateSchemaProviderMemory, transform_template_node_to_schema, TEMPLATE_NAMESPACE
Runtime UnifiedSchemaProvider, ConditionalValidator, NodeWriter, IndentStyle, to_canonical_tree, to_canonical_json
Resolution DiscoveryResolver, DiscoveryResult, DiscoveryDefinition, DiscoveryLevel, DiscoveryError, DiscoveryFileSystem, DiscoveryEntry, DiscoveryEnvironment, OsDiscoveryFileSystem, SystemDiscoveryEnvironment and stxt.discovery.resolve

Until 1.0, a minor release can change the in-memory API, but never the language or the canonical tree: a document that parses and validates today parses and validates the same tomorrow. The release notes of each version are in the repository, which is also where its errors are reported.