La biblioteca Python

stxt es el parser de STXT para Python, publicado en PyPI: Python puro, sin dependencias, 3.10 o superior. Implementa las cinco especificaciones y devuelve los mismos códigos de error que las bibliotecas TypeScript y Java.

La API es la de los otros dos puertos, con nombres en snake_case (getCanonicalName → get_canonical_name). No incluye línea de comandos; la del ecosistema es stxt.

Instalación

pip install stxt

El paquete lleva anotaciones de tipo (py.typed). Solo accede al sistema de ficheros y al entorno en los adaptadores de la resolución, que se pueden sustituir. Todo lo público se importa del paquete raíz; la versión está en stxt.__version__.

Parsear

Entrada Comportamiento
parse_result(text) Acumula todos los errores y devuelve también los nodos que ha podido construir
parse(text) Lanza una ParseException en el primer error; sin errores, devuelve list[Node]
parse_stream(lines) No retiene nodos ni errores; ver Streaming y límites del parser
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()   # los nodos raíz, en orden; puede haber varios

# La forma "lanzar al primer error"
try:
    nodes = parser.parse(text)
except ParseException as e:
    print(e.line, e.code, e.message)

Cada error es una ParseException con line (desde 1), code y message, también como get_line(), get_code() y get_message(). Los errores de gramática son ValidationException, una subclase: un solo bucle recorre los dos tipos e isinstance los distingue. Tras un error de sintaxis el parser se recupera y sigue, y el resultado puede incluir nodos.

El árbol

Node es una clase abstracta con dos formas, y no se puede heredar de InlineNode ni de TextNode:

Clase Sintaxis Métodos propios
InlineNode Nombre: valor 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 Nombre >> get_text_lines(), set_text(), set_text_lines(), add_text_line(), clear_text()

Métodos comunes, en Node:

Método Descripción
get_name() El nombre tal como se escribió
get_canonical_name() El nombre canónico (STXT-SPEC §4.3)
get_declared_namespace() El namespace escrito entre paréntesis, o ""
get_namespace() El namespace efectivo, heredado de los padres
get_line() La línea del documento
get_level() El nivel, derivado de la profundidad
get_parent() El InlineNode padre, o None en la raíz
get_text() El valor de un inline, o las líneas unidas de un bloque
detach() Separa el nodo de su padre

get_children() y get_text_lines() devuelven tuplas de solo lectura; el árbol se modifica con add_child, remove_child y detach.

La forma de un nodo se distingue con isinstance. Con el documento del tutorial:

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": la búsqueda es por nombre canónico
    book.get_child("Publisher")                        # None: no existe

    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()                   # "": no lo declara...
    authors.get_namespace()                            # "com.acme.book": ...lo hereda

    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

# Un recorrido genérico
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)

Construir y modificar

Los árboles son mutables:

  • add_child lanza RuntimeException si el nodo ya tiene padre (NODE_ALREADY_ATTACHED) o es un ancestro (NODE_CYCLE); remove_child y detach() lo separan.
  • set_value y add_text_line rechazan un salto de línea (LINE_BREAK_NOT_ALLOWED).
  • El nivel se deriva de la cadena de padres, y la línea solo la fija el parser.
  • En los constructores y fábricas con dos cadenas, la segunda es el contenido (valor o texto); el namespace solo aparece en la forma de tres argumentos, InlineNode(name, namespace, value). También valen los argumentos con nombre value=, namespace= y text=.
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", heredado

# Reordenar: "To" al principio
to.detach()
email.add_child(to, 0)

# Editar
email.set_namespace("com.example.docs")   # todo el subárbol que hereda le sigue
body.set_text("Hi Bob,\n\nSee the new attachment.")

Validar contra un esquema o una plantilla

UnifiedSchemaProvider carga esquemas y plantillas con add_file(text): parsea la definición, la valida contra su meta-esquema y registra el esquema por namespace. SchemaValidator es un Validator que se registra en el Parser y valida cada nodo con namespace al cerrarlo.

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: Plantilla para fichas de libros editoriales
from stxt import (
    Parser, UnifiedSchemaProvider, SchemaValidator,
    ValidationException,
)

provider = UnifiedSchemaProvider()
provider.add_file(template_text)            # lanza si la plantilla no valida contra su meta-esquema

parser = Parser()
parser.register_validator(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}")

