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
16 changes: 14 additions & 2 deletions pathable/accessors.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,11 @@ def _read_cached(cls, node_id: int, parts: PDeque[CSK]) -> CSV:
class LookupAccessor(CachedSubscriptableAccessor[LookupKey, LookupValue]):

def stat(self, parts: Sequence[LookupKey]) -> Union[dict[str, Any], None]:
node = self._get_node(self.node, pdeque(parts))
try:
node = self._get_node(self.node, pdeque(parts))
except KeyError:
return None

if isinstance(node, Mapping):
return {
'type': 'mapping',
Expand All @@ -164,7 +168,15 @@ def stat(self, parts: Sequence[LookupKey]) -> Union[dict[str, Any], None]:
'type': 'list',
'length': len(node),
}
return None
try:
length = len(node)
except TypeError:
length = None

return {
'type': type(node),
'length': length,
}
Comment on lines 176 to 179
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return value for the 'type' key has changed from a string literal ('mapping' or 'list') to a type object (type(node)). This is a breaking API change that could affect consumers expecting string values. If this change is intentional, it should be clearly documented. Additionally, this will now include the type for all node values, not just Mappings and lists, which changes the semantics of when stat() returns a result versus None.

Suggested change
return {
'type': type(node),
'length': len(node),
}
if isinstance(node, Mapping):
return {
'type': 'mapping',
'length': len(node),
}
if isinstance(node, list):
return {
'type': 'list',
'length': len(node),
}
return None

Copilot uses AI. Check for mistakes.

