Metadata-Version: 2.4
Name: stxt
Version: 0.14.1
Summary: STXT (Semantic Text) parser, schema/template validator and discovery resolver
Author: stxt-lang
License-Expression: MIT
Project-URL: Homepage, https://stxt.dev
Project-URL: Repository, https://github.com/stxt-lang/stxt-python
Project-URL: Specification, https://stxt.dev
Keywords: stxt,semantic text,parser,schema,markup,human-first
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: Text Processing :: Markup
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: test
Requires-Dist: pytest>=7; extra == "test"
Dynamic: license-file

# 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.

- Website and language reference: <https://stxt.dev>
- TypeScript implementation: [`@stxt-lang/core`](https://www.npmjs.com/package/@stxt-lang/core) on npm
- Java implementation: [`dev.stxt:stxt-core`](https://central.sonatype.com/artifact/dev.stxt/stxt-core) on Maven Central
- VS Code extension: [STXT - Semantic Text](https://marketplace.visualstudio.com/items?itemName=stxt-lang.stxt)

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

```stxt
# 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

```bash
pip install stxt
```

## Parsing

```python
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.

```python
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.

```python
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:

```python
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.

```python
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:

```python
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:

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

## Writing STXT back out, and the canonical tree

```python
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.

```python
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`:

- **Parsing** — `Parser`, `ParseResult`, `Node`, `InlineNode`, `TextNode`, `NO_LINE`,
  `LineIndent`, `parse_line`
- **Exceptions** — `ParseException`, `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 points** — `Observer`, `StreamObserver`, `Validator`
- **Schemas** — `Schema`, `SchemaValidator`, `SchemaProvider`, `SchemaProviderMemory`,
  `SchemaProviderMeta`, `NodeDefinition`, `ChildDefinition`, `transform_node_to_schema`
- **Templates** — `TemplateSchemaProviderMemory`, `MetaTemplateSchemaProvider`,
  `transform_template_node_to_schema`
- **Runtime** — `UnifiedSchemaProvider`, `NodeWriter`, `IndentStyle`, `Formatter`, `FormatResult`,
  `to_canonical_tree`, `to_canonical_json`
- **Discovery** — `DiscoveryResolver`, `DiscoveryResult`, `DiscoveryDefinition`,
  `DiscoveryLevel`, `DiscoveryError`, `DiscoveryFileSystem`, `DiscoveryEntry`,
  `DiscoveryEnvironment`, `OsDiscoveryFileSystem`, `SystemDiscoveryEnvironment` (and
  `stxt.discovery.resolve`)

## Development

```bash
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`](https://github.com/stxt-lang/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`](https://github.com/stxt-lang/stxt-lang/tree/master/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](LICENSE).