Con un libro sin ISBN y con una fecha que no es YYYY-MM-DD, el bucle imprime:

schema line 6 [INVALID_VALUE]: Published: Invalid date (1 de octubre de 2025)
schema line 1 [TOO_FEW_CHILDREN]: 0 nodes of 'com.acme.book:isbn' and min is 1
  • Un error de cardinalidad se señala en la línea del padre.
  • Un namespace que el proveedor no conoce produce un SCHEMA_NOT_FOUND por nodo; el proveedor no lanza (get_schema() devuelve None).
  • Los nodos sin namespace no se validan (STXT-SCHEMA-SPEC §5). Una definición se valida siempre contra su meta-esquema.
  • add_file acepta ficheros con varias definiciones; get_all_schemas() las enumera y clear() vacía el proveedor.
  • Están implementados todos los tipos de STXT-SCHEMA-SPEC §9.

El esquema compilado se puede inspeccionar: provider.get_schema(ns) devuelve un Schema con get_namespace() y get_node_definition(name); cada NodeDefinition tiene get_type(), get_children() (un diccionario de ChildDefinition por nombre cualificado namespace:nombre, con get_min() / get_max()), get_values() para un ENUM y get_description().

Resolución de gramáticas

DiscoveryResolver implementa STXT-DISCOVERY-SPEC: dado el directorio de un documento, determina qué definiciones le aplican (los .stxt/ del documento y de sus ancestros, después ~/.stxt y /etc/stxt, con precedencia por namespace; STXT_PATH sustituye la cadena), igual que la CLI y la extensión.

El resolutor recibe un DiscoveryFileSystem y un DiscoveryEnvironment. El paquete incluye OsDiscoveryFileSystem (sobre os) y SystemDiscoveryEnvironment (STXT_PATH, ~/.stxt y /etc/stxt o %ProgramData%\stxt), y el atajo stxt.discovery.resolve(document_dir) sobre ellos; un test puede pasar un árbol en memoria. A resolve se le pasa el directorio del documento, o None para la entrada estándar o un búfer sin guardar; en ese caso la cadena empieza en el nivel de usuario. DiscoveryResult implementa SchemaProvider y se pasa directamente al validador:

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

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

discovery.get_chain()        # ['/home/ana/libros/.stxt']  (todos los ancestros, el más cercano primero)

# Los errores de resolución se acumulan, no se lanzan
for error in discovery.get_errors():
    print(f"[{error.code}] {error.file}: {error.message}")

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

DiscoveryResult registra también el origen de cada definición:

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'  (el nivel que ha ganado)
definition.schema      # el Schema compilado

discovery.get_active_definitions()   # una por namespace, con la precedencia aplicada
discovery.get_all_schemas()          # solo los esquemas de las anteriores

Un DiscoveryResolver propio cachea los niveles por directorio, para resolver muchos documentos; clear_cache() los invalida cuando los ficheros de definición pueden haber cambiado:

from stxt import DiscoveryResolver, OsDiscoveryFileSystem, SystemDiscoveryEnvironment

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

Los errores son DiscoveryError (code, file, message, namespace), con los códigos DISCOVERY_DUPLICATE_NAMESPACE, DISCOVERY_NOT_A_DEFINITION, DISCOVERY_NOT_PARSEABLE y DISCOVERY_INVALID_DEFINITION.

El árbol canónico en JSON

to_canonical_tree(nodes) devuelve el valor JSON de STXT-TREE-SPEC como una lista de diccionarios, el mismo árbol que emite stxt describe, y to_canonical_json(nodes) lo serializa con dos espacios de sangría. Emiten solo los campos normativos, sin posiciones ni comentarios.

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))

Escribir STXT

NodeWriter serializa un nodo, o una lista de nodos raíz, en la forma canónica de STXT-TREE-SPEC §11, con IndentStyle.TABS (por defecto) o IndentStyle.SPACES_4. Escribe el namespace solo donde cambia respecto al padre. Como parte del árbol, la salida no lleva comentarios ni líneas en blanco fuera de los bloques; es lo que hace stxt format --clean.

from stxt import NodeWriter, IndentStyle

one = NodeWriter.to_stxt(email)                                          # un nodo, con tabuladores
all = NodeWriter.to_stxt_docs(result.get_nodes(), IndentStyle.SPACES_4)   # un documento entero

