Skip to content

Commit 42966a3

Browse files
authored
Take a common ancestor if closest not available to shorten paths. (#76)
1 parent d7903e9 commit 42966a3

8 files changed

+252
-109
lines changed

docs/changes.rst

+7
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ all releases are available on `PyPI <https://pypi.org/project/pytask>`_ and
77
`Anaconda.org <https://anaconda.org/conda-forge/pytask>`_.
88

99

10+
0.0.14 - 2021-xx-xx
11+
-------------------
12+
13+
- :gh:`76` fixes :gh:`75` which reports a bug when a closest ancestor cannot be found to
14+
shorten node names in the CLI output. Instead a common ancestor is used.
15+
16+
1017
0.0.13 - 2021-03-09
1118
-------------------
1219

src/_pytask/nodes.py

+12-65
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
from _pytask.exceptions import NodeNotCollectedError
1818
from _pytask.exceptions import NodeNotFoundError
1919
from _pytask.mark import get_marks_from_obj
20+
from _pytask.path import find_closest_ancestor
21+
from _pytask.path import find_common_ancestor
22+
from _pytask.path import relative_to
2023
from _pytask.shared import find_duplicates
2124

2225

@@ -337,66 +340,6 @@ def _create_task_name(path: Path, base_name: str):
337340
return path.as_posix() + "::" + base_name
338341

339342

340-
def _relative_to(path: Path, source: Path, include_source: bool = True):
341-
"""Make a path relative to another path.
342-
343-
In contrast to :meth:`pathlib.Path.relative_to`, this function allows to keep the
344-
name of the source path.
345-
346-
Examples
347-
--------
348-
>>> from pathlib import Path
349-
>>> _relative_to(Path("folder", "file.py"), Path("folder")).as_posix()
350-
'folder/file.py'
351-
>>> _relative_to(Path("folder", "file.py"), Path("folder"), False).as_posix()
352-
'file.py'
353-
354-
"""
355-
return Path(source.name if include_source else "", path.relative_to(source))
356-
357-
358-
def _find_closest_ancestor(path: Path, potential_ancestors: List[Path]):
359-
"""Find the closest ancestor of a path.
360-
361-
In case a path is the path to the task file itself, we return the path.
362-
363-
.. note::
364-
365-
The check :meth:`pathlib.Path.is_file` only succeeds when the file exists. This
366-
must be true as otherwise an error is raised by :obj:`click` while using the
367-
cli.
368-
369-
Examples
370-
--------
371-
>>> from pathlib import Path
372-
>>> _find_closest_ancestor(Path("folder", "file.py"), [Path("folder")]).as_posix()
373-
'folder'
374-
375-
>>> paths = [Path("folder"), Path("folder", "subfolder")]
376-
>>> _find_closest_ancestor(Path("folder", "subfolder", "file.py"), paths).as_posix()
377-
'folder/subfolder'
378-
379-
"""
380-
closest_ancestor = None
381-
for ancestor in potential_ancestors:
382-
if ancestor == path:
383-
closest_ancestor = path
384-
break
385-
386-
# Paths can also point to files in which case we want to take the parent folder.
387-
if ancestor.is_file():
388-
ancestor = ancestor.parent
389-
390-
if ancestor in path.parents:
391-
if closest_ancestor is None or (
392-
len(path.relative_to(ancestor).parts)
393-
< len(path.relative_to(closest_ancestor).parts)
394-
):
395-
closest_ancestor = ancestor
396-
397-
return closest_ancestor
398-
399-
400343
def reduce_node_name(node, paths: List[Path]):
401344
"""Reduce the node name.
402345
@@ -407,17 +350,21 @@ def reduce_node_name(node, paths: List[Path]):
407350
path from one path in ``session.config["paths"]`` to the node.
408351
409352
"""
410-
ancestor = _find_closest_ancestor(node.path, paths)
353+
ancestor = find_closest_ancestor(node.path, paths)
411354
if ancestor is None:
412-
raise ValueError("A node must be defined in a child of 'paths'.")
413-
elif isinstance(node, MetaTask):
355+
try:
356+
ancestor = find_common_ancestor(node.path, *paths)
357+
except ValueError:
358+
ancestor = node.path.parents[-1]
359+
360+
if isinstance(node, MetaTask):
414361
if ancestor == node.path:
415362
name = _create_task_name(Path(node.path.name), node.base_name)
416363
else:
417-
shortened_path = _relative_to(node.path, ancestor)
364+
shortened_path = relative_to(node.path, ancestor)
418365
name = _create_task_name(shortened_path, node.base_name)
419366
elif isinstance(node, MetaNode):
420-
name = _relative_to(node.path, ancestor).as_posix()
367+
name = relative_to(node.path, ancestor).as_posix()
421368
else:
422369
raise ValueError(f"Unknown node {node} with type '{type(node)}'.")
423370

src/_pytask/path.py

+96
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""This module contains code to handle paths."""
2+
from pathlib import Path
3+
from pathlib import PurePath
4+
from typing import List
5+
from typing import Union
6+
7+
8+
def relative_to(
9+
path: Union[str, Path], source: Union[str, Path], include_source: bool = True
10+
) -> Union[str, Path]:
11+
"""Make a path relative to another path.
12+
13+
In contrast to :meth:`pathlib.Path.relative_to`, this function allows to keep the
14+
name of the source path.
15+
16+
Examples
17+
--------
18+
The default behavior of :mod:`pathlib` is to exclude the source path from the
19+
relative path.
20+
21+
>>> relative_to("folder/file.py", "folder", False).as_posix()
22+
'file.py'
23+
24+
To provide relative locations to users, it is sometimes more helpful to provide the
25+
source as an orientation.
26+
27+
>>> relative_to("folder/file.py", "folder").as_posix()
28+
'folder/file.py'
29+
30+
"""
31+
source_name = Path(source).name if include_source else ""
32+
return Path(source_name, Path(path).relative_to(source))
33+
34+
35+
def find_closest_ancestor(
36+
path: Union[str, Path], potential_ancestors: List[Union[str, Path]]
37+
) -> Path:
38+
"""Find the closest ancestor of a path.
39+
40+
In case a path is the path to the task file itself, we return the path.
41+
42+
.. note::
43+
44+
The check :meth:`pathlib.Path.is_file` only succeeds when the file exists. This
45+
must be true as otherwise an error is raised by :obj:`click` while using the
46+
cli.
47+
48+
Examples
49+
--------
50+
>>> from pathlib import Path
51+
>>> find_closest_ancestor(Path("folder", "file.py"), [Path("folder")]).as_posix()
52+
'folder'
53+
54+
>>> paths = [Path("folder"), Path("folder", "subfolder")]
55+
>>> find_closest_ancestor(Path("folder", "subfolder", "file.py"), paths).as_posix()
56+
'folder/subfolder'
57+
58+
"""
59+
closest_ancestor = None
60+
for ancestor in potential_ancestors:
61+
if ancestor == path:
62+
closest_ancestor = path
63+
break
64+
65+
# Paths can also point to files in which case we want to take the parent folder.
66+
if ancestor.is_file():
67+
ancestor = ancestor.parent
68+
69+
if ancestor in path.parents:
70+
if closest_ancestor is None or (
71+
len(path.relative_to(ancestor).parts)
72+
< len(path.relative_to(closest_ancestor).parts)
73+
):
74+
closest_ancestor = ancestor
75+
76+
return closest_ancestor
77+
78+
79+
def find_common_ancestor(*paths: Union[str, Path]) -> Path:
80+
"""Find a common ancestor of many paths."""
81+
paths = [path if isinstance(path, PurePath) else Path(path) for path in paths]
82+
83+
for path in paths:
84+
if not path.is_absolute():
85+
raise ValueError(
86+
f"Cannot find common ancestor for relative paths. {path} is relative."
87+
)
88+
89+
common_parents = set.intersection(*[set(path.parents) for path in paths])
90+
91+
if len(common_parents) == 0:
92+
raise ValueError("Paths have no common ancestor.")
93+
else:
94+
longest_parent = sorted(common_parents, key=lambda x: len(x.parts))[-1]
95+
96+
return longest_parent

tests/test_capture.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ def task_unicode():
218218

219219

220220
@pytest.mark.end_to_end
221-
@pytest.mark.xfail(reason="pytask cannot capture during collection.")
221+
@pytest.mark.xfail(strict=True, reason="pytask cannot capture during collection.")
222222
def test_collect_capturing(tmp_path, runner):
223223
source = """
224224
import sys

tests/test_nodes.py

+12-40
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@
1010
from _pytask.nodes import _convert_objects_to_node_dictionary
1111
from _pytask.nodes import _create_task_name
1212
from _pytask.nodes import _extract_nodes_from_function_markers
13-
from _pytask.nodes import _find_closest_ancestor
14-
from _pytask.nodes import _relative_to
1513
from _pytask.nodes import depends_on
1614
from _pytask.nodes import FilePathNode
1715
from _pytask.nodes import MetaNode
@@ -203,37 +201,6 @@ def test_create_task_name(path, name, expected):
203201
assert result == expected
204202

205203

206-
@pytest.mark.unit
207-
@pytest.mark.parametrize(
208-
"path, source, include_source, expected",
209-
[
210-
(Path("src/hello.py"), Path("src"), True, Path("src/hello.py")),
211-
(Path("src/hello.py"), Path("src"), False, Path("hello.py")),
212-
],
213-
)
214-
def test_relative_to(path, source, include_source, expected):
215-
result = _relative_to(path, source, include_source)
216-
assert result == expected
217-
218-
219-
@pytest.mark.unit
220-
@pytest.mark.parametrize(
221-
"path, potential_ancestors, expected",
222-
[
223-
(Path("src/task.py"), [Path("src"), Path("bld")], Path("src")),
224-
(Path("tasks/task.py"), [Path("src"), Path("bld")], None),
225-
(Path("src/ts/task.py"), [Path("src"), Path("src/ts")], Path("src/ts")),
226-
(Path("src/in.txt"), [Path("src/task_d.py")], Path("src")),
227-
(Path("src/task.py"), [Path("src/task.py")], Path("src/task.py")),
228-
],
229-
)
230-
def test_find_closest_ancestor(monkeypatch, path, potential_ancestors, expected):
231-
# Ensures that files are detected by an existing suffix not if they also exist.
232-
monkeypatch.setattr("_pytask.nodes.pathlib.Path.is_file", lambda x: bool(x.suffix))
233-
result = _find_closest_ancestor(path, potential_ancestors)
234-
assert result == expected
235-
236-
237204
@attr.s
238205
class DummyTask(MetaTask):
239206
path = attr.ib()
@@ -259,19 +226,21 @@ class FalseNode:
259226
@pytest.mark.parametrize(
260227
"node, paths, expectation, expected",
261228
[
262-
(
229+
pytest.param(
263230
FilePathNode.from_path(_ROOT.joinpath("src/module.py")),
264231
[_ROOT.joinpath("alternative_src")],
265-
pytest.raises(ValueError, match="A node must be"),
266-
None,
232+
does_not_raise(),
233+
"pytask/src/module.py",
234+
id="Common path found for FilePathNode not in 'paths' and 'paths'",
267235
),
268-
(
236+
pytest.param(
269237
FalseNode(_ROOT.joinpath("src/module.py")),
270238
[_ROOT.joinpath("src")],
271239
pytest.raises(ValueError, match="Unknown node"),
272240
None,
241+
id="throw error on unknown node type.",
273242
),
274-
(
243+
pytest.param(
275244
DummyTask(
276245
_ROOT.joinpath("top/src/module.py"),
277246
_ROOT.joinpath("top/src/module.py").as_posix() + "::task_func",
@@ -280,14 +249,16 @@ class FalseNode:
280249
[_ROOT.joinpath("top/src")],
281250
does_not_raise(),
282251
"src/module.py::task_func",
252+
id="make task name relative to 'paths'.",
283253
),
284-
(
254+
pytest.param(
285255
FilePathNode.from_path(_ROOT.joinpath("top/src/module.py")),
286256
[_ROOT.joinpath("top/src")],
287257
does_not_raise(),
288258
"src/module.py",
259+
id="make filepathnode relative to 'paths'.",
289260
),
290-
(
261+
pytest.param(
291262
PythonFunctionTask(
292263
"task_d",
293264
_ROOT.joinpath("top/src/module.py").as_posix() + "::task_d",
@@ -297,6 +268,7 @@ class FalseNode:
297268
[_ROOT.joinpath("top/src/module.py")],
298269
does_not_raise(),
299270
"module.py::task_d",
271+
id="shorten task name when task module is passed to 'paths'.",
300272
),
301273
],
302274
)

tests/test_parametrize.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def func(i):
4444

4545

4646
@pytest.mark.integration
47-
@pytest.mark.xfail(reason="Cartesian task product is disabled.")
47+
@pytest.mark.xfail(strict=True, reason="Cartesian task product is disabled.")
4848
def test_pytask_generate_tasks_1(session):
4949
@pytask.mark.parametrize("j", range(2))
5050
@pytask.mark.parametrize("i", range(2))
@@ -62,7 +62,7 @@ def func(i, j):
6262

6363

6464
@pytest.mark.integration
65-
@pytest.mark.xfail(reason="Cartesian task product is disabled.")
65+
@pytest.mark.xfail(strict=True, reason="Cartesian task product is disabled.")
6666
def test_pytask_generate_tasks_2(session):
6767
@pytask.mark.parametrize("j, k", itertools.product(range(2), range(2)))
6868
@pytask.mark.parametrize("i", range(2))
@@ -264,7 +264,7 @@ def task_func(i):
264264

265265

266266
@pytest.mark.end_to_end
267-
@pytest.mark.xfail(reason="Cartesian task product is disabled.")
267+
@pytest.mark.xfail(strict=True, reason="Cartesian task product is disabled.")
268268
def test_two_parametrize_w_ids(tmp_path):
269269
tmp_path.joinpath("task_dummy.py").write_text(
270270
textwrap.dedent(

0 commit comments

Comments
 (0)