def keys(self, parts: Sequence[LookupKey]) -> Sequence[LookupKey]:
node = self._get_node(self.node, pdeque(parts))
Expand Down
110 changes: 51 additions & 59 deletions pathable/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,17 @@ def __init__(self, accessor: NodeAccessor[N, K, V], *args: Any, separator: Optio
object.__setattr__(self, 'accessor', accessor)
super().__init__(*args, separator=separator)

@classmethod
def _from_parts(
cls: Type[TAccessorPath],
args: Sequence[Any],
separator: Optional[str] = None,
accessor: Union[NodeAccessor[N, K, V], None] = None,
) -> TAccessorPath:
if accessor is None:
raise ValueError("accessor must be provided")
return cls(accessor, *args, separator=separator)

@classmethod
def _from_parsed_parts(
cls: Type[TAccessorPath],
Expand Down Expand Up @@ -159,87 +170,68 @@ def _make_child_relpath(
parts, separator=self.separator, accessor=self.accessor,
)

def __rtruediv__(self: TAccessorPath, key: Hashable) -> TAccessorPath:
try:
return self._from_parts(
(key, ) + self.parts,
separator=self.separator,
accessor=self.accessor,
)
except TypeError:
return NotImplemented

def __floordiv__(self: TAccessorPath, key: K) -> TAccessorPath:
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method raises KeyError - should raise an ArithmeticError or return NotImplemented instead.

Copilot uses AI. Check for mistakes.
"""Return a new existing path with the key appended."""
if key not in self:
raise KeyError(key)
return self / key

def __rfloordiv__(self: TAccessorPath, key: K) -> TAccessorPath:
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method raises KeyError - should raise an ArithmeticError or return NotImplemented instead.

Copilot uses AI. Check for mistakes.
"""Return a new existing path with the key prepended."""
new = key / self
if not new.exists():
raise KeyError(key)
return new

def __iter__(self: TAccessorPath) -> Iterator[TAccessorPath]:
"""Iterate over all child paths."""
for key in self.accessor.keys(self.parts):
yield self._make_child_relpath(key)

def __getitem__(self, key: K) -> Any:
if key not in self:
raise KeyError(key)
path = self / key
def __getitem__(self, key: K) -> V:
"""Access a child path's value."""
path = self // key
return path.read_value()

def __contains__(self, key: K) -> bool:
"""Check if a key exists in the path."""
return key in self.accessor.keys(self.parts)

def __len__(self) -> int:
"""Return the number of child paths."""
return self.accessor.len(self.parts)

def exists(self) -> bool:
"""Check if the path exists."""
return self.accessor.stat(self.parts) is not None
Comment on lines +214 to +216
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new exists() method lacks test coverage. Given that this codebase has comprehensive test coverage for other methods (as evidenced by the extensive test classes in this file), the exists() method should have corresponding tests to verify: (1) it returns True for existing paths, (2) it returns False for non-existing paths, and (3) edge cases like empty paths or invalid path structures.

Copilot uses AI. Check for mistakes.

def keys(self) -> Sequence[K]:
"""Return all keys at the current path."""
return self.accessor.keys(self.parts)

def getkey(self, key: K, default: Any = None) -> Any:
"""Return the value for key if key is in the path, else default."""
warnings.warn(
"'getkey' method is deprecated. Use 'key not in path' and 'path.read_value' instead.",
DeprecationWarning,
stacklevel=2,
)
try:
return self[key]
except KeyError:
return default

def iter(self: TAccessorPath) -> Iterator[TAccessorPath]:
"""Iterate over all child paths."""
warnings.warn(
"'iter' method is deprecated. Use 'iter(path)' instead.",
DeprecationWarning,
)
return iter(self)

def iteritems(self: TAccessorPath) -> Iterator[tuple[K, TAccessorPath]]:
"""Return path's items."""
warnings.warn(
"'iteritems' method is deprecated. Use 'items' instead.",
DeprecationWarning,
)
return self.items()

def items(self: TAccessorPath) -> Iterator[tuple[K, TAccessorPath]]:
"""Return path's items."""
for key in self.accessor.keys(self.parts):
yield key, self._make_child_relpath(key)

def content(self) -> Any:
warnings.warn(
"'content' method is deprecated. Use 'read_value' instead.",
DeprecationWarning,
)
return self.read_value()

def get(self, key: K, default: Any = None) -> Any:
"""Return the child path for key if key is in the path, else default."""
warnings.warn(
"'get' method is deprecated. Use 'key in path' and 'path / key' instead.",
DeprecationWarning,
stacklevel=2,
)
if key in self:
return self / key
return default

@contextmanager
def open(self) -> Any:
"""Open the path."""
warnings.warn(
"'open' method is deprecated. Use 'read_value' instead.",
DeprecationWarning,
stacklevel=2,
)
yield self.read_value()
"""Return the value for key if key is in the path, else default."""
try:
return self[key]
except KeyError:
return default
Comment on lines 221 to +232
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The semantics of the get() method have completely changed. The old deprecated implementation returned a child Path object (self / key), while the new implementation returns the value at that path (self[key], which calls read_value()). This is a breaking change that fundamentally alters the method's behavior. Consider either: (1) keeping the old path-returning behavior for backwards compatibility, or (2) renaming this method to something like get_value() to make the semantic difference clear, or (3) clearly documenting this breaking change in migration documentation.

Copilot uses AI. Check for mistakes.

def read_value(self) -> Any:
def read_value(self) -> V:
"""Return the path's value."""
return self.accessor.read(self.parts)

Expand Down
107 changes: 106 additions & 1 deletion tests/unit/test_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,77 @@ def test_non_existing_key_default_defined(self):

assert result == value

@pytest.mark.parametrize(
"resource,key,expected",
(
[
{"test1": "test2"},
"test1",
'test2',
],
[
{"test1": {"test2": "test3"}},
"test1",
{'test2': 'test3'},
],
),
)
def test_key_exists(self, resource, key, expected):
p = LookupPath._from_lookup(resource)

result = p.get(key)

assert result == expected

class TestLookupPathExists:
def test_non_existing_key(self):
value = "testvalue"
resource = {"test1": {"test2": {"test3": value}}}
p = LookupPath._from_lookup(resource, "test1/test2/non_existing_key")

result = p.exists()

assert result is False

@pytest.mark.parametrize(
"resource,key",
(
[
{"test1": "test2"},
"test1",
],
[
{"test1": 123},
"test1",
],
[
{"test1": True},
"test1",
],
[
{"test1": {"test2": "test3"}},
"test1",
],
),
)
def test_key_exists(self, resource, key):
p = LookupPath._from_lookup(resource, key)

result = p.exists()

assert result is True


class TestLookupPathFloorDiv:

def test_non_existing_key(self):
value = "testvalue"
resource = {"test1": {"test2": {"test3": value}}}
p = LookupPath._from_lookup(resource, "test1/test2")

with pytest.raises(KeyError):
p // "non_existing_key"

@pytest.mark.parametrize(
"resource,key,expected",
(
Expand All @@ -826,7 +897,41 @@ def test_non_existing_key_default_defined(self):
def test_key_exists(self, resource, key, expected):
p = LookupPath._from_lookup(resource)

result = p.get(key)
result = p // key

assert result == expected

class TestLookupPathRfloorDiv:

def test_non_existing_key(self):
value = "testvalue"
resource = {"test1": {"test2": {"test3": value}}}
p = LookupPath._from_lookup(resource, "test1/test2")

with pytest.raises(KeyError):
"non_existing_key" // p

@pytest.mark.parametrize(
"resource,key,expected",
(
[
{"test1": "test2"},
"test1",
LookupPath._from_lookup({"test1": "test2"}, "test1"),
],
[
{"test1": {"test2": "test3"}},
"test1",
LookupPath._from_lookup(
{"test1": {"test2": "test3"}}, "test1"
),
],
),
)
def test_key_exists(self, resource, key, expected):
p = LookupPath._from_lookup(resource)

result = key // p

assert result == expected

Expand Down
Loading