El email construido más arriba, escrito con to_stxt:

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

		See the new attachment.

Formatter (STXT-TREE-SPEC §12) reformatea conservando comentarios y líneas en blanco: reescribe el texto original línea a línea y devuelve, junto al texto, los errores de sintaxis encontrados. Aplica las mismas reglas que stxt format, la extensión y el playground.

from stxt import Formatter, IndentStyle

formatted = Formatter.format(source, IndentStyle.TABS)
text, errors = formatted.text, formatted.errors

Observar y validar durante el parseo

Observer es una clase base con cuatro callbacks vacíos, de los que se redefinen los necesarios. El parser los llama durante el parseo: on_create(node, line_string) al abrir un nodo (ya con padre, namespace efectivo y nivel), on_finish(node) al cerrarlo, on_comment(line_number, line_string) por cada comentario y on_text_line(node, line_number, line_string, line_indent) por cada línea de un bloque.

Un Validator se ejecuta sobre cada nodo al cerrarlo y devuelve una lista de ValidationException, sin lanzar. SchemaValidator es el que trae la biblioteca.

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)

Streaming y límites del parser

parse_stream(lines) recibe un iterable de líneas, cada una sin su salto de línea (por ejemplo, un fichero abierto), y no retiene nodos ni errores. Los resultados llegan por un StreamObserver, una clase base con dos callbacks: on_root_node(node) con cada nodo raíz completo y ya validado, que el parser libera tras la llamada, y on_error(error) con cada error, de sintaxis o de validación. La memoria en uso es la de un árbol raíz, lo que permite procesar ficheros que no caben en memoria. Un StreamObserver registrado recibe las mismas llamadas con parse y parse_result.

from stxt import Parser, StreamObserver, Node, ParseException

class Counter(StreamObserver):
    def __init__(self) -> None:
        self.roots = 0
    def on_root_node(self, node: Node) -> None:
        self.roots += 1
    def on_error(self, error: ParseException) -> None:
        print(error.line, error.code, error.message)

parser = Parser()
counter = Counter()
parser.register_stream_observer(counter)
with open("registro.stxt", encoding="utf-8") as f:
    parser.parse_stream(line.rstrip("\n") for line in f)
counter.roots

El parser aplica los límites de STXT-SPEC §11.2, configurables en el constructor: max_nesting (100 niveles), max_line_length (10 000 caracteres) y max_input_size (10 000 000 caracteres); -1 desactiva uno. Superar un límite produce una LimitException (subclase de ParseException, con los códigos LIMIT_NESTING_EXCEEDED, LIMIT_LINE_LENGTH_EXCEEDED y LIMIT_INPUT_SIZE_EXCEEDED) y aborta el parseo.

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

La superficie de la API

Todo lo importable de stxt; los subpaquetes (stxt.schema, stxt.discovery...) también lo son:

Grupo Nombres
Parseo Parser, ParseResult, Node, InlineNode, TextNode, NO_LINE, LineIndent, parse_line, EMPTY_NAMESPACE, SPEC_VERSION
Errores ParseException, ValidationException, LimitException, RuntimeException
Puntos de extensión Observer, StreamObserver, Validator
Esquemas Schema, NodeDefinition, ChildDefinition, SchemaProvider, SchemaProviderMemory, SchemaProviderMeta, SchemaValidator, Type, TypeRegistry, transform_node_to_schema, SCHEMA_NAMESPACE
Plantillas MetaTemplateSchemaProvider, TemplateSchemaProviderMemory, transform_template_node_to_schema, TEMPLATE_NAMESPACE
Runtime UnifiedSchemaProvider, NodeWriter, IndentStyle, Formatter, FormatResult, to_canonical_tree, to_canonical_json
Resolución DiscoveryResolver, DiscoveryResult, DiscoveryDefinition, DiscoveryLevel, DiscoveryError, DiscoveryFileSystem, DiscoveryEntry, DiscoveryEnvironment, OsDiscoveryFileSystem, SystemDiscoveryEnvironment y stxt.discovery.resolve

El paquete sigue el versionado semántico: la API no cambia de forma incompatible dentro de la línea 1.x. El estado de cada especificación está en Estabilidad y versiones. Los cambios de cada versión están en el repositorio, donde se reportan también los errores.