La biblioteca Python

stxt es el parser de STXT para Python, publicado en PyPI: Python puro, sin dependencias, 3.10 o superior. Es un puerto módulo a módulo del pseudocódigo de referencia del lenguaje, así que implementa las cinco especificaciones —sintaxis, árbol canónico, esquemas, plantillas y resolución— y devuelve los mismos códigos de error que las bibliotecas TypeScript y Java y la línea de comandos. Es una biblioteca: no lleva CLI; el comando del ecosistema es stxt.

Esta página es la guía de uso: parsear, recorrer y construir árboles, validar contra esquemas y plantillas, resolver qué gramática aplica a un documento, obtener su JSON canónico y volver a escribir STXT. Tiene la misma estructura que las guías de TypeScript y Java, y la API es la misma con nombres en snake_case (getCanonicalNameget_canonical_name): pasar de un puerto a otro es cambiar la grafía.

Instalación

pip install stxt

El paquete lleva anotaciones de tipo (py.typed), no depende de nada y no toca por sí mismo ni el sistema de ficheros ni el entorno —salvo en los adaptadores de host de la resolución, que se pueden sustituir—. Todo lo público se importa del paquete raíz, from stxt import Parser, InlineNode, …; la versión está en stxt.__version__.

Parsear

Parser tiene dos entradas. parse_result(text) acumula todos los errores y devuelve además los nodos que ha podido construir; parse(text) lanza una ParseException en el primero y devuelve list[Node] si no hay ninguno. La primera es la que quiere un editor o un validador; la segunda, un programa para el que un documento inválido es simplemente una excepción.

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 tres atributos: line (la línea del documento, empezando en 1), code (estable, en mayúsculas: INVALID_LINE, MIXED_INDENTATION, INDENTATION_LEVEL_NOT_VALID…) y message —también como get_line(), get_code() y get_message()—. Los errores de gramática son ValidationException, una subclase con los mismos atributos, así que un solo bucle recorre los dos e isinstance distingue la severidad. Un documento con un error de sintaxis puede seguir devolviendo nodos: el parser se recupera y sigue, para que un editor pueda subrayar todo lo que está mal de una vez.

El árbol

Node es una clase abstracta con exactamente dos formas, y cada una tiene solo lo que es suyo:

Clase Sintaxis Lo propio
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()

Lo común vive en Node: get_name() y get_canonical_name() (el nombre canónico de STXT-SPEC: minúsculas, NFC, separadores unificados), get_declared_namespace() (lo que el nodo escribe entre paréntesis, o "") y get_namespace() (el efectivo, heredado por la cadena de padres), get_line(), get_level() (derivado de la profundidad), get_parent() (siempre un InlineNode, o None en la raíz), detach() y get_text() —el valor de un inline o las líneas unidas de un bloque—. Recorrer un árbol es preguntar la forma con isinstance, igual que el árbol canónico de STXT-TREE-SPEC solo tiene children en los inline y lines en los bloques. La jerarquía es cerrada: no se puede heredar de InlineNode ni de TextNode.

Detalle propio de este puerto: get_children() y get_text_lines() devuelven tuplas (vistas de solo lectura); el árbol se cambia con add_child, remove_child y detach, nunca mutando lo que devuelven.

Con el documento del tutorial, la ficha de un libro:

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 y mantienen su propia integridad: cada nodo conoce a su padre, add_child engancha los dos extremos y lanza RuntimeException si el nodo ya tiene padre (NODE_ALREADY_ATTACHED) o es un ancestro (NODE_CYCLE); remove_child y detach() lo deshacen. Los niveles se derivan 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 siempre el contenido (valor o texto); el namespace solo aparece en la forma de tres argumentos, InlineNode(name, namespace, value), y 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

Las gramáticas son documentos STXT en los namespaces reservados @stxt.schema y @stxt.template; la plantilla es la forma de autoría corta y se compila a un esquema al cargarla. UnifiedSchemaProvider carga cualquiera de las dos con add_file(text): parsea, valida contra el meta-esquema que corresponda y registra el esquema por namespace. La validación es un Validator que se registra en el Parser y corre sobre cada nodo al cerrarlo; ConditionalValidator lo envuelve para que solo se validen los nodos con namespace, que es la regla del lenguaje: un documento sin namespace no está mal, solo no se puede validar.

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, ConditionalValidator,
    ValidationException,
)

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

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

Con un libro al que le falta el ISBN y cuya fecha no es YYYY-MM-DD, el bucle imprime exactamente lo que imprimiría stxt validate:

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

Reglas que conviene saber:

  • Un error de cardinalidad se señala en la línea del padre, que es quien tiene cero ISBN.
  • Si el documento usa un namespace que el proveedor no conoce, cada nodo produce un SCHEMA_NOT_FOUND; el proveedor nunca lanza por un namespace ausente (get_schema() devuelve None).
  • Los nodos sin namespace no se validan (por el ConditionalValidator); un documento que es a su vez una definición se valida siempre contra su meta-esquema.
  • add_file acepta ficheros con varias definiciones, y get_all_schemas() las enumera; clear() vacía el proveedor.
  • Los tipos de valor disponibles son los de STXT-SCHEMA-SPEC: INLINE, BLOCK, TEXT, MARKDOWN, BOOLEAN, INTEGER, NATURAL, NUMBER, DATE, TIME, TIMESTAMP, UUID, EMAIL, URL, HEXADECIMAL, BINARY, BASE64, GROUP y ENUM.

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: qué gramática aplica a un documento

