Skip to content

Commit ee08931

Browse files
CollectionTree (#5)
1 parent 7fc429f commit ee08931

File tree

9 files changed

+380
-4
lines changed

9 files changed

+380
-4
lines changed

.devcontainer/devcontainer.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@
4343
// - https://github.com/DavidAnson/vscode-markdownlint/issues/180
4444
// - https://github.com/DavidAnson/vscode-markdownlint/issues/302
4545
// - https://github.com/DavidAnson/markdownlint/issues/209
46-
"MD046": false // {"style": "fenced"} leads to errors on codeblocks in admonitions
46+
"MD046": false, // {"style": "fenced"} leads to errors on codeblocks in admonitions
47+
"MD024": {
48+
"siblings_only": true
49+
}
4750
},
4851
"[markdown]": {
4952
"editor.tabSize": 2

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.0.2] - 2025-02-12
11+
12+
### Added
13+
14+
- `CollectionTree` to allow for comparison of test collection results provided by `pytester`.
15+
1016
## [0.0.1] - 2025-02-12
1117

1218
### Added

__pyversion__

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.0.1
1+
0.0.2

pyproject.toml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@
8686

8787
[tool.coverage.run]
8888
branch = true
89-
omit = ["tests/*"]
89+
source = ["pytest_ipynb2"]
9090

9191
[tool.coverage.report]
9292
exclude_also = [
@@ -99,7 +99,11 @@ exclude_also = [
9999
show_contexts = true
100100

101101
[tool.pytype]
102-
inputs = ["pytest_ipynb2"]
102+
inputs = ["pytest_ipynb2", "tests"]
103+
exclude = ["tests/assets"]
104+
keep_going = true
105+
jobs = "auto"
106+
none_is_not_bool = true
103107

104108
[tool.ruff]
105109
line-length = 120

pytest_ipynb2/pytester_helpers.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"""Helper classes and functions to support testing this plugin with pytester."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
from textwrap import indent
7+
from typing import TYPE_CHECKING
8+
9+
import pytest
10+
11+
if TYPE_CHECKING:
12+
from contextlib import suppress
13+
14+
with suppress(ImportError): # not type-checking on python < 3.11
15+
from typing import Self
16+
17+
class CollectionTree:
18+
"""
19+
A (top-down) tree of pytest collection Nodes.
20+
21+
Designed to enable testing the results of collection plugins via:
22+
```
23+
assert CollectionTree.from_items(pytester.genitems([...])) == CollectionTree.from_dict({...})
24+
```
25+
26+
WARNING: Currently only handles a single tree (one top-level node)
27+
"""
28+
29+
@classmethod
30+
def from_items(cls, items: list[pytest.Item]) -> Self:
31+
"""Create a CollectionTree from a list of collection items, as returned by `pytester.genitems()`."""
32+
def _from_item(item: pytest.Item | Self) -> Self:
33+
return item if isinstance(item, cls) else cls(node=item, children=None)
34+
35+
items = [_from_item(item) for item in items]
36+
37+
def _get_parents_as_CollectionTrees(items: list[Self]) -> list[Self]: # noqa: N802
38+
"""
39+
Walk up the tree one step. (Not recursive, does provide sentinel or repeat).
40+
41+
Returns a set of CollectionTree items for the parents of those provided.
42+
"""
43+
parents = (item.node.parent for item in items)
44+
items_byparent = {
45+
parent: [item for item in items if item.node.parent == parent]
46+
for parent in parents
47+
}
48+
return [
49+
cls(node = parent, children = list(children))
50+
for parent, children in items_byparent.items()
51+
]
52+
53+
if all(isinstance(item.node, pytest.Session) for item in items):
54+
assert len(items) == 1, "There should only ever be one session node." # noqa: S101
55+
return next(iter(items))
56+
57+
return cls.from_items(_get_parents_as_CollectionTrees(items))
58+
59+
@classmethod
60+
def from_dict(cls, tree: dict[tuple[str, type], dict | None]) -> Self:
61+
"""
62+
Create a dummy CollectionTree from a dict of dicts with following format:
63+
64+
```
65+
{(str: name, type: Nodetype):
66+
(str: name, type: Nodetype): {
67+
(str: name, type: Nodetype): None,
68+
(str: name, type: Nodetype): None
69+
}
70+
}
71+
}
72+
```
73+
74+
For example:
75+
```
76+
tree = {
77+
(f"<Dir {pytester.path.name}>", pytest.Dir): {
78+
("<Module test_module.py>", pytest.Module): {
79+
("<Function test_adder>", pytest.Function): None,
80+
("<Function test_globals>", pytest.Function): None,
81+
},
82+
},
83+
}
84+
```
85+
""" # noqa: D415
86+
if len(tree) != 1:
87+
msg = f"Please provide a dict with exactly 1 top-level entry (root), not {tree}"
88+
raise ValueError(msg)
89+
nodedetails, children = next(iter(tree.items()))
90+
node = cls._DummyNode(*nodedetails)
91+
92+
if children is not None:
93+
return cls(
94+
node=node,
95+
children=[
96+
cls.from_dict({childnode: grandchildren})
97+
for childnode, grandchildren in children.items()
98+
],
99+
)
100+
101+
return cls(node=node, children=None)
102+
103+
@dataclass
104+
class _DummyNode:
105+
"""
106+
A dummy node for a `CollectionTree`, used by `CollectionTree.from_dict()`.
107+
108+
Compares equal to a genuine `pytest.Node` of the correct type AND where `repr(Node)` == `DummyNode.name`.
109+
"""
110+
111+
name: str
112+
nodetype: type
113+
parent: Self | None = None
114+
"""Currently always None but required to avoid attribute errors if type checking Union[pytest.Node,_DummNode]"""
115+
116+
def __eq__(self, other: pytest.Item | pytest.Collector | Self) -> bool:
117+
try:
118+
samename = self.name == other.name
119+
sametype = self.nodetype == other.nodetype
120+
except AttributeError:
121+
samename = self.name == repr(other)
122+
sametype = self.nodetype is type(other)
123+
return samename and sametype
124+
125+
def __repr__(self) -> str:
126+
return f"{self.name} ({self.nodetype})"
127+
128+
def __init__(
129+
self,
130+
*_, # noqa: ANN002
131+
node: pytest.Item | pytest.Collector | _DummyNode,
132+
children: list[CollectionTree] | None,
133+
):
134+
"""Do not directly initiatise a CollectionTree, use the constructors `from_items()` or `from_dict()` instead."""
135+
self.children = children
136+
"""
137+
either:
138+
- if node is `pytest.Collector`: a `list[CollectionTree]` of child nodes
139+
- if node is `pytest.Item`: `None`
140+
"""
141+
self.node = node
142+
"""The actual collected node."""
143+
144+
def __eq__(self, other: Self) -> bool:
145+
"""CollectionTrees are equal if their children and node attributes are equal."""
146+
try:
147+
other_children = other.children
148+
other_node = other.node
149+
except AttributeError:
150+
return NotImplemented
151+
return self.children == other_children and self.node == other_node
152+
153+
def __repr__(self) -> str:
154+
"""Indented, multiline representation of the tree to simplify interpreting test failures."""
155+
if self.children is None:
156+
children_repr = ""
157+
else:
158+
children_repr = indent("\n".join(repr(child).rstrip() for child in self.children), " ")
159+
return f"{self.node!r}\n{children_repr}\n"

tests/assets/module.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#ruff: noqa
2+
# This cell sets some global variables
3+
4+
x = 1
5+
y = 2
6+
7+
x + y
8+
9+
# Define a function
10+
11+
def adder(a, b):
12+
return a+b
13+
14+
def test_adder():
15+
assert adder(1,2) == 3
16+
17+
def test_globals():
18+
assert x == 1
19+
20+
def another_function(*args):
21+
return args

tests/assets/othermodule.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#ruff: noqa
2+
# This cell sets some global variables
3+
4+
x = 1
5+
y = 2
6+
7+
x + y
8+
9+
# Define a function
10+
11+
def adder(a, b):
12+
return a+b
13+
14+
def test_adder():
15+
assert adder(1,2) == 3
16+
17+
def test_globals():
18+
assert x == 1
19+
20+
def another_function(*args):
21+
return args

tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pytest_plugins = ["pytester"]

0 commit comments

Comments
 (0)