Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .importlinter
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,3 @@ exhaustive_ignores =
_display
_types
ignore_imports =
yamltrip.document -> yamltrip
yamltrip.sync -> yamltrip
14 changes: 14 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Comment thread
nathanjmcdougall marked this conversation as resolved.
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:
Expand Down
123 changes: 123 additions & 0 deletions doc/specs/2026-05-22-deep-merge-design.md
Original file line number Diff line number Diff line change
@@ -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()`) |
Comment thread
nathanjmcdougall marked this conversation as resolved.

Comment thread
nathanjmcdougall marked this conversation as resolved.
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
Comment thread
nathanjmcdougall marked this conversation as resolved.

## 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
33 changes: 32 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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
]
16 changes: 2 additions & 14 deletions src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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})"),
}
}
}
Comment thread
nathanjmcdougall marked this conversation as resolved.
Expand Down
24 changes: 19 additions & 5 deletions src/yamltrip/_core.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from typing import Any, ClassVar, final

from typing_extensions import override

__all__ = [
"Component",
"Document",
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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: ...

Comment thread
nathanjmcdougall marked this conversation as resolved.
@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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading