Skip to content

Repository files navigation

yamltrip

usethisPyPI VersionPyPI LicensePyPI Supported Versions

Edit YAML files from Python, while respecting format and comments during the round-trip.

Built on tree-sitter-yaml via the yamlpath and yamlpatch Rust crates.

Installation

# With uv
$ uv add yamltrip
# With pip
$ pip install yamltrip

Quick Start

importyamltrip# Load and readdoc=yamltrip.loads("name: Alice\nage: 30")
print(doc["name"]) # "Alice"print("name"indoc) # True# Immutable mutations: each call returns a new Documentdoc2=doc.replace("age", value=31)
doc3=doc2.add(key="city", value="Portland")
print(doc3.dumps())
# Complex values (dicts/lists) work toodoc4=doc3.replace("age", value={"years": 31, "months": 4})
doc5=doc4.upsert("hobbies", value=["reading", "hiking"])
# File-based editing with a context managerwithyamltrip.edit("config.yml") aseditor:
editor.replace("version", value="2.0")
editor.upsert("settings", "debug", value=True)
# writes back on successful exit; discards on exception

API Overview

Top-level function

FunctionDescription
yamltrip.loads(source)Parse a YAML string into a Document
yamltrip.load(path)Read a YAML file into a Document
yamltrip.edit(path)Open a YAML file for editing (context manager)

Document (immutable)

Every mutation method returns a newDocument. The original is never modified.

doc=yamltrip.loads("items:\n - a\n - b")
doc.root# {"items": ["a", "b"]}doc["items"] # ["a", "b"]doc["items", 0] # "a"
("items", 0) indoc# Truedoc.get("items", 0) # "a" (returns None if missing)doc.get("missing", default=42) # 42doc.replace("items", 0, value="x")
doc.replace("items", value=["x", "y"]) # dicts and lists accepteddoc.add("items", key="c", value=3)
doc.upsert("new", "nested", value=True)
doc.upsert("config", value={"debug": True}) # dicts and lists accepteddoc.remove("items", 0)
doc.prune_remove("a", "b", "c") # remove + prune empty parentsdoc.append("items", value="c")
doc.insert("items", index=1, value="between") # positional insertdoc.extend_list("items", values=["d", "e"])
doc.remove_from_list("items", values=["a"])
doc.ensure_in_list("items", value="c") # no-op if already presentdoc.ensure_in_list("repos", where={"name": "x"}, value={"name": "x", "version": "1.0"})
doc.sync("items", value=["a", "new", "b"]) # minimal diff-and-patchdoc.merge("config", value={"debug": True, "level": 2}) # recursive mapping mergedoc.find_index("repos", where={"id": "x"}) # find in list-of-dicts; returns int | Nonedoc.query("items") # Feature with location infodoc.query_pretty("items") # Feature with surrounding contextdoc.extract(feature) # raw YAML text for a Featuredoc.has_anchors() # True if anchors/aliases presentdoc.dumps() # full YAML sourcedoc.dump("output.yml") # write to file

Editor (mutable context manager)

Wraps Document with the same mutation methods, but applies changes in place and writes back to disk when the context exits cleanly:

withyamltrip.edit("config.yml") ased:
ed.replace("version", value="2.0")
ed.upsert("new_key", value="new_value")
ed.remove("old_key")
ed.sync("deps", value={"a": "1.0", "b": "2.0"}) # minimal patchinged.merge("settings", value={"debug": True, "level": 2}) # recursive mergeprint(ed["version"]) # "2.0"print(ed.get("missing")) # Noneprint(ed.original["version"]) # original value before edits

Error Hierarchy

All yamltrip errors inherit from YAMLTripError:

  • ParseError: YAML input cannot be parsed.
  • QueryError: path not found during lookup.
  • PatchError: mutation operation failed.
    • KeyExistsError: add() target already exists.
    • KeyMissingError: replace() target does not exist.
    • RoutingError: path passes through a non-mapping node (scalar or list).
    • NodeTypeError (PatchError + TypeError): node is the wrong type for the operation (e.g. appending to a scalar).

Limitations

  • Multi-document YAML streams (--- separated) are not supported.
  • YAML tags (!!omap, !!set, !!merge, custom tags) are not interpreted.
  • Anchors and aliases (&anchor / *alias) are detected (doc.has_anchors()) but not resolved during value extraction.
  • Large integers may lose precision. YAML integers outside the signed 64-bit range (i64) may become float during deserialization.
  • Editor write-back is not atomic.Editor detects external file changes between enter and exit, but the check-then-write is racy. Do not use it with concurrent writers.

Design Decisions

  • No custom Python class serialization. Values convert to/from str, int, float, bool, None, list, and dict only.
  • UTF-8 only. Other encodings raise ParseError.
  • Non-finite floats round-trip.float("inf"), float("-inf"), and float("nan") map to YAML's .inf, -.inf, and .nan.
  • Integer keys cannot create structures.upsert() with integer path components can update existing sequence entries but cannot create new intermediate mappings. Only string keys create new mappings.
  • No negative sequence indices for lookup. Python-style negative indexing is not supported for [] access or replace()/remove(). However, insert() accepts negative indices (matching list.insert() semantics).
  • Line endings preserved as-is. No CRLF/LF normalization. Mixed line endings pass through unchanged.

Acknowledgements

yamltrip depends entirely on the yamlpath and yamlpatch Rust crates for format-preserving YAML parsing and patching. These libraries use tree-sitter to query and modify YAML source text without discarding comments, whitespace, or key ordering. All of the core logic in yamltrip passes through them. Thanks to William Woodruff for creating and maintaining both crates as part of zizmor.

License

MIT

About

A round-tripping YAML library for Python

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages