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
8 changes: 5 additions & 3 deletions .github/workflows/python-bench-regression.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ jobs:
with:
suffix: head
quick: true
repeats: "3"
warmup_loops: "0"
repeats: "5"
warmup_loops: "1"
artifact_name: pathable-bench-pr-head

compare:
Expand All @@ -38,7 +38,9 @@ jobs:
id: baseline-cache
uses: actions/cache/restore@v4
with:
path: reports
path: |
reports/bench-parse.baseline.json
reports/bench-lookup.baseline.json
key: bench-baseline-py3.12-${{ github.event.pull_request.base.sha }}
restore-keys: |
bench-baseline-py3.12-
Expand Down
34 changes: 30 additions & 4 deletions pathable/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,10 +429,36 @@ def __iter__(self: TAccessorPath) -> Iterator[TAccessorPath]:

def __getitem__(self: TAccessorPath, key: K) -> V | TAccessorPath:
"""Access a child path's value."""
path = self // key
if path.is_traversable():
return path
return cast(V, path.read_value())
path: TAccessorPath | None = None

# Fast path: if accessor supports direct traversal helpers, resolve the
# child once and classify it without repeating full-path lookups.
try:
parent = self.accessor[self.parts]
child = self.accessor._get_subnode(parent, key)
except NotImplementedError:
# Compatibility path for accessors that only implement keys/read.
path = self // key
if path.is_traversable():
return path
return cast(V, path.read_value())

try:
if self.accessor._is_traversable_node(child):
path = self._make_child_relpath(key)
return path
except NotImplementedError:
if path is None:
path = self // key
if path.is_traversable():
return path

try:
return cast(V, self.accessor._read_node(child))
except NotImplementedError:
if path is None:
path = self // key
return cast(V, path.read_value())

def __contains__(self, key: K) -> bool:
"""Check if a key exists in the path.
Expand Down
38 changes: 38 additions & 0 deletions tests/benchmarks/bench_lookup.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,44 @@ def main(argv: Iterable[str] | None = None) -> int:
)
)

# __getitem__ leaf read: should return value for non-traversable child.
leaf_parent = LookupPath.from_lookup(
{"root": {"branch": {"leaf": "value"}}}, "root", "branch"
)

def getitem_leaf(_p: LookupPath = leaf_parent) -> None:
_ = _p["leaf"]

loops_getitem_leaf = 200_000 if not args.quick else 20_000
results.append(
run_benchmark(
"lookup.getitem.leaf",
getitem_leaf,
loops=loops_getitem_leaf,
repeats=repeats,
warmup_loops=warmup_loops,
)
)

# __getitem__ branch read: should return child path for traversable child.
branch_parent = LookupPath.from_lookup(
{"root": {"branch": {"child": {"x": 1}}}}, "root", "branch"
)

def getitem_branch(_p: LookupPath = branch_parent) -> None:
_ = _p["child"]

loops_getitem_branch = 200_000 if not args.quick else 20_000
results.append(
run_benchmark(
"lookup.getitem.branch",
getitem_branch,
loops=loops_getitem_branch,
repeats=repeats,
warmup_loops=warmup_loops,
)
)

# Cache miss cost: disable cache and repeatedly read.
deep_accessor = deep.accessor
if not isinstance(deep_accessor, LookupAccessor):
Expand Down
68 changes: 64 additions & 4 deletions tests/unit/test_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,10 @@ def read(self, parts: Sequence[Hashable]) -> Mapping[Hashable, Any] | Any:
return self._content


class MockTraversableAccessor(
NodeAccessor[Mapping[Hashable, Any], Hashable, Any]
):
class MockTraversableAccessor(NodeAccessor[Mapping[Any, Any], Hashable, Any]):
"""Mock accessor that implements _get_subnode for testing fast paths."""

def __init__(self, node: Mapping[Hashable, Any]):
def __init__(self, node: Mapping[Any, Any]):
super().__init__(node)

def keys(self, parts: Sequence[Hashable]) -> Any:
Expand Down Expand Up @@ -73,6 +71,57 @@ def _get_subnode(cls, node: Any, part: Hashable) -> Any:
raise KeyError(part) from exc


class CountingTraversableAccessor(
NodeAccessor[Mapping[Any, Any], Hashable, Any]
):
"""Accessor that counts traversal operations."""

def __init__(self, node: Mapping[Any, Any]):
super().__init__(node)
self.get_node_calls = 0

def keys(self, parts: Sequence[Hashable]) -> Any:
node = self._get_node(self.node, parts)
if isinstance(node, Mapping):
return list(node.keys())
raise KeyError

def len(self, parts: Sequence[Hashable]) -> int:
keys = self.keys(parts)
return len(keys)

def read(self, parts: Sequence[Hashable]) -> Any:
return self._read_node(self._get_node(self.node, parts))

@classmethod
def _is_traversable_node(cls, node: Mapping[Any, Any] | Any) -> bool:
return isinstance(node, Mapping)

@classmethod
def _read_node(cls, node: Any) -> Any:
return node

@classmethod
def _get_subnode(cls, node: Any, part: Hashable) -> Any:
if not isinstance(node, Mapping):
raise KeyError(part)
try:
return node[part]
except KeyError as exc:
raise KeyError(part) from exc

@classmethod
def _get_node(cls, node: Any, parts: Sequence[Hashable]) -> Any:
current = node
for part in parts:
current = cls._get_subnode(current, part)
return current

def __getitem__(self, parts: Sequence[Hashable]) -> Any:
self.get_node_calls += 1
return super().__getitem__(parts)


class MockPart(str):
"""Mock resource for testing purposes."""

Expand Down Expand Up @@ -1024,6 +1073,17 @@ def test_invalid(self):
p["test4"]


class TestAccessorPathGetItemPerformance:
def test_single_traversal_for_leaf_value(self):
accessor = CountingTraversableAccessor({"a": {"b": "value"}})
p = AccessorPath(accessor, "a")

result = p["b"]

assert result == "value"
assert accessor.get_node_calls == 1


class TestLookupPathReadValue:
@pytest.mark.parametrize(
"resource,args,expected",
Expand Down