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
68 changes: 68 additions & 0 deletions doc/specs/2026-05-19-get-method-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Document.get() — Non-Raising Value Access

**Date:** 2026-05-19
**Issue:** #23

## Problem

Accessing `doc.root` or `doc[()]` on a document with no YAML value (empty string, comment-only, or `---` only) raises `QueryError`. This forces callers to wrap every access in try/except even though an empty document is a normal state.

The request proposes making `.root` return `None`, but that conflates "document has no value" with "document explicitly contains null" — two semantically different states. A `.get()` pattern keeps the distinction clear and gives callers control over the fallback.

## Design

Add a `get(*keys, default=None)` method to `Document` and `Editor` that returns the parsed Python value at the given path, or `default` if the path doesn't exist.

### Signature

```python
def get(self, *keys: KeyPart, default: Any = None) -> Any:
```

### Semantics

| Expression | Result |
|------------|--------|
| `doc.get()` | Root value, or `None` if empty doc |
| `doc.get('tool', 'ruff')` | Value at path, or `None` if missing |
| `doc.get('missing', default={})` | `{}` |
| `doc.get('existing_null_key')` | `None` (the actual YAML null) |
| `doc.root` | Unchanged — still raises `QueryError` on empty docs |

### Null Ambiguity

`doc.get('key')` returns `None` both for "key missing" and "key has YAML null value". Callers who need to distinguish use `'key' in doc` first. This is the same trade-off as `dict.get()`.

## Change Locations

- `src/yamltrip/document.py` — add `get()` method to `Document`
- `src/yamltrip/editor.py` — add `get()` method to `Editor`
- No Rust changes required

## Testing

New tests:

- `doc.get()` on empty document → `None`
- `doc.get()` on comment-only document → `None`
- `doc.get()` on `---\n` document → `None`
- `doc.get()` on document with root value → returns the value
- `doc.get('key')` on existing key → returns value
- `doc.get('key')` on missing key → `None`
- `doc.get('key', default={})` on missing key → `{}`
- `doc.get('nested', 'path')` on existing nested path → value
- `doc.get('nested', 'missing')` on partial path → `None`
- `doc.get('null_key')` where value is YAML null → `None`
- `doc.root` still raises on empty docs (no regression)
- `Editor.get()` mirrors Document behavior

## Scope Boundaries

**In scope:**
- `Document.get()` method
- `Editor.get()` method

**Out of scope:**
- Changing `.root` behavior
- Adding `.get()` to the stub file (`_core.pyi`) — this is Python-only
- Sentinel-based missing detection (use `in` operator for that)
2 changes: 1 addition & 1 deletion ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ lint.select = [
]
lint.ignore = [ "PLR2004", "S101", "SIM108" ]
lint.per-file-ignores."!tests/**/*.py" = [ "ARG002", "PT" ]
lint.per-file-ignores."tests/**" = [ "D", "INP", "S603", "TC" ]
lint.per-file-ignores."tests/**" = [ "B018", "D", "INP", "S603", "TC" ]
lint.flake8-builtins.strict-checking = true
lint.flake8-type-checking.quote-annotations = true
lint.flake8-type-checking.strict = true
Expand Down
11 changes: 11 additions & 0 deletions src/yamltrip/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,17 @@ def root(self) -> Any:
"""The entire document parsed as a Python object."""
return self[()]

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)
route = _make_route(normalized)
try:
return self._core_doc.parse_value(route)
except KeyError:
return default
Comment thread
nathanjmcdougall marked this conversation as resolved.
except ValueError as e:
raise QueryError(str(e)) from None

def __eq__(self, other: object) -> bool:
"""Compare documents by their source text."""
if not isinstance(other, Document):
Expand Down
4 changes: 4 additions & 0 deletions src/yamltrip/editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ def root(self) -> Any:
"""The entire document parsed as a Python object."""
return self.document.root

def get(self, *keys: KeyPart, default: Any = None) -> Any:
"""Return the parsed value at path, or default if missing."""
return self.document.get(*keys, default=default)

def __getitem__(self, keys: object) -> Any:
"""Retrieve the parsed value at the given path."""
return self.document[keys]
Expand Down
53 changes: 53 additions & 0 deletions tests/test_document.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,59 @@ def test_nested_key_missing(self):
assert ("a", "c") not in doc


class TestDocumentGet:
def test_empty_document_returns_default(self):
doc = Document("")
assert doc.get() is None

def test_comment_only_returns_default(self):
doc = Document("# just a comment\n")
assert doc.get() is None

def test_directive_only_returns_default(self):
doc = Document("---\n")
assert doc.get() is None

def test_root_value_returned(self):
doc = Document("name: foo\n")
assert doc.get() == {"name": "foo"}

def test_existing_key(self):
doc = Document("name: foo\nversion: 1\n")
assert doc.get("name") == "foo"

def test_missing_key_returns_none(self):
doc = Document("name: foo\n")
assert doc.get("missing") is None

def test_missing_key_returns_custom_default(self):
doc = Document("name: foo\n")
assert doc.get("missing", default={}) == {}

def test_nested_existing_path(self):
doc = Document("a:\n b: 1\n")
assert doc.get("a", "b") == 1

def test_nested_missing_path(self):
doc = Document("a:\n b: 1\n")
assert doc.get("a", "c") is None

def test_null_value_returns_none(self):
doc = Document("key: null\n")
assert doc.get("key") is None

def test_malformed_value_raises_query_error(self):
"""get() should raise QueryError (not ValueError) for unparsable values."""
doc = Document("a:\n b: 1\n b: 2")
with pytest.raises(QueryError):
doc.get("a")

def test_root_still_raises_on_empty(self):
doc = Document("")
with pytest.raises(QueryError):
doc.root
Comment thread
nathanjmcdougall marked this conversation as resolved.


class TestDocumentInspection:
def test_query_returns_feature(self):
doc = Document("name: foo")
Expand Down
19 changes: 19 additions & 0 deletions tests/test_editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,22 @@ def test_document_outside_context(self, yaml_file):
def test_repr(self, yaml_file):
editor = Editor(yaml_file)
assert "Editor(" in repr(editor)


class TestEditorGet:
def test_get_existing_key(self, yaml_file):
with Editor(yaml_file) as editor:
assert editor.get("name") == "foo"

def test_get_missing_key(self, yaml_file):
with Editor(yaml_file) as editor:
assert editor.get("missing") is None

def test_get_missing_key_with_default(self, yaml_file):
with Editor(yaml_file) as editor:
assert editor.get("missing", default="fallback") == "fallback"

def test_get_root(self, yaml_file):
with Editor(yaml_file) as editor:
result = editor.get()
assert result["name"] == "foo"
Loading