UnifiedSchemaProvider espera que le des el texto de la gramática. La resolución responde a la pregunta anterior: dado este documento, ¿qué definiciones le aplican? DiscoveryResolver implementa STXT-DISCOVERY-SPEC —los .stxt/ del documento y de todos sus ancestros, después ~/.stxt y /etc/stxt, precedencia por namespace, STXT_PATH sustituye la cadena—, igual que la CLI y la extensión, así que las tres coinciden por construcción.

Como en el puerto TypeScript, el resolutor no toca por sí mismo ni el sistema de ficheros ni el entorno: recibe un DiscoveryFileSystem y un DiscoveryEnvironment. La diferencia es que el paquete ya trae los dos adaptadores de hostOsDiscoveryFileSystem sobre os y SystemDiscoveryEnvironment sobre 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. La cadena es por documento: se pasa el directorio en el que vive (None para la entrada estándar o un búfer sin guardar, que arranca la cadena en el nivel de usuario). Como DiscoveryResult implementa SchemaProvider, resolver y validar son dos pasos:

from stxt import Parser, SchemaValidator, ConditionalValidator
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, nunca se lanzan: se informan y se sigue
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 dice además de dónde sale cada gramática, que es lo que necesita un editor para "ir a la definición" o un diagnóstico que se explique solo:

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

Para resolver muchos documentos conviene un DiscoveryResolver propio, que cachea los niveles por directorio y lee cada .stxt/ una sola vez; clear_cache() cuando los ficheros de definición puedan haber cambiado:

from stxt import DiscoveryResolver, OsDiscoveryFileSystem, SystemDiscoveryEnvironment

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

Los códigos de error de la resolución son DISCOVERY_DUPLICATE_NAMESPACE, DISCOVERY_NOT_A_DEFINITION, DISCOVERY_NOT_PARSEABLE y DISCOVERY_INVALID_DEFINITION; DiscoveryError es una clase de datos (code, file, message, namespace), no una excepción, porque la spec quiere informar de una definición mala sin detener la carga del resto.

El árbol canónico en JSON

to_canonical_tree(nodes) transforma los nodos raíz en el valor JSON de STXT-TREE-SPEC —una lista de diccionarios, el mismo árbol que emite stxt describe— y to_canonical_json(nodes) lo serializa con dos espacios de sangría. Es una función explícita que emite solo los campos normativos, children en los inline y lines en los bloques, 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 hace el viaje inverso: serializa un nodo, o una lista de documentos, a texto STXT en forma canónica, con IndentStyle.TABS (por defecto) o IndentStyle.SPACES_4. Escribe el namespace donde el nodo lo declara, no donde difiere del padre: fiel al fuente. Como parte del árbol lógico, un texto que pase por parse y NodeWriter sale sin comentarios ni líneas en blanco fuera de los bloques —es lo que hace stxt format --clean—; el reformateo que los conserva es trabajo de la CLI y de la extensión, no de la biblioteca.

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.mail): Weekly report
	To:
		Address: [email protected]
	From: Ana García <[email protected]>
	Body >>
		Hi Bob,

		See attached.

Observar el parseo

Observer es una clase base con cuatro callbacks vacíos que se redefinen los que interesen, y que el parser llama en streaming: 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) y on_text_line(node, line_number, line_string, line_indent) por cada línea de un bloque. Validator es el otro gancho: corre sobre cada nodo al cerrarlo y devuelve una lista de ValidationException (no lanza); SchemaValidator no es más que un Validator integrado.

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)

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
Errores ParseException, ValidationException, RuntimeException
Puntos de extensión Observer, Validator
Esquemas Schema, NodeDefinition, ChildDefinition, SchemaProvider, SchemaProviderMemory, SchemaProviderMeta, SchemaValidator, transform_node_to_schema, SCHEMA_NAMESPACE
Plantillas MetaTemplateSchemaProvider, TemplateSchemaProviderMemory, transform_template_node_to_schema, TEMPLATE_NAMESPACE
Runtime UnifiedSchemaProvider, ConditionalValidator, NodeWriter, IndentStyle, to_canonical_tree, to_canonical_json
Resolución DiscoveryResolver, DiscoveryResult, DiscoveryDefinition, DiscoveryLevel, DiscoveryError, DiscoveryFileSystem, DiscoveryEntry, DiscoveryEnvironment, OsDiscoveryFileSystem, SystemDiscoveryEnvironment y stxt.discovery.resolve

Hasta la 1.0, una versión menor puede cambiar la API en memoria, pero nunca el lenguaje ni el árbol canónico: un documento que parsea y valida hoy parsea y valida igual mañana. Las novedades de cada versión están en el repositorio, que es también donde se reportan sus errores.