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
(getCanonicalName → get_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()devuelveNone). - 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_fileacepta ficheros con varias definiciones, yget_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,GROUPyENUM.
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 host
—OsDiscoveryFileSystem 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.