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 y no incluye CLI; el comando del
ecosistema es stxt.
La guía tiene la misma estructura que las de TypeScript y Java, y la API es la
misma con nombres en snake_case (getCanonicalName → get_canonical_name): la
diferencia entre puertos se reduce a 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 adecuada para un editor o un validador; la segunda, para un programa que
trata un documento inválido como 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,
INDENTATION_MIXED, 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 continúa, de modo
que un editor pueda señalar todos los errores de una vez.
El árbol
Node es una clase abstracta con exactamente dos formas, y cada una expone solo
las operaciones propias de su forma:
| 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 modifica 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), y set_value y
add_text_line rechazan un salto de línea (LINE_BREAK_NOT_ALLOWED); 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 se ejecuta sobre cada nodo al cerrarlo; solo valida los nodos con
namespace, que es la regla del lenguaje (STXT-SCHEMA-SPEC §5): un documento sin
namespace no es inválido, 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 editorialesfrom 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 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 [TOO_FEW_CHILDREN]: 0 nodes of 'com.acme.book:isbn' and min is 1
Reglas de la validación:
- Un error de cardinalidad se señala en la línea del padre, que es el nodo con
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 (regla del propio
SchemaValidator); 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 recibe el texto de la gramática. La resolución
determina qué definiciones aplican a un documento dado. 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 incluye 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
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(SchemaValidator(discovery))
result = parser.parse_result(document_text)
DiscoveryResult registra además el origen de cada gramática, que es lo que necesita
un editor para «ir a la definición» o un diagnóstico que indique el fichero:
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 se puede mantener un DiscoveryResolver propio,
que cachea los niveles por directorio y lee cada .stxt/ una sola vez;
clear_cache() invalida la caché 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 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 especificación exige
informar de una definición inválida 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 realiza la operación inversa: serializa un nodo, o una lista de
documentos, a texto STXT 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, esté
donde esté en el 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—.
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 reformateo que conserva comentarios y líneas en blanco es Formatter
(STXT-TREE-SPEC §12): reescribe el texto original línea a línea —las
líneas que abren un nodo en forma canónica, las de un bloque al nivel del bloque, el
resto tal cual, con sus unidades de indentación convertidas al estilo pedido— y
devuelve, junto al texto, los errores de sintaxis que haya encontrado, para que quien
llama decida qué hacer con un documento que no parsea. Es el mismo formateador, con las
mismas reglas, que el de 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
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, de los que se redefinen
los necesarios, 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: se ejecuta sobre cada nodo al cerrarlo y devuelve una lista de
ValidationException (no lanza); SchemaValidator es 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, 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 |
Desde la 1.0 esta API queda congelada dentro de la línea 1.x del paquete, y el lenguaje y el árbol canónico lo están para siempre: STXT-SPEC y STXT-TREE-SPEC están en estado Zenith, y un documento que parsea hoy parsea igual mañana. Qué se congela exactamente, qué no, y por qué la versión del paquete no es la fecha de la especificación, en Estabilidad y versiones. Las novedades de cada versión están en el repositorio, que es también donde se reportan sus errores.