Skip to content

Commit 379171a

Browse files
feat: add merge() method + basedpyright type checking (#41)
* fix: prevent nested dict flattening in _create_at Op.merge_into flattens nested dicts to the wrong indentation level when creating intermediate keys (e.g. upsert('parent', 'child', value={'x': 1})). Fix by using add(key, None) + replace(value) which correctly serializes the full nested structure. Adds a regression test that fails without the fix. * refactor: add remove_extra flag to _compute_patches * feat: add Document.merge() method * test: add merge edge case tests Add test classes for list replacement, type promotion, path creation, and no-op merge scenarios. Fix _create_at to correctly handle nested dict values by using placeholder+replace instead of direct Op.add which flattens nested structures. * feat: add Editor.merge() delegate * docs: add deep merge design spec and implementation plan * refactor: address review feedback - Short-circuit list diffing in merge (replace entirely per spec) - Add root-exists comment to merge() for consistency with sync() - Add flow sequence tests to test_merge.py * fix: use doc in merge fallback, add equality guard, add error test * fix: raise NodeTypeError on scalar traversal, remove broken spec reference * fix: improve merge comment clarity and NodeTypeError message format * refactor: address review - improve error path, remove dead flow-seq code, rewrite docstring, introduce DiffMode enum * feat: add basedpyright type checking - Add basedpyright as dev dependency - Add typing_extensions as runtime dependency (for @OverRide on py3.10/3.11) - Configure [tool.basedpyright] with appropriate suppressions - Add basedpyright hook to .pre-commit-config.yaml (runs in CI via prek) - Switch to relative imports to break import cycles - Remove redundant Component.key()/Component.index() static factories - Retype _create_at child_keys as tuple[str, ...] with _ensure_str_keys - Make normalize_keys and compute_patches public (with tests) - Add @OverRide decorators and type annotations for class attributes - Enable reportUnusedCallResult, reportUnknownMemberType, reportUnnecessaryIsInstance, reportImplicitStringConcatenation * fix: update DiffMode docstring to reference compute_patches * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent c009316 commit 379171a

14 files changed

Lines changed: 654 additions & 160 deletions

.importlinter

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,3 @@ exhaustive_ignores =
1818
_display
1919
_types
2020
ignore_imports =
21-
yamltrip.document -> yamltrip
22-
yamltrip.sync -> yamltrip

.pre-commit-config.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,20 @@ repos:
5555
entry: uv run --frozen cargo clippy --all-targets --no-default-features -- -D warnings
5656
language: system
5757
pass_filenames: false
58+
- repo: local
59+
hooks:
60+
- id: basedpyright
61+
name: basedpyright
62+
always_run: true
63+
entry: uv run --frozen --offline basedpyright
64+
language: system
65+
types: [python]
66+
pass_filenames: false
67+
require_serial: true
68+
- repo: https://github.com/jendrikseipp/vulture
69+
rev: v2.16
70+
hooks:
71+
- id: vulture
5872
- repo: https://github.com/codespell-project/codespell
5973
rev: v2.4.2
6074
hooks:
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# Design: Deep Merge (`merge()`)
2+
3+
**Date:** 2026-05-22
4+
**Status:** Approved
5+
6+
## Summary
7+
8+
Add a `merge()` method that recursively merges a value into an existing
9+
mapping without removing keys not present in the target. Lists replace
10+
entirely (list identity is ambiguous); only mapping keys get additive
11+
treatment.
12+
13+
## Semantics
14+
15+
| Current type | Target type | Behavior |
16+
|---|---|---|
17+
| mapping | mapping | Recurse: update matching keys, add new keys, never remove existing keys |
18+
| list | list | Replace the list entirely |
19+
| scalar | mapping | Replace scalar with mapping (promote) |
20+
| mapping | scalar | Replace mapping with scalar |
21+
| any | any (equal) | No-op |
22+
| missing | any | Create path + set value (delegate to `upsert()`) |
23+
24+
Full-depth recursion always. No configurable depth parameter.
25+
26+
## Public API
27+
28+
```python
29+
# Document (immutable, returns new Document)
30+
doc = doc.merge(*keys, value={"debug": False, "timeout": 30})
31+
32+
# Editor (mutable context manager)
33+
with edit("config.yaml") as ed:
34+
ed.merge("settings", value={"debug": False, "timeout": 30})
35+
```
36+
37+
Signature: `merge(self, *keys: KeyPart, value: Any) -> Document`
38+
39+
Matches `sync()` signature exactly.
40+
41+
## Error behavior
42+
43+
- `NodeTypeError` if path traverses through a scalar/list where a mapping
44+
is expected
45+
- `PatchError` on Rust-level failures (same fallback as `sync()`)
46+
- No new error types
47+
48+
## Scope
49+
50+
- No new Rust code — reuses existing patch primitives
51+
- No new error types
52+
- `sync()` behavior unchanged
53+
- Available on both `Document` and `Editor`
54+
55+
## Examples
56+
57+
```python
58+
from yamltrip import loads
59+
60+
doc = loads("""
61+
settings:
62+
debug: true
63+
log_level: info
64+
custom_setting: 42
65+
""")
66+
67+
# Merge ensures debug+timeout exist without removing log_level/custom_setting
68+
doc = doc.merge("settings", value={"debug": False, "timeout": 30})
69+
70+
# Result:
71+
# settings:
72+
# debug: false
73+
# log_level: info
74+
# custom_setting: 42
75+
# timeout: 30
76+
```
77+
78+
```python
79+
# Lists replace entirely
80+
doc = loads("""
81+
plugins:
82+
- eslint
83+
- prettier
84+
""")
85+
86+
doc = doc.merge("plugins", value=["stylelint"])
87+
88+
# Result:
89+
# plugins:
90+
# - stylelint
91+
```
92+
93+
```python
94+
# Nested merge
95+
doc = loads("""
96+
database:
97+
host: localhost
98+
credentials:
99+
user: admin
100+
password: secret
101+
""")
102+
103+
doc = doc.merge("database", value={"credentials": {"user": "deploy"}, "port": 5432})
104+
105+
# Result:
106+
# database:
107+
# host: localhost
108+
# credentials:
109+
# user: deploy
110+
# password: secret
111+
# port: 5432
112+
```
113+
114+
## Testing
115+
116+
- Mapping merge: keeps extra keys, updates matching, adds new
117+
- Nested mapping merge: recurses correctly
118+
- List replacement: lists in target replace entirely
119+
- Scalar-to-mapping promotion: works
120+
- Missing path creation: delegates to upsert
121+
- No-op when values equal: returns same Document instance
122+
- Flow sequence handling: same fallback as sync
123+
- Editor delegation: verify Editor.merge works

pyproject.toml

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,13 @@ classifiers = [
1818
"Programming Language :: Python :: 3.13",
1919
"Programming Language :: Python :: 3.14",
2020
]
21-
dependencies = []
21+
dependencies = [
22+
"typing-extensions>=4.4.0",
23+
]
2224

2325
[dependency-groups]
2426
dev = [
27+
"basedpyright>=1.39.6",
2528
"codespell>=2.4.2",
2629
"deptry>=0.25.1",
2730
"import-linter>=2.11",
@@ -32,6 +35,7 @@ dev = [
3235
"ruff>=0.15.12",
3336
"tomli>=2.4.1",
3437
"ty>=0.0.35",
38+
"vulture>=2.16",
3539
]
3640
test = [
3741
"coverage[toml]>=7.14.0",
@@ -75,3 +79,30 @@ report.exclude_also = [
7579
"raise NotImplementedError",
7680
]
7781
report.omit = [ "*/pytest-of-*/*" ]
82+
83+
[tool.basedpyright]
84+
reportAny = false
85+
reportExplicitAny = false
86+
reportUnknownArgumentType = false
87+
reportUnknownVariableType = false
88+
89+
[[tool.basedpyright.executionEnvironments]]
90+
root = "tests"
91+
reportMissingParameterType = false
92+
reportPrivateUsage = false
93+
reportUnannotatedClassAttribute = false
94+
reportUnknownLambdaType = false
95+
reportUnknownMemberType = false
96+
reportUnknownParameterType = false
97+
reportUnreachable = false
98+
reportUnusedCallResult = false
99+
reportUnusedExpression = false
100+
reportUnusedFunction = false
101+
reportUnusedParameter = false
102+
103+
[tool.vulture]
104+
paths = [ "src/yamltrip" ]
105+
ignore_names = [
106+
"dump", # Public API method on Document
107+
"original", # Public API property on Editor
108+
]

src/types.rs

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -76,22 +76,10 @@ pub enum PyComponent {
7676

7777
#[pymethods]
7878
impl PyComponent {
79-
#[staticmethod]
80-
fn key(name: &str) -> Self {
81-
Self::Key {
82-
name: name.to_string(),
83-
}
84-
}
85-
86-
#[staticmethod]
87-
fn index(index: usize) -> Self {
88-
Self::Index { index }
89-
}
90-
9179
fn __repr__(&self) -> String {
9280
match self {
93-
Self::Key { name } => format!("Component.key('{name}')"),
94-
Self::Index { index } => format!("Component.index({index})"),
81+
Self::Key { name } => format!("Component.Key('{name}')"),
82+
Self::Index { index } => format!("Component.Index({index})"),
9583
}
9684
}
9785
}

src/yamltrip/_core.pyi

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from typing import Any, ClassVar, final
44

5+
from typing_extensions import override
6+
57
__all__ = [
68
"Component",
79
"Document",
@@ -22,8 +24,11 @@ class Location:
2224
def start(self) -> int: ...
2325
@property
2426
def end(self) -> int: ...
27+
@override
2528
def __eq__(self, value: object, /) -> bool: ...
29+
@override
2630
def __hash__(self) -> int: ...
31+
@override
2732
def __repr__(self) -> str: ...
2833

2934
@final
@@ -33,9 +38,12 @@ class FeatureKind:
3338
FlowMapping: ClassVar[FeatureKind]
3439
BlockSequence: ClassVar[FeatureKind]
3540
FlowSequence: ClassVar[FeatureKind]
41+
@override
3642
def __eq__(self, value: object, /) -> bool: ...
43+
@override
3744
def __hash__(self) -> int: ...
3845
def __int__(self) -> int: ...
46+
@override
3947
def __repr__(self) -> str: ...
4048

4149
class Component:
@@ -51,22 +59,24 @@ class Component:
5159
__match_args__: ClassVar[tuple[str, ...]]
5260
def __new__(cls, index: int) -> Component.Index: ...
5361
@property
54-
def index(self) -> int: ... # type: ignore[override]
62+
def index(self) -> int: ...
5563

56-
@staticmethod
57-
def key(name: str) -> Component: ...
58-
@staticmethod
59-
def index(index: int) -> Component: ...
64+
@override
6065
def __eq__(self, value: object, /) -> bool: ...
66+
@override
6167
def __hash__(self) -> int: ...
68+
@override
6269
def __repr__(self) -> str: ...
6370

6471
@final
6572
class Route:
6673
def __new__(cls, parts: list[str | int]) -> Route: ...
6774
def __len__(self) -> int: ...
75+
@override
6876
def __eq__(self, value: object, /) -> bool: ...
77+
@override
6978
def __hash__(self) -> int: ...
79+
@override
7080
def __repr__(self) -> str: ...
7181

7282
@final
@@ -79,8 +89,11 @@ class Feature:
7989
def kind(self) -> FeatureKind: ...
8090
@property
8191
def is_multiline(self) -> bool: ...
92+
@override
8293
def __eq__(self, value: object, /) -> bool: ...
94+
@override
8395
def __hash__(self) -> int: ...
96+
@override
8497
def __repr__(self) -> str: ...
8598

8699
@final
@@ -111,6 +124,7 @@ class Op:
111124
def insert_at(index: int, value: Any) -> Op: ...
112125
@property
113126
def kind(self) -> str: ...
127+
@override
114128
def __repr__(self) -> str: ...
115129

116130
@final

0 commit comments

Comments
 (0)