diff --git a/.importlinter b/.importlinter index 6ccde1e..8e01a2c 100644 --- a/.importlinter +++ b/.importlinter @@ -18,5 +18,3 @@ exhaustive_ignores = _display _types ignore_imports = - yamltrip.document -> yamltrip - yamltrip.sync -> yamltrip diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 45bc04e..7d177d0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -55,6 +55,20 @@ repos: entry: uv run --frozen cargo clippy --all-targets --no-default-features -- -D warnings language: system pass_filenames: false + - repo: local + hooks: + - id: basedpyright + name: basedpyright + always_run: true + entry: uv run --frozen --offline basedpyright + language: system + types: [python] + pass_filenames: false + require_serial: true + - repo: https://github.com/jendrikseipp/vulture + rev: v2.16 + hooks: + - id: vulture - repo: https://github.com/codespell-project/codespell rev: v2.4.2 hooks: diff --git a/doc/specs/2026-05-22-deep-merge-design.md b/doc/specs/2026-05-22-deep-merge-design.md new file mode 100644 index 0000000..c88ed0e --- /dev/null +++ b/doc/specs/2026-05-22-deep-merge-design.md @@ -0,0 +1,123 @@ +# Design: Deep Merge (`merge()`) + +**Date:** 2026-05-22 +**Status:** Approved + +## Summary + +Add a `merge()` method that recursively merges a value into an existing +mapping without removing keys not present in the target. Lists replace +entirely (list identity is ambiguous); only mapping keys get additive +treatment. + +## Semantics + +| Current type | Target type | Behavior | +|---|---|---| +| mapping | mapping | Recurse: update matching keys, add new keys, never remove existing keys | +| list | list | Replace the list entirely | +| scalar | mapping | Replace scalar with mapping (promote) | +| mapping | scalar | Replace mapping with scalar | +| any | any (equal) | No-op | +| missing | any | Create path + set value (delegate to `upsert()`) | + +Full-depth recursion always. No configurable depth parameter. + +## Public API + +```python +# Document (immutable, returns new Document) +doc = doc.merge(*keys, value={"debug": False, "timeout": 30}) + +# Editor (mutable context manager) +with edit("config.yaml") as ed: + ed.merge("settings", value={"debug": False, "timeout": 30}) +``` + +Signature: `merge(self, *keys: KeyPart, value: Any) -> Document` + +Matches `sync()` signature exactly. + +## Error behavior + +- `NodeTypeError` if path traverses through a scalar/list where a mapping + is expected +- `PatchError` on Rust-level failures (same fallback as `sync()`) +- No new error types + +## Scope + +- No new Rust code — reuses existing patch primitives +- No new error types +- `sync()` behavior unchanged +- Available on both `Document` and `Editor` + +## Examples + +```python +from yamltrip import loads + +doc = loads(""" +settings: + debug: true + log_level: info + custom_setting: 42 +""") + +# Merge ensures debug+timeout exist without removing log_level/custom_setting +doc = doc.merge("settings", value={"debug": False, "timeout": 30}) + +# Result: +# settings: +# debug: false +# log_level: info +# custom_setting: 42 +# timeout: 30 +``` + +```python +# Lists replace entirely +doc = loads(""" +plugins: + - eslint + - prettier +""") + +doc = doc.merge("plugins", value=["stylelint"]) + +# Result: +# plugins: +# - stylelint +``` + +```python +# Nested merge +doc = loads(""" +database: + host: localhost + credentials: + user: admin + password: secret +""") + +doc = doc.merge("database", value={"credentials": {"user": "deploy"}, "port": 5432}) + +# Result: +# database: +# host: localhost +# credentials: +# user: deploy +# password: secret +# port: 5432 +``` + +## Testing + +- Mapping merge: keeps extra keys, updates matching, adds new +- Nested mapping merge: recurses correctly +- List replacement: lists in target replace entirely +- Scalar-to-mapping promotion: works +- Missing path creation: delegates to upsert +- No-op when values equal: returns same Document instance +- Flow sequence handling: same fallback as sync +- Editor delegation: verify Editor.merge works diff --git a/pyproject.toml b/pyproject.toml index bec1cfc..31afca7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,10 +18,13 @@ classifiers = [ "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", ] -dependencies = [] +dependencies = [ + "typing-extensions>=4.4.0", +] [dependency-groups] dev = [ + "basedpyright>=1.39.6", "codespell>=2.4.2", "deptry>=0.25.1", "import-linter>=2.11", @@ -32,6 +35,7 @@ dev = [ "ruff>=0.15.12", "tomli>=2.4.1", "ty>=0.0.35", + "vulture>=2.16", ] test = [ "coverage[toml]>=7.14.0", @@ -75,3 +79,30 @@ report.exclude_also = [ "raise NotImplementedError", ] report.omit = [ "*/pytest-of-*/*" ] + +[tool.basedpyright] +reportAny = false +reportExplicitAny = false +reportUnknownArgumentType = false +reportUnknownVariableType = false + +[[tool.basedpyright.executionEnvironments]] +root = "tests" +reportMissingParameterType = false +reportPrivateUsage = false +reportUnannotatedClassAttribute = false +reportUnknownLambdaType = false +reportUnknownMemberType = false +reportUnknownParameterType = false +reportUnreachable = false +reportUnusedCallResult = false +reportUnusedExpression = false +reportUnusedFunction = false +reportUnusedParameter = false + +[tool.vulture] +paths = [ "src/yamltrip" ] +ignore_names = [ + "dump", # Public API method on Document + "original", # Public API property on Editor +] diff --git a/src/types.rs b/src/types.rs index 0cc74df..4fba6ab 100644 --- a/src/types.rs +++ b/src/types.rs @@ -76,22 +76,10 @@ pub enum PyComponent { #[pymethods] impl PyComponent { - #[staticmethod] - fn key(name: &str) -> Self { - Self::Key { - name: name.to_string(), - } - } - - #[staticmethod] - fn index(index: usize) -> Self { - Self::Index { index } - } - fn __repr__(&self) -> String { match self { - Self::Key { name } => format!("Component.key('{name}')"), - Self::Index { index } => format!("Component.index({index})"), + Self::Key { name } => format!("Component.Key('{name}')"), + Self::Index { index } => format!("Component.Index({index})"), } } } diff --git a/src/yamltrip/_core.pyi b/src/yamltrip/_core.pyi index 7975792..176915b 100644 --- a/src/yamltrip/_core.pyi +++ b/src/yamltrip/_core.pyi @@ -2,6 +2,8 @@ from typing import Any, ClassVar, final +from typing_extensions import override + __all__ = [ "Component", "Document", @@ -22,8 +24,11 @@ class Location: def start(self) -> int: ... @property def end(self) -> int: ... + @override def __eq__(self, value: object, /) -> bool: ... + @override def __hash__(self) -> int: ... + @override def __repr__(self) -> str: ... @final @@ -33,9 +38,12 @@ class FeatureKind: FlowMapping: ClassVar[FeatureKind] BlockSequence: ClassVar[FeatureKind] FlowSequence: ClassVar[FeatureKind] + @override def __eq__(self, value: object, /) -> bool: ... + @override def __hash__(self) -> int: ... def __int__(self) -> int: ... + @override def __repr__(self) -> str: ... class Component: @@ -51,22 +59,24 @@ class Component: __match_args__: ClassVar[tuple[str, ...]] def __new__(cls, index: int) -> Component.Index: ... @property - def index(self) -> int: ... # type: ignore[override] + def index(self) -> int: ... - @staticmethod - def key(name: str) -> Component: ... - @staticmethod - def index(index: int) -> Component: ... + @override def __eq__(self, value: object, /) -> bool: ... + @override def __hash__(self) -> int: ... + @override def __repr__(self) -> str: ... @final class Route: def __new__(cls, parts: list[str | int]) -> Route: ... def __len__(self) -> int: ... + @override def __eq__(self, value: object, /) -> bool: ... + @override def __hash__(self) -> int: ... + @override def __repr__(self) -> str: ... @final @@ -79,8 +89,11 @@ class Feature: def kind(self) -> FeatureKind: ... @property def is_multiline(self) -> bool: ... + @override def __eq__(self, value: object, /) -> bool: ... + @override def __hash__(self) -> int: ... + @override def __repr__(self) -> str: ... @final @@ -111,6 +124,7 @@ class Op: def insert_at(index: int, value: Any) -> Op: ... @property def kind(self) -> str: ... + @override def __repr__(self) -> str: ... @final diff --git a/src/yamltrip/document.py b/src/yamltrip/document.py index ee3113d..c8fc2d5 100644 --- a/src/yamltrip/document.py +++ b/src/yamltrip/document.py @@ -5,9 +5,20 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, cast -from yamltrip import _core -from yamltrip._display import format_path -from yamltrip.errors import ( +from typing_extensions import override + +from ._core import ( + Document as CoreDocument, +) +from ._core import ( + FeatureKind, + Op, + Patch, + Route, + serialize_value, +) +from ._display import format_path +from .errors import ( KeyExistsError, KeyMissingError, NodeTypeError, @@ -19,10 +30,11 @@ if TYPE_CHECKING: from collections.abc import Sequence - from yamltrip._types import KeyPart + from ._core import Feature + from ._types import KeyPart -def _normalize_keys(keys: object) -> tuple[KeyPart, ...]: +def normalize_keys(keys: object) -> tuple[KeyPart, ...]: """Normalize __getitem__ input to a tuple of keys.""" if isinstance(keys, (str, int)): return (keys,) @@ -36,13 +48,16 @@ def _normalize_keys(keys: object) -> tuple[KeyPart, ...]: raise TypeError(msg) -def _make_route(keys: Sequence[KeyPart]) -> _core.Route: - """Build a _core.Route from a sequence of keys.""" - return _core.Route(list(keys)) +def _make_route(keys: Sequence[KeyPart]) -> Route: + """Build a Route from a sequence of keys.""" + return Route(list(keys)) + +def _ensure_str_keys(keys: tuple[KeyPart, ...]) -> tuple[str, ...]: + """Validate all keys are strings for creation operations. -def _check_no_int_keys_for_creation(keys: Sequence[KeyPart]) -> None: - """Raise PatchError if any key is an int (cannot create sequences via upsert).""" + Raises PatchError if any key is an int (cannot create sequences via upsert). + """ for k in keys: if isinstance(k, int): msg = ( @@ -50,26 +65,25 @@ def _check_no_int_keys_for_creation(keys: Sequence[KeyPart]) -> None: "only string keys can create new mappings" ) raise PatchError(msg) + return cast("tuple[str, ...]", keys) def _flow_seq_replacements( - core_doc: _core.Document, + core_doc: CoreDocument, old_value: Any, new_value: Any, path: tuple[KeyPart, ...], -) -> list[_core.Patch]: +) -> list[Patch]: """Find flow sequences that need modification and emit targeted replace patches.""" - patches: list[_core.Patch] = [] + patches: list[Patch] = [] if isinstance(old_value, list) and isinstance(new_value, list): if old_value != new_value: route = _make_route(path) try: feature = core_doc.query_exact(route) - if feature and feature.kind == _core.FeatureKind.FlowSequence: - patches.append( - _core.Patch(route=route, operation=_core.Op.replace(new_value)) - ) + if feature and feature.kind == FeatureKind.FlowSequence: + patches.append(Patch(route=route, operation=Op.replace(new_value))) return patches except (KeyError, ValueError): pass @@ -109,20 +123,20 @@ class Document: def __init__(self, source: str) -> None: """Parse a YAML string into an immutable document.""" try: - self._core_doc = _core.Document(source) + self._core_doc: CoreDocument = CoreDocument(source) except (ValueError, RuntimeError) as e: raise ParseError(str(e)) from None - self._source = source + self._source: str = source @classmethod - def _from_core(cls, core_doc: _core.Document) -> Document: - """Construct a Document from an already-parsed _core.Document.""" + def _from_core(cls, core_doc: CoreDocument) -> Document: + """Construct a Document from an already-parsed CoreDocument.""" obj = object.__new__(cls) obj._core_doc = core_doc obj._source = core_doc.source() return obj - def _apply_patches(self, patches: list[_core.Patch]) -> Document: + def _apply_patches(self, patches: list[Patch]) -> Document: """Apply patches to this document and return a new Document.""" try: core_doc = self._core_doc.apply_patches(patches) @@ -142,7 +156,7 @@ def root(self) -> Any: def get(self, *keys: KeyPart, default: Any = None) -> Any: """Return the parsed value at path, or default if the path doesn't exist.""" - normalized = _normalize_keys(keys) + normalized = normalize_keys(keys) route = _make_route(normalized) try: return self._core_doc.parse_value(route) @@ -151,16 +165,19 @@ def get(self, *keys: KeyPart, default: Any = None) -> Any: except ValueError as e: raise QueryError(str(e)) from None + @override def __eq__(self, other: object) -> bool: """Compare documents by their source text.""" if not isinstance(other, Document): return NotImplemented return self._source == other._source + @override def __hash__(self) -> int: """Hash based on source text.""" return hash(self._source) + @override def __repr__(self) -> str: """Return a developer-friendly representation.""" return f"Document(<{len(self._source)} bytes>)" @@ -170,7 +187,7 @@ def __getitem__(self, keys: object) -> Any: An empty tuple ``()`` retrieves the entire document as a Python object. """ - normalized = _normalize_keys(keys) + normalized = normalize_keys(keys) route = _make_route(normalized) try: return self._core_doc.parse_value(route) @@ -183,11 +200,11 @@ def __contains__(self, keys: object) -> bool: An empty tuple ``()`` checks that the document has a root data node. Returns False for empty or comment-only documents. """ - normalized = _normalize_keys(keys) + normalized = normalize_keys(keys) route = _make_route(normalized) return self._core_doc.query_exists(route) - def query(self, *keys: KeyPart) -> _core.Feature: + def query(self, *keys: KeyPart) -> Feature: """Return the Feature at the given path.""" route = _make_route(keys) try: @@ -199,7 +216,7 @@ def query(self, *keys: KeyPart) -> _core.Feature: raise QueryError(msg) return feature - def query_pretty(self, *keys: KeyPart) -> _core.Feature: + def query_pretty(self, *keys: KeyPart) -> Feature: """Return a Feature with context (surrounding structure) at the path.""" route = _make_route(keys) try: @@ -211,7 +228,7 @@ def has_anchors(self) -> bool: """Check whether the document contains YAML anchors (&anchor/*alias).""" return self._core_doc.has_anchors() - def extract(self, feature: _core.Feature) -> str: + def extract(self, feature: Feature) -> str: """Extract the raw YAML text for a feature.""" return self._core_doc.extract(feature) @@ -221,7 +238,7 @@ def dumps(self) -> str: def dump(self, path: str | Path) -> None: """Write the YAML source text to a file.""" - Path(path).write_text(self._source, encoding="utf-8") + _ = Path(path).write_text(self._source, encoding="utf-8") def replace(self, *keys: KeyPart, value: Any) -> Document: """Replace value at an existing path. Raises KeyMissingError if missing.""" @@ -230,8 +247,8 @@ def replace(self, *keys: KeyPart, value: Any) -> Document: msg = f"Path not found: {keys}" raise KeyMissingError(msg) - op = _core.Op.replace(value) - patch = _core.Patch(route=route, operation=op) + op = Op.replace(value) + patch = Patch(route=route, operation=op) return self._apply_patches([patch]) def add(self, *keys: KeyPart, key: str, value: Any) -> Document: @@ -243,11 +260,11 @@ def add(self, *keys: KeyPart, key: str, value: Any) -> Document: raise KeyExistsError(msg) if self._is_empty_document(): - return self._create_at((), full_path, value) + return self._create_at((), _ensure_str_keys(full_path), value) route = _make_route(keys) - op = _core.Op.add(key, value) - patch = _core.Patch(route=route, operation=op) + op = Op.add(key, value) + patch = Patch(route=route, operation=op) return self._apply_patches([patch]) def _is_empty_document(self) -> bool: @@ -257,32 +274,24 @@ def _is_empty_document(self) -> bool: def _create_at( self, parent_keys: tuple[KeyPart, ...], - child_keys: tuple[KeyPart, ...], + child_keys: tuple[str, ...], value: Any, ) -> Document: """Create a nested value under parent_keys using child_keys.""" - _check_no_int_keys_for_creation(child_keys) - # Bootstrap root mapping if document has no root data node if not parent_keys and self._is_empty_document(): first_key = child_keys[0] - if not isinstance(first_key, str): - msg = f"Expected string key, got {type(first_key).__name__}" - raise TypeError(msg) nested_value = value for k in reversed(child_keys[1:]): nested_value = {k: nested_value} full_dict = {first_key: nested_value} - yaml_text = _core.serialize_value(full_dict) + yaml_text = serialize_value(full_dict) prefix = self._source if prefix and not prefix.endswith("\n"): prefix += "\n" return Document(prefix + yaml_text) first_key = child_keys[0] - if not isinstance(first_key, str): - msg = f"Expected string key, got {type(first_key).__name__}" - raise TypeError(msg) nested_value = value for k in reversed(child_keys[1:]): nested_value = {k: nested_value} @@ -293,17 +302,17 @@ def _create_at( # Op.merge_into is scoped to flat mappings (uniform indent); for # nested values, add a placeholder then replace via complex-replace # which preserves relative indentation. - add_op = _core.Op.add(first_key, None) - add_patch = _core.Patch(route=route, operation=add_op) + add_op = Op.add(first_key, None) + add_patch = Patch(route=route, operation=add_op) replace_route = _make_route((*parent_keys, first_key)) - replace_op = _core.Op.replace(nested_value) - replace_patch = _core.Patch(route=replace_route, operation=replace_op) + replace_op = Op.replace(nested_value) + replace_patch = Patch(route=replace_route, operation=replace_op) return self._apply_patches([add_patch, replace_patch]) elif isinstance(nested_value, dict): - op = _core.Op.merge_into(first_key, nested_value) + op = Op.merge_into(first_key, nested_value) else: - op = _core.Op.add(first_key, nested_value) - patch = _core.Patch(route=route, operation=op) + op = Op.add(first_key, nested_value) + patch = Patch(route=route, operation=op) return self._apply_patches([patch]) def upsert(self, *keys: KeyPart, value: Any) -> Document: @@ -315,8 +324,8 @@ def upsert(self, *keys: KeyPart, value: Any) -> Document: ) raise PatchError(msg) route = _make_route(()) - op = _core.Op.replace(value) - patch = _core.Patch(route=route, operation=op) + op = Op.replace(value) + patch = Patch(route=route, operation=op) return self._apply_patches([patch]) full_route = _make_route(keys) @@ -328,16 +337,18 @@ def upsert(self, *keys: KeyPart, value: Any) -> Document: ancestor_keys = keys[:depth] ancestor_route = _make_route(ancestor_keys) if self._core_doc.query_exists(ancestor_route): - return self._create_at(ancestor_keys, keys[depth:], value) + return self._create_at( + ancestor_keys, _ensure_str_keys(keys[depth:]), value + ) # No path exists — add at root - return self._create_at((), keys, value) + return self._create_at((), _ensure_str_keys(keys), value) def remove(self, *keys: KeyPart, prune: bool = False) -> Document: """Remove the key/index at path.""" route = _make_route(keys) - op = _core.Op.remove() - patch = _core.Patch(route=route, operation=op) + op = Op.remove() + patch = Patch(route=route, operation=op) doc = self._apply_patches([patch]) if prune and len(keys) > 1: @@ -360,8 +371,8 @@ def prune_remove(self, *keys: KeyPart) -> Document: def append(self, *keys: KeyPart, value: Any) -> Document: """Append a single item to the sequence at path.""" route = _make_route(keys) - op = _core.Op.append(value) - patch = _core.Patch(route=route, operation=op) + op = Op.append(value) + patch = Patch(route=route, operation=op) try: return self._apply_patches([patch]) except PatchError as e: @@ -370,10 +381,8 @@ def append(self, *keys: KeyPart, value: Any) -> Document: if "flow sequence" in msg: current = self[keys] new_list = [*list(current), value] - replace_op = _core.Op.replace(new_list) - return self._apply_patches( - [_core.Patch(route=route, operation=replace_op)] - ) + replace_op = Op.replace(new_list) + return self._apply_patches([Patch(route=route, operation=replace_op)]) if "only permitted against sequence" in msg: raise NodeTypeError(msg) from None raise @@ -385,8 +394,8 @@ def insert(self, *keys: KeyPart, index: int, value: Any) -> Document: negative indices count from the end, out-of-range indices clamp. """ route = _make_route(keys) - op = _core.Op.insert_at(index=index, value=value) - patch = _core.Patch(route=route, operation=op) + op = Op.insert_at(index=index, value=value) + patch = Patch(route=route, operation=op) try: return self._apply_patches([patch]) except PatchError as e: @@ -398,19 +407,17 @@ def insert(self, *keys: KeyPart, index: int, value: Any) -> Document: current = self[keys] if not isinstance(current, list): raise NodeTypeError(msg) from None - new_list = list(current) + new_list: list[Any] = list(current) new_list.insert(index, value) - replace_op = _core.Op.replace(new_list) - return self._apply_patches([_core.Patch(route=route, operation=replace_op)]) + replace_op = Op.replace(new_list) + return self._apply_patches([Patch(route=route, operation=replace_op)]) def extend_list(self, *keys: KeyPart, values: Sequence[Any]) -> Document: """Append multiple items to the sequence at path.""" if not values: return self route = _make_route(keys) - patches = [ - _core.Patch(route=route, operation=_core.Op.append(v)) for v in values - ] + patches = [Patch(route=route, operation=Op.append(v)) for v in values] try: return self._apply_patches(patches) except PatchError as e: @@ -419,10 +426,8 @@ def extend_list(self, *keys: KeyPart, values: Sequence[Any]) -> Document: if "flow sequence" in msg: current = self[keys] new_list = [*list(current), *values] - replace_op = _core.Op.replace(new_list) - return self._apply_patches( - [_core.Patch(route=route, operation=replace_op)] - ) + replace_op = Op.replace(new_list) + return self._apply_patches([Patch(route=route, operation=replace_op)]) if "only permitted against sequence" in msg: raise NodeTypeError(msg) from None raise @@ -443,9 +448,9 @@ def remove_from_list(self, *keys: KeyPart, values: Sequence[Any]) -> Document: if not indices_to_remove: return self patches = [ - _core.Patch( + Patch( route=_make_route((*keys, idx)), - operation=_core.Op.remove(), + operation=Op.remove(), ) for idx in indices_to_remove ] @@ -504,9 +509,9 @@ def sync(self, *keys: KeyPart, value: Any) -> Document: Diffs the current value against the desired value and applies the minimal set of patches. Returns self if no changes needed. """ - from yamltrip.sync import _compute_patches # noqa: PLC0415 + from yamltrip.sync import compute_patches # noqa: PLC0415 - normalized = _normalize_keys(keys) if keys else () + normalized = normalize_keys(keys) if keys else () # If path doesn't exist, delegate to upsert. # Root (empty keys) always exists, so skip the check. @@ -532,7 +537,7 @@ def sync(self, *keys: KeyPart, value: Any) -> Document: # Re-read old_value from the now-converted document old_value = doc._core_doc.parse_value(_make_route(normalized)) - patches = _compute_patches(old_value, value, normalized) + patches = compute_patches(old_value, value, normalized) if not patches: return doc try: @@ -543,8 +548,57 @@ def sync(self, *keys: KeyPart, value: Any) -> Document: # Fallback: a flow sequence was missed by pre-detection (e.g. due to # list reordering). Replace the entire synced value. route = _make_route(normalized) - op = _core.Op.replace(value) - return self._apply_patches([_core.Patch(route=route, operation=op)]) + op = Op.replace(value) + return self._apply_patches([Patch(route=route, operation=op)]) + + def merge(self, *keys: KeyPart, value: Any) -> Document: + """Merge or replace the value at path, depending on node type. + + If both the existing value and *value* are mappings, matching keys + are updated recursively and new keys are added, but existing keys not + present in *value* are preserved. Lists are replaced entirely (not + merged element-wise), and scalars are replaced. If the path does not + exist, it is created. If the existing node type differs from *value*, + the value at path is replaced. + """ + from yamltrip.sync import DiffMode, compute_patches # noqa: PLC0415 + + normalized = normalize_keys(keys) if keys else () + + # If a non-root path doesn't exist, delegate to upsert. + # For the root path, defer missing/empty-document handling to the + # parse_value(...) fallback below. + if normalized: + route = _make_route(normalized) + if not self._core_doc.query_exists(route): + try: + return self.upsert(*normalized, value=value) + except PatchError as e: + if "unexpected node" in str(e): + # Find deepest existing ancestor to report + failing = normalized + for i in range(len(normalized), 0, -1): + sub = normalized[:i] + if self._core_doc.query_exists(_make_route(sub)): + failing = sub + break + msg = f"Value at {format_path(failing)} is not a mapping" + raise NodeTypeError(msg) from None + raise + + # Get current value and diff + try: + old_value = self._core_doc.parse_value(_make_route(normalized)) + except (ValueError, KeyError): + return self.upsert(*normalized, value=value) + + if old_value == value: + return self + + patches = compute_patches(old_value, value, normalized, mode=DiffMode.MERGE) + if not patches: + return self + return self._apply_patches(patches) def find_index(self, *keys: KeyPart, where: dict[str, Any]) -> int | None: """Return the index of the first list item matching all key/value pairs. diff --git a/src/yamltrip/editor.py b/src/yamltrip/editor.py index a723726..539f3d5 100644 --- a/src/yamltrip/editor.py +++ b/src/yamltrip/editor.py @@ -5,14 +5,16 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -from yamltrip.document import Document, _normalize_keys +from typing_extensions import override + +from .document import Document, normalize_keys if TYPE_CHECKING: from collections.abc import Sequence from types import TracebackType - from yamltrip._core import Feature - from yamltrip._types import KeyPart + from ._core import Feature + from ._types import KeyPart class Editor: @@ -24,11 +26,12 @@ class Editor: def __init__(self, path: str | Path) -> None: """Create an editor for the given YAML file path.""" - self._path = Path(path) + self._path: Path = Path(path) self._original: Document | None = None self._document: Document | None = None self._original_source: str | None = None + @override def __repr__(self) -> str: """Return a developer-friendly representation.""" return f"Editor('{self._path}')" @@ -57,12 +60,13 @@ def __exit__( made between ``__enter__`` and ``__exit__`` but is not atomic and cannot guard against concurrent writes during the write itself. """ + del exc_val, exc_tb if exc_type is None and self._document is not None: current_source = self._path.read_text(encoding="utf-8") if current_source != self._original_source: msg = f"File was modified externally: {self._path}" raise RuntimeError(msg) - self._path.write_text(self._document.dumps(), encoding="utf-8") + _ = self._path.write_text(self._document.dumps(), encoding="utf-8") self._original = None self._document = None self._original_source = None @@ -102,7 +106,7 @@ def __contains__(self, keys: object) -> bool: def __setitem__(self, keys: object, value: Any) -> None: """Upsert a value at the given path.""" - normalized = _normalize_keys(keys) + normalized = normalize_keys(keys) self._document = self.document.upsert(*normalized, value=value) def replace(self, *keys: KeyPart, value: Any) -> None: @@ -151,6 +155,10 @@ def sync(self, *keys: KeyPart, value: Any) -> None: """Sync the value at path to match the desired value.""" self._document = self.document.sync(*keys, value=value) + def merge(self, *keys: KeyPart, value: Any) -> None: + """Merge or replace the value at path, depending on node type.""" + self._document = self.document.merge(*keys, value=value) + def find_index(self, *keys: KeyPart, where: dict[str, Any]) -> int | None: """Return the index of the first list item matching all key/value pairs.""" return self.document.find_index(*keys, where=where) diff --git a/src/yamltrip/sync.py b/src/yamltrip/sync.py index def0f35..1e03724 100644 --- a/src/yamltrip/sync.py +++ b/src/yamltrip/sync.py @@ -3,19 +3,32 @@ from __future__ import annotations from difflib import SequenceMatcher +from enum import Enum from typing import TYPE_CHECKING, Any -from yamltrip import _core +from ._core import Op, Patch, Route if TYPE_CHECKING: from yamltrip._types import KeyPart -def _compute_patches( +class DiffMode(Enum): + """Controls how compute_patches diffs values.""" + + SYNC = "sync" + """Exact sync: remove extra keys, diff lists element-wise.""" + + MERGE = "merge" + """Additive merge: keep extra keys, replace lists entirely.""" + + +def compute_patches( old_value: Any, new_value: Any, path: tuple[KeyPart, ...], -) -> list[_core.Patch]: + *, + mode: DiffMode = DiffMode.SYNC, +) -> list[Patch]: """Compute minimal patches to transform old_value into new_value at path.""" if old_value == new_value: return [] @@ -24,45 +37,51 @@ def _compute_patches( new_is_dict = isinstance(new_value, dict) if old_is_dict and new_is_dict: - return _diff_mappings(old_value, new_value, path) + return _diff_mappings(old_value, new_value, path, mode=mode) old_is_list = isinstance(old_value, list) new_is_list = isinstance(new_value, list) if old_is_list and new_is_list: + if mode is DiffMode.MERGE: + route = Route(list(path)) + return [Patch(route=route, operation=Op.replace(new_value))] return _diff_lists(old_value, new_value, path) # Type mismatch or scalar change — replace - route = _core.Route(list(path)) - op = _core.Op.replace(new_value) - return [_core.Patch(route=route, operation=op)] + route = Route(list(path)) + op = Op.replace(new_value) + return [Patch(route=route, operation=op)] def _diff_mappings( old: dict[str, Any], new: dict[str, Any], path: tuple[KeyPart, ...], -) -> list[_core.Patch]: + *, + mode: DiffMode = DiffMode.SYNC, +) -> list[Patch]: """Diff two mappings and return patches.""" - patches: list[_core.Patch] = [] + patches: list[Patch] = [] # Keys in new that exist in old — recurse for key in new: if key in old: - child_patches = _compute_patches(old[key], new[key], (*path, key)) + child_patches = compute_patches(old[key], new[key], (*path, key), mode=mode) patches.extend(child_patches) else: # New key — add - route = _core.Route(list(path)) - op = _core.Op.add(key, new[key]) - patches.append(_core.Patch(route=route, operation=op)) + route = Route(list(path)) + op = Op.add(key, new[key]) + patches.append(Patch(route=route, operation=op)) # Keys in old not in new — remove - for key in old: - if key not in new: - route = _core.Route([*path, key]) - op = _core.Op.remove() - patches.append(_core.Patch(route=route, operation=op)) + if mode is DiffMode.SYNC: + for key in old: + if key not in new: + route = Route([*path, key]) + op = Op.remove() + patches.append(Patch(route=route, operation=op)) return patches @@ -71,22 +90,22 @@ def _diff_lists( old: list[Any], new: list[Any], path: tuple[KeyPart, ...], -) -> list[_core.Patch]: +) -> list[Patch]: """Diff two lists using SequenceMatcher and return patches.""" if not old and not new: return [] # Replacing with empty list: use a single replace instead of removing items if not new: - route = _core.Route(list(path)) - op = _core.Op.replace([]) - return [_core.Patch(route=route, operation=op)] + route = Route(list(path)) + op = Op.replace([]) + return [Patch(route=route, operation=op)] # Map items to integers for SequenceMatcher (handles unhashable items) int_old, int_new = _shared_int_sequences(old, new) sm = SequenceMatcher(None, int_old, int_new, autojunk=False) - patches: list[_core.Patch] = [] + patches: list[Patch] = [] # Track offset: as inserts/deletes happen, indices in the original shift offset = 0 @@ -104,17 +123,17 @@ def _diff_lists( # shifts subsequent positions). This matches _core.apply_patches. for k in range(j1, j2): insert_idx = i1 + offset - route = _core.Route(list(path)) - insert_op = _core.Op.insert_at(index=insert_idx, value=new[k]) - patches.append(_core.Patch(route=route, operation=insert_op)) + route = Route(list(path)) + insert_op = Op.insert_at(index=insert_idx, value=new[k]) + patches.append(Patch(route=route, operation=insert_op)) offset += 1 elif tag == "delete": # Remove from highest index to lowest within this block for k in reversed(range(i1, i2)): idx = k + offset - route = _core.Route([*path, idx]) - remove_op = _core.Op.remove() - patches.append(_core.Patch(route=route, operation=remove_op)) + route = Route([*path, idx]) + remove_op = Op.remove() + patches.append(Patch(route=route, operation=remove_op)) offset -= i2 - i1 return patches @@ -126,36 +145,36 @@ def _apply_replace( path: tuple[KeyPart, ...], opcode: tuple[int, int, int, int], offset: int, -) -> tuple[list[_core.Patch], int]: +) -> tuple[list[Patch], int]: """Handle a 'replace' opcode block, returning patches and updated offset.""" i1, i2, j1, j2 = opcode replace_count = min(i2 - i1, j2 - j1) - patches: list[_core.Patch] = [] + patches: list[Patch] = [] for k in range(replace_count): old_idx = i1 + k + offset child_path = (*path, old_idx) if isinstance(old[i1 + k], dict) and isinstance(new[j1 + k], dict): - child_patches = _compute_patches(old[i1 + k], new[j1 + k], child_path) + child_patches = compute_patches(old[i1 + k], new[j1 + k], child_path) patches.extend(child_patches) else: - route = _core.Route(list(child_path)) - replace_op = _core.Op.replace(new[j1 + k]) - patches.append(_core.Patch(route=route, operation=replace_op)) + route = Route(list(child_path)) + replace_op = Op.replace(new[j1 + k]) + patches.append(Patch(route=route, operation=replace_op)) # More new items than old — insert the extras for k in range(replace_count, j2 - j1): insert_idx = i1 + replace_count + offset - route = _core.Route(list(path)) - insert_op = _core.Op.insert_at(index=insert_idx, value=new[j1 + k]) - patches.append(_core.Patch(route=route, operation=insert_op)) + route = Route(list(path)) + insert_op = Op.insert_at(index=insert_idx, value=new[j1 + k]) + patches.append(Patch(route=route, operation=insert_op)) offset += 1 # More old items than new — remove the extras (reverse order) remove_indices = [i1 + k + offset for k in range(replace_count, i2 - i1)] for idx in reversed(remove_indices): - route = _core.Route([*path, idx]) - remove_op = _core.Op.remove() - patches.append(_core.Patch(route=route, operation=remove_op)) + route = Route([*path, idx]) + remove_op = Op.remove() + patches.append(Patch(route=route, operation=remove_op)) offset -= 1 return patches, offset diff --git a/tests/test_compute_patches.py b/tests/test_compute_patches.py new file mode 100644 index 0000000..e56c56f --- /dev/null +++ b/tests/test_compute_patches.py @@ -0,0 +1,50 @@ +"""Tests for compute_patches.""" + +import yamltrip +from yamltrip.sync import DiffMode, compute_patches + + +class TestComputePatchesSync: + def test_no_change_returns_empty(self) -> None: + assert compute_patches({"a": 1}, {"a": 1}, ()) == [] + + def test_scalar_change(self) -> None: + patches = compute_patches({"a": 1}, {"a": 2}, ()) + assert len(patches) == 1 + doc = yamltrip.loads("a: 1\n") + result = doc.sync("a", value=2) + assert result["a"] == 2 + + def test_add_key(self) -> None: + patches = compute_patches({"a": 1}, {"a": 1, "b": 2}, ()) + assert len(patches) == 1 + + def test_remove_key(self) -> None: + patches = compute_patches({"a": 1, "b": 2}, {"a": 1}, ()) + assert len(patches) == 1 + + def test_nested_change(self) -> None: + patches = compute_patches({"a": {"b": 1}}, {"a": {"b": 2}}, ()) + assert len(patches) == 1 + + def test_list_element_change(self) -> None: + patches = compute_patches([1, 2, 3], [1, 4, 3], ("items",)) + assert len(patches) == 1 + + def test_type_mismatch_replaces(self) -> None: + patches = compute_patches({"a": 1}, {"a": [1, 2]}, ()) + assert len(patches) == 1 + + +class TestComputePatchesMerge: + def test_no_change_returns_empty(self) -> None: + assert compute_patches({"a": 1}, {"a": 1}, (), mode=DiffMode.MERGE) == [] + + def test_keeps_extra_keys(self) -> None: + patches = compute_patches({"a": 1, "b": 2}, {"a": 3}, (), mode=DiffMode.MERGE) + # Only patch for changing "a", "b" is kept + assert len(patches) == 1 + + def test_list_replaced_entirely(self) -> None: + patches = compute_patches([1, 2, 3], [4, 5], ("items",), mode=DiffMode.MERGE) + assert len(patches) == 1 diff --git a/tests/test_core_types.py b/tests/test_core_types.py index 6a4c819..5a735fb 100644 --- a/tests/test_core_types.py +++ b/tests/test_core_types.py @@ -37,21 +37,21 @@ def test_hash(self): class TestComponent: def test_key(self): - c = Component.key("name") - assert repr(c) == "Component.key('name')" + c = Component.Key("name") + assert repr(c) == "Component.Key('name')" def test_index(self): - c = Component.index(0) - assert repr(c) == "Component.index(0)" + c = Component.Index(0) + assert repr(c) == "Component.Index(0)" def test_eq(self): - assert Component.key("a") == Component.key("a") - assert Component.key("a") != Component.key("b") - assert Component.index(0) == Component.index(0) - assert Component.key("0") != Component.index(0) + assert Component.Key("a") == Component.Key("a") + assert Component.Key("a") != Component.Key("b") + assert Component.Index(0) == Component.Index(0) + assert Component.Key("0") != Component.Index(0) def test_hash(self): - s = {Component.key("a"), Component.key("a"), Component.key("b")} + s = {Component.Key("a"), Component.Key("a"), Component.Key("b")} assert len(s) == 2 diff --git a/tests/test_merge.py b/tests/test_merge.py new file mode 100644 index 0000000..9e27d2f --- /dev/null +++ b/tests/test_merge.py @@ -0,0 +1,114 @@ +import pytest + +from yamltrip import edit +from yamltrip.document import Document +from yamltrip.errors import NodeTypeError + + +class TestMergeMapping: + def test_keeps_extra_keys(self): + doc = Document("a: 1\nb: 2\nc: 3\n") + doc2 = doc.merge(value={"a": 10, "d": 4}) + assert doc2["a"] == 10 + assert doc2["b"] == 2 + assert doc2["c"] == 3 + assert doc2["d"] == 4 + + def test_nested_merge(self): + doc = Document("db:\n host: localhost\n port: 5432\n user: admin\n") + doc2 = doc.merge("db", value={"port": 3306, "ssl": True}) + assert doc2["db", "host"] == "localhost" + assert doc2["db", "port"] == 3306 + assert doc2["db", "user"] == "admin" + assert doc2["db", "ssl"] is True + + def test_deeply_nested_merge(self): + doc = Document("a:\n b:\n c: 1\n d: 2\n e: 3\n") + doc2 = doc.merge("a", value={"b": {"c": 99}}) + assert doc2["a", "b", "c"] == 99 + assert doc2["a", "b", "d"] == 2 + assert doc2["a", "e"] == 3 + + +class TestMergeListReplacement: + def test_list_replaced_entirely(self): + doc = Document("items:\n - a\n - b\n - c\n") + doc2 = doc.merge("items", value=["x"]) + assert doc2["items"] == ["x"] + + def test_nested_list_replaced(self): + doc = Document("cfg:\n tags:\n - alpha\n - beta\n name: foo\n") + doc2 = doc.merge("cfg", value={"tags": ["gamma"]}) + assert doc2["cfg", "tags"] == ["gamma"] + assert doc2["cfg", "name"] == "foo" + + +class TestMergeFlowSequence: + def test_flow_sequence_replaced(self): + doc = Document("cfg:\n tags: [alpha, beta]\n name: foo\n") + doc2 = doc.merge("cfg", value={"tags": ["gamma"], "extra": 1}) + assert doc2["cfg", "tags"] == ["gamma"] + assert doc2["cfg", "name"] == "foo" + assert doc2["cfg", "extra"] == 1 + + def test_flow_sequence_at_path(self): + doc = Document("items: [a, b, c]\n") + doc2 = doc.merge("items", value=["x", "y"]) + assert doc2["items"] == ["x", "y"] + + +class TestMergeTypePromotion: + def test_scalar_to_mapping(self): + doc = Document("settings: defaults\n") + doc2 = doc.merge("settings", value={"debug": True}) + assert doc2["settings", "debug"] is True + + def test_mapping_to_scalar(self): + doc = Document("settings:\n debug: true\n") + doc2 = doc.merge("settings", value="off") + assert doc2["settings"] == "off" + + +class TestMergePathCreation: + def test_creates_missing_path(self): + doc = Document("a: 1\n") + doc2 = doc.merge("new_section", value={"x": 1}) + assert doc2["new_section", "x"] == 1 + assert doc2["a"] == 1 + + def test_creates_nested_missing_path(self): + doc = Document("a: 1\n") + doc2 = doc.merge("b", "c", value={"d": 2}) + assert doc2["b", "c", "d"] == 2 + + +class TestMergeNoop: + def test_returns_self_when_equal(self): + doc = Document("a: 1\nb: 2\n") + doc2 = doc.merge(value={"a": 1, "b": 2}) + assert doc2 is doc + + def test_returns_self_when_subset(self): + doc = Document("a: 1\nb: 2\nc: 3\n") + doc2 = doc.merge(value={"a": 1, "b": 2}) + assert doc2 is doc + + +class TestEditorMerge: + def test_editor_merge(self, tmp_path): + p = tmp_path / "test.yaml" + p.write_text("a: 1\nb: 2\n", encoding="utf-8") + with edit(p) as ed: + ed.merge(value={"a": 99, "c": 3}) + result = p.read_text(encoding="utf-8") + assert "a: 99" in result + assert "b: 2" in result + assert "c: 3" in result + + +class TestMergeErrors: + def test_merge_through_scalar_raises(self): + """Merging through a scalar path raises NodeTypeError.""" + doc = Document("a:\n b: 1\n") + with pytest.raises(NodeTypeError, match="Value at a > b"): + doc.merge("a", "b", "c", value={"x": 1}) diff --git a/tests/test_normalize_keys.py b/tests/test_normalize_keys.py new file mode 100644 index 0000000..1459bab --- /dev/null +++ b/tests/test_normalize_keys.py @@ -0,0 +1,33 @@ +"""Tests for normalize_keys.""" + +import pytest + +from yamltrip.document import normalize_keys + + +class TestNormalizeKeys: + def test_str_key(self) -> None: + assert normalize_keys("foo") == ("foo",) + + def test_int_key(self) -> None: + assert normalize_keys(0) == (0,) + + def test_tuple_of_str(self) -> None: + assert normalize_keys(("a", "b")) == ("a", "b") + + def test_tuple_of_int(self) -> None: + assert normalize_keys((0, 1)) == (0, 1) + + def test_tuple_mixed(self) -> None: + assert normalize_keys(("a", 0, "b")) == ("a", 0, "b") + + def test_empty_tuple(self) -> None: + assert normalize_keys(()) == () + + def test_invalid_type_raises(self) -> None: + with pytest.raises(TypeError, match="Keys must be str, int, or tuple"): + normalize_keys([1, 2]) + + def test_invalid_tuple_element_raises(self) -> None: + with pytest.raises(TypeError, match="Key elements must be str or int"): + normalize_keys(("a", 3.14)) diff --git a/uv.lock b/uv.lock index 64b4c80..d965390 100644 --- a/uv.lock +++ b/uv.lock @@ -44,6 +44,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/46/d3ec57ad500f598d1554bd14ce4df615960549ab2844961bc4e1f5fbd174/ast_serialize-0.3.0-cp39-abi3-win_arm64.whl", hash = "sha256:0dd00da29985f15f50dc35728b7e1e7c84507bccfea1d9914738530f1c72238a", size = 1077165, upload-time = "2026-04-30T23:24:46.377Z" }, ] +[[package]] +name = "basedpyright" +version = "1.39.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodejs-wheel-binaries" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/1a/48296b4479ccc9051eb9617a6507a69a68f5b68693fb6a118cfe08199270/basedpyright-1.39.6.tar.gz", hash = "sha256:d00ec5f8ba4e1a67dfc2fa3a9474229c89f61f207d14c02d320db78f57aa16ef", size = 25504244, upload-time = "2026-05-24T07:44:41.864Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/07/6d1b3192715d42e8c9887876684a941eff28ec5d79c23a0f3758377e2182/basedpyright-1.39.6-py3-none-any.whl", hash = "sha256:5e0b9befbae6b26d0fbcc6645ac26923725e749d1224539e24f05ab07f9365ad", size = 13182122, upload-time = "2026-05-24T07:44:47.086Z" }, +] + [[package]] name = "click" version = "8.3.3" @@ -572,6 +584,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "nodejs-wheel-binaries" +version = "24.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/70/a1e4f4d5986768ab90cc860b1cc3660fd2ded74ca175a900a5c29f839c7d/nodejs_wheel_binaries-24.15.0.tar.gz", hash = "sha256:b43f5c4f6e5768d8845b2ae4682eb703a19bf7aadc84187e2d903ed3a611c859", size = 8057, upload-time = "2026-04-19T15:48:16.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/66/54051d14853d6ab4fb85f8be9b042b530be653357fb9a19557498bc91ab7/nodejs_wheel_binaries-24.15.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:a6232fa8b754220941f52388c8ead923f7c1c7fdf0ea0d98f657523bd9a81ef4", size = 55173485, upload-time = "2026-04-19T15:47:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5f/66acada164da5ca10a0824db021aa7394ae18396c550cd9280e839a43126/nodejs_wheel_binaries-24.15.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:001a6b62c69d9109c1738163cca00608dd2722e8663af59300054ea02610972d", size = 55348100, upload-time = "2026-04-19T15:47:40.521Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2d/0cbd5ff40c9bb030ca1735d8f8793bd74f08a4cbd49100a1d19313ea57ab/nodejs_wheel_binaries-24.15.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:0fbc48765e60ed0ff30d43898dbf5cadbadf2e5f1e7f204afc2b01493b7ebce6", size = 59668206, upload-time = "2026-04-19T15:47:46.848Z" }, + { url = "https://files.pythonhosted.org/packages/da/d5/91ac63951ec75927a486b83b8cafe650e360fa70ac01dc94adfb32b93b97/nodejs_wheel_binaries-24.15.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:20ee0536809795da8a4942fc1ab4cbdebbcaaf29383eab67ba8874268fb00008", size = 60206736, upload-time = "2026-04-19T15:47:52.668Z" }, + { url = "https://files.pythonhosted.org/packages/db/72/dc22776974d928869c0c30d23ee98ed7df254243c2df68f09f5963e8e8b8/nodejs_wheel_binaries-24.15.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1fade6c214285e72472ca40a631e98ff36559671cd5eefc8bf009471d67f04b4", size = 61720456, upload-time = "2026-04-19T15:47:58.325Z" }, + { url = "https://files.pythonhosted.org/packages/01/0a/34461b9050cb45ee371dccdefc622aef6351506ea2691b08fc761ca67150/nodejs_wheel_binaries-24.15.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3984cb8d87766567aee67a49743227ab40ede6f47734ec990ff90e50b74e7740", size = 62326172, upload-time = "2026-04-19T15:48:04.094Z" }, + { url = "https://files.pythonhosted.org/packages/c9/17/09252bf35672dba926649d59dfe51443a0f6955ad13784e91131d5ec82a2/nodejs_wheel_binaries-24.15.0-py2.py3-none-win_amd64.whl", hash = "sha256:a437601956b532dcb3082046e6978e622733f90edc0932cbb9adb3bb97a16501", size = 41543461, upload-time = "2026-04-19T15:48:09.332Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/b649777d148e1e0c2ce349156603cdb12f7ed99921b95d93717393650193/nodejs_wheel_binaries-24.15.0-py2.py3-none-win_arm64.whl", hash = "sha256:bdf4a431e08321a32efc604111c6f23941f87055d796a537e8c4110daecad23f", size = 39233248, upload-time = "2026-04-19T15:48:13.326Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -838,13 +866,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "vulture" +version = "2.16" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/3e/4d08c5903b2c0c70cad583c170cc4a663fc6a61e2ad00b711fcda61358cd/vulture-2.16.tar.gz", hash = "sha256:f8d9f6e2af03011664a3c6c240c9765b3f392917d3135fddca6d6a68d359f717", size = 52680, upload-time = "2026-03-25T14:41:27.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/be/f935130312330614811dae2ea9df3f395f6d63889eb6c2e68c14507152ee/vulture-2.16-py3-none-any.whl", hash = "sha256:6e0f1c312cef1c87856957e5c2ca9608834a7c794c2180477f30bf0e4cc58eee", size = 26993, upload-time = "2026-03-25T14:41:26.21Z" }, +] + [[package]] name = "yamltrip" version = "0.5.0" source = { editable = "." } +dependencies = [ + { name = "typing-extensions" }, +] [package.dev-dependencies] dev = [ + { name = "basedpyright" }, { name = "codespell" }, { name = "deptry" }, { name = "import-linter" }, @@ -855,6 +899,7 @@ dev = [ { name = "ruff" }, { name = "tomli" }, { name = "ty" }, + { name = "vulture" }, ] test = [ { name = "coverage", extra = ["toml"] }, @@ -862,9 +907,11 @@ test = [ ] [package.metadata] +requires-dist = [{ name = "typing-extensions", specifier = ">=4.4.0" }] [package.metadata.requires-dev] dev = [ + { name = "basedpyright", specifier = ">=1.39.6" }, { name = "codespell", specifier = ">=2.4.2" }, { name = "deptry", specifier = ">=0.25.1" }, { name = "import-linter", specifier = ">=2.11" }, @@ -875,6 +922,7 @@ dev = [ { name = "ruff", specifier = ">=0.15.12" }, { name = "tomli", specifier = ">=2.4.1" }, { name = "ty", specifier = ">=0.0.35" }, + { name = "vulture", specifier = ">=2.16" }, ] test = [ { name = "coverage", extras = ["toml"], specifier = ">=7.14.0" },