Skip to main content

stxt

Parser and schema validator for STXT, an indentation-based structured-text format, in pure Python (no dependencies, Python 3.10+).

STXT is a plain-text format for writing structured, semantic documents: no braces, no closing tags, just indentation. It is designed to be equally readable by humans and by machines, and it comes with an optional schema layer so documents can be validated.

This package is a port of the language's neutral implementation blueprint (stxt-impl), so it shares its behaviour, its node model and its error codes with the other implementations.

What STXT looks like

# A line starting with '#' is a comment

Article (blog.post):
    Title: Getting started with STXT
    Author: Joan
    Published: 2026-07-28
    Tags:
        Tag: parser
        Tag: text-format
    Body >>
        Everything indented under a '>>' node is kept verbatim
        as a block of text lines.
  • Name: value declares an inline node.
  • Name >> opens a text block; every deeper-indented line belongs to it.
  • Indentation is one level per tab or per 4 spaces.
  • Name (a.b.c): attaches a namespace to a node; children inherit it unless they declare their own.

Install

pip install stxt

Parsing

from stxt import Parser, InlineNode

text = "Article (blog.post):\n\tTitle: Getting started with STXT\n\tAuthor: Joan\n"

parser = Parser()

# parse_result() collects every error instead of stopping at the first one
result = parser.parse_result(text)

for error in result.get_errors():
    print(f"line {error.line} [{error.code}]: {error.message}")

article = result.get_nodes()[0]

print(article.get_name())        # "Article"
print(article.get_namespace())   # "blog.post"
if isinstance(article, InlineNode):
    print(article.get_child("Title").get_text())   # "Getting started with STXT"

Use parser.parse(text) instead if you prefer an exception (ParseException) on the first error.

Working with the tree

Node is an abstract class with exactly two forms, and each one owns only what is really its own: InlineNode (Name: value) has the optional value, the children and the child lookups (get_children(), get_child(name), get_children_by_name(name)); TextNode (Name >>) has the literal text lines and nothing else. What they share lives in Node: name and canonical name, declared and effective namespace, source line, parent (always an InlineNode) and get_text() — the value of an inline node or the joined lines of a text node. Walking a tree therefore asks for the form (isinstance(node, InlineNode)), the same way the canonical tree of STXT-TREE-SPEC has children only for inline nodes.

Trees are mutable and keep their own integrity: every node knows its parent, add_child links both ends and refuses a node that already has one, and remove_child / detach() undo it. Levels are derived from the chain of parents; the source line is only set by the parser.

from stxt import InlineNode, TextNode

email = InlineNode("Email", "com.example.docs", "Weekly report")
email.add_inline_node("From", "ana@example.com")
to = email.add_inline_node("To")
to.add_inline_node("Address", "bob@example.com")
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.docs", inherited
to.get_declared_namespace()     # "" — it declares none

# Reorganise: move "To" to the front
to.detach()
email.add_child(to, 0)

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

for child in email.get_children():
    if isinstance(child, InlineNode):
        print(child.get_value(), len(child.get_children()))
    if isinstance(child, TextNode):
        print(child.get_text_lines())

Constructors with two strings always take the second one as the content (value or text); the namespace only appears in the three-argument forms (InlineNode(name, namespace, value)), and value= / namespace= / text= are accepted as keywords too. Adding a node that already has a parent raises RuntimeException with code NODE_ALREADY_ATTACHED; adding an ancestor, NODE_CYCLE.

Validating against a schema

Schemas are themselves STXT documents, written in the reserved @stxt.schema namespace (or in the friendlier @stxt.template form, which compiles to a schema). UnifiedSchemaProvider loads either kind, validates it against the corresponding meta-schema, and registers it by namespace.

from stxt import Parser, SchemaValidator, UnifiedSchemaProvider, ValidationException

schema_text = """Schema (@stxt.schema): blog.post
\tNode: Article
\t\tChildren:
\t\t\tChild: Title
\t\t\t\tMin: 1
\t\t\t\tMax: 1
\t\t\tChild: Author
\t\t\t\tMin: 1
\tNode: Title
\tNode: Author
"""

provider = UnifiedSchemaProvider()
provider.add_file(schema_text)

parser = Parser()
# Only nodes that carry a namespace are validated; free nodes pass through
parser.register_validator(SchemaValidator(provider))

result = parser.parse_result(document_text)

for error in result.get_errors():
    # Schema problems are ValidationException; syntax problems are plain ParseException
    severity = "warning" if isinstance(error, ValidationException) else "error"
    print(f"{severity} at line {error.line} [{error.code}]: {error.message}")

Available value types: INLINE, BLOCK, TEXT, MARKDOWN, BOOLEAN, INTEGER, NATURAL, NUMBER, DATE, TIME, TIMESTAMP, UUID, EMAIL, URL, HEXADECIMAL, BINARY, BASE64, GROUP, ENUM.

Finding the schemas: discovery

UnifiedSchemaProvider expects you to hand it the schema text. Discovery answers the previous question: given this document, which schema definitions apply to it? DiscoveryResolver implements the STXT discovery specification, so a command line, an editor and a build step all agree on the answer by construction.

Definitions live in .stxt/ directories. For a given document the resolution chain is, highest precedence first:

  1. every ancestor .stxt/ directory, nearest first — the ascent does not stop at the first one, so in a monorepo both the subproject's and the repo root's participate;
  2. the user level, $HOME/.stxt (%USERPROFILE%\.stxt on Windows);
  3. the system level, /etc/stxt (%ProgramData%\stxt on Windows).

Precedence is per namespace: the nearest level that defines a namespace wins, and the rest of the chain still contributes the namespaces that level does not define. Defining one namespace twice at the same level is a resolution error, and leaves that namespace without an active definition. When STXT_PATH is defined it replaces the whole chain — useful in CI and tests.

The resolver never touches the file system or the environment itself: you inject a DiscoveryFileSystem and a DiscoveryEnvironment. The package ships the two host adapters, OsDiscoveryFileSystem and SystemDiscoveryEnvironment, and a resolve() shortcut over them; a test can pass an in-memory tree instead. DiscoveryResult implements SchemaProvider, so it goes straight into the validator:

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

# The chain is per document: pass the directory the document lives in
# (None for stdin or an unsaved buffer, which starts the chain at the user level).
result = resolve("/repo/site/posts")

print(result.get_chain())
# ['/repo/site/.stxt', '/repo/.stxt']   <- both ancestors, nearest first

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

parser = Parser()
parser.register_validator(SchemaValidator(result))
parsed = parser.parse_result(document_text)

definition = result.get_definition("blog.post")
print(definition.file)        # '/repo/site/.stxt/blog.stxt'
print(definition.level_dir)   # '/repo/site/.stxt'  <- the level that won

Levels are cached by directory; call resolver.clear_cache() on a DiscoveryResolver when the definition files may have changed.

Observing the parse

Observer receives streaming callbacks while the document is parsed — useful for syntax highlighting, indexes or any per-line bookkeeping. Subclass it and override what you need.

from stxt import Observer, Parser

class LoggingObserver(Observer):
    def on_create(self, node, line_string):
        print("open", node.get_qualified_name())

    def on_finish(self, node):
        print("close", node.get_qualified_name())

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

StreamObserver watches the results instead of the process: each completed root node and each error, in every mode. With parse_stream the parser retains nothing — no nodes, no errors — so a file larger than memory can be processed one root tree at a time:

from stxt import Parser, StreamObserver

class Roots(StreamObserver):
    def on_root_node(self, node):
        print("root", node.get_qualified_name())  # one complete root at a time

    def on_error(self, error):
        print(error)  # "[CODE] line N: message"

parser = Parser()
parser.register_stream_observer(Roots())
with open("data.stxt", encoding="utf-8") as f:
    parser.parse_stream(f)  # any iterable of lines; the trailing "\n" is removed

Parser limits

The parser rejects hostile or runaway inputs by default (STXT-SPEC §11.2): documents nesting more than 100 levels, lines longer than 10 000 characters, or inputs over 10 000 000 characters. A limit error is a LimitException (LIMIT_NESTING_EXCEEDED, LIMIT_LINE_LENGTH_EXCEEDED, LIMIT_INPUT_SIZE_EXCEEDED) and aborts the parse: it is always the last error reported. Each limit is configurable per parser; -1 disables it:

parser = Parser(max_nesting=500, max_input_size=-1)

Writing STXT back out, and the canonical tree

from stxt import IndentStyle, NodeWriter, to_canonical_json, to_canonical_tree

text = NodeWriter.to_stxt(node, IndentStyle.TABS)                     # a single node
doc = NodeWriter.to_stxt_docs(result.get_nodes(), IndentStyle.SPACES_4)  # a whole document

tree = to_canonical_tree(result.get_nodes())   # the STXT-TREE-SPEC data model (list of dicts)
json_text = to_canonical_json(result.get_nodes())

NodeWriter re-serializes the tree, so comments and blank lines are gone. To reformat a document keeping everything the author wrote, use Formatter: it rewrites the original text line by line — node lines in canonical form, block lines re-indented to their block, comments and blank lines kept with their indentation units converted — and reports the syntax errors it met, so the caller decides what to do with a document that does not parse.

from stxt import Formatter, IndentStyle

result = Formatter.format(source, IndentStyle.TABS)
if not result.errors:
    path.write_text(result.text, encoding="utf-8")

Formatter.format takes the same limits as the parser as keyword arguments — Formatter.format(source, IndentStyle.TABS, max_input_size=-1) — since formatting parses the document with them (STXT-SPEC §11.2).

API surface

Everything importable from stxt:

  • ParsingParser, ParseResult, Node, InlineNode, TextNode, NO_LINE, LineIndent, parse_line
  • ExceptionsParseException, ValidationException, LimitException, RuntimeException. Their message is only the description; str(e) adds the frame: [CODE] line N: message (or [CODE] message for RuntimeException)
  • Versions__version__ (the package) and SPEC_VERSION (the specifications it implements)
  • Extension pointsObserver, StreamObserver, Validator
  • SchemasSchema, SchemaValidator, SchemaProvider, SchemaProviderMemory, SchemaProviderMeta, NodeDefinition, ChildDefinition, transform_node_to_schema
  • TemplatesTemplateSchemaProviderMemory, MetaTemplateSchemaProvider, transform_template_node_to_schema
  • RuntimeUnifiedSchemaProvider, NodeWriter, IndentStyle, Formatter, FormatResult, to_canonical_tree, to_canonical_json
  • DiscoveryDiscoveryResolver, DiscoveryResult, DiscoveryDefinition, DiscoveryLevel, DiscoveryError, DiscoveryFileSystem, DiscoveryEntry, DiscoveryEnvironment, OsDiscoveryFileSystem, SystemDiscoveryEnvironment (and stxt.discovery.resolve)

Development

python -m venv .venv && . .venv/bin/activate
pip install -e ".[test]"
pytest

The tests are regression tests against the real corpus of the sibling repository stxt-lang (the language specifications and their examples). The corpus is mandatory: clone stxt-lang next to this repository, or point at it with STXT_LANG=/path/to/stxt-lang; without it the corpus suites fail, they are never skipped.

Conformance

stxt implements the five STXT specifications at SPEC_VERSION (exposed by the package; the package version is independent) and passes every case of the official conformance kit, stxt-lang/conformance, across all its profiles: core, schema, template, discovery and text. The kit is the same one any other implementation can run, which is what makes the three ports interchangeable. What the 1.0 line freezes, and what it does not, is stated at https://stxt.dev/lang-stability.

License

MIT — see LICENSE.

Release files for stxt 0.15.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 stxt 0.15.0
File Size Uploaded
stxt-0.15.0.tar.gz 85.7 kB Details

Built distribution (wheel)

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

Total release size: 156.2 kB

Release files / stxt-0.15.0.tar.gz

Download URL stxt-0.15.0.tar.gz
Size 85.7 kB
Tags Source
SHA-256 checksum
How to use checksums
ef2e24e6cb03f4674d3dd2ca0c4a89861bbfe2131ba533a9c8ff6ea8c7367a7e
BLAKE2b-256 checksum
How to use checksums
aee8df4bd2ffed72d535a8c57419f65808871833982069895e434801c28d607d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.5

Release files / stxt-0.15.0-py3-none-any.whl

Download URL stxt-0.15.0-py3-none-any.whl
Size 70.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
00cf28af680c066b203b026714b6df8cc55d27e932e5ee5a47e59ad7760b7218
BLAKE2b-256 checksum
How to use checksums
a5ec7b052913a968408734aeb964c928049034210bf318f982e6c75aee9217be
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.5

Release history Release notifications | RSS feed

1.0.4

2 release files

1.0.3

2 release files

1.0.2

2 release files

1.0.1

2 release files

1.0.0

2 release files

0.17.0

2 release files

0.16.0

2 release files

This release

0.15.0 This release

2 release files

0.14.1

2 release files

0.14.0

2 release files

0.13.0

2 release files

0.12.0

2 release files

0.11.0

2 release files

0.10.0

2 release files

0.9.1

2 release files

0.9.0

2 release files

0.8.1

2 release files

0.8.0

2 release files

0.7.1

2 release files

0.7.0

2 release files

0.0.1

2 release files

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