|
1 | 1 | from __future__ import annotations
|
2 | 2 |
|
3 | 3 | import sys
|
| 4 | +import textwrap |
4 | 5 | from contextlib import ExitStack as does_not_raise # noqa: N813
|
5 | 6 | from pathlib import Path
|
6 | 7 | from pathlib import PurePosixPath
|
7 | 8 | from pathlib import PureWindowsPath
|
| 9 | +from types import ModuleType |
| 10 | +from typing import Any |
8 | 11 |
|
9 | 12 | import pytest
|
| 13 | +from _pytask.path import _insert_missing_modules |
| 14 | +from _pytask.path import _module_name_from_path |
10 | 15 | from _pytask.path import find_case_sensitive_path
|
11 | 16 | from _pytask.path import find_closest_ancestor
|
12 | 17 | from _pytask.path import find_common_ancestor
|
| 18 | +from _pytask.path import import_path |
13 | 19 | from _pytask.path import relative_to
|
14 | 20 |
|
15 | 21 |
|
@@ -117,3 +123,182 @@ def test_find_case_sensitive_path(tmp_path, path, existing_paths, expected):
|
117 | 123 |
|
118 | 124 | result = find_case_sensitive_path(tmp_path / path, sys.platform)
|
119 | 125 | assert result == tmp_path / expected
|
| 126 | + |
| 127 | + |
| 128 | +@pytest.fixture() |
| 129 | +def simple_module(tmp_path: Path) -> Path: |
| 130 | + fn = tmp_path / "_src/project/mymod.py" |
| 131 | + fn.parent.mkdir(parents=True) |
| 132 | + fn.write_text("def foo(x): return 40 + x") |
| 133 | + return fn |
| 134 | + |
| 135 | + |
| 136 | +def test_importmode_importlib(simple_module: Path, tmp_path: Path) -> None: |
| 137 | + """`importlib` mode does not change sys.path.""" |
| 138 | + module = import_path(simple_module, root=tmp_path) |
| 139 | + assert module.foo(2) == 42 # type: ignore[attr-defined] |
| 140 | + assert str(simple_module.parent) not in sys.path |
| 141 | + assert module.__name__ in sys.modules |
| 142 | + assert module.__name__ == "_src.project.mymod" |
| 143 | + assert "_src" in sys.modules |
| 144 | + assert "_src.project" in sys.modules |
| 145 | + |
| 146 | + |
| 147 | +def test_importmode_twice_is_different_module( |
| 148 | + simple_module: Path, tmp_path: Path |
| 149 | +) -> None: |
| 150 | + """`importlib` mode always returns a new module.""" |
| 151 | + module1 = import_path(simple_module, root=tmp_path) |
| 152 | + module2 = import_path(simple_module, root=tmp_path) |
| 153 | + assert module1 is not module2 |
| 154 | + |
| 155 | + |
| 156 | +def test_no_meta_path_found( |
| 157 | + simple_module: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path |
| 158 | +) -> None: |
| 159 | + """Even without any meta_path should still import module.""" |
| 160 | + monkeypatch.setattr(sys, "meta_path", []) |
| 161 | + module = import_path(simple_module, root=tmp_path) |
| 162 | + assert module.foo(2) == 42 # type: ignore[attr-defined] |
| 163 | + |
| 164 | + # mode='importlib' fails if no spec is found to load the module |
| 165 | + import importlib.util |
| 166 | + |
| 167 | + monkeypatch.setattr( |
| 168 | + importlib.util, "spec_from_file_location", lambda *args: None # noqa: ARG005 |
| 169 | + ) |
| 170 | + with pytest.raises(ImportError): |
| 171 | + import_path(simple_module, root=tmp_path) |
| 172 | + |
| 173 | + |
| 174 | +def test_importmode_importlib_with_dataclass(tmp_path: Path) -> None: |
| 175 | + """ |
| 176 | + Ensure that importlib mode works with a module containing dataclasses (#373, |
| 177 | + pytest#7856). |
| 178 | + """ |
| 179 | + fn = tmp_path.joinpath("_src/project/task_dataclass.py") |
| 180 | + fn.parent.mkdir(parents=True) |
| 181 | + fn.write_text( |
| 182 | + textwrap.dedent( |
| 183 | + """ |
| 184 | + from dataclasses import dataclass |
| 185 | +
|
| 186 | + @dataclass |
| 187 | + class Data: |
| 188 | + value: str |
| 189 | + """ |
| 190 | + ) |
| 191 | + ) |
| 192 | + |
| 193 | + module = import_path(fn, root=tmp_path) |
| 194 | + Data: Any = module.Data # noqa: N806 |
| 195 | + data = Data(value="foo") |
| 196 | + assert data.value == "foo" |
| 197 | + assert data.__module__ == "_src.project.task_dataclass" |
| 198 | + |
| 199 | + |
| 200 | +def test_importmode_importlib_with_pickle(tmp_path: Path) -> None: |
| 201 | + """Ensure that importlib mode works with pickle (#373, pytest#7859).""" |
| 202 | + fn = tmp_path.joinpath("_src/project/task_pickle.py") |
| 203 | + fn.parent.mkdir(parents=True) |
| 204 | + fn.write_text( |
| 205 | + textwrap.dedent( |
| 206 | + """ |
| 207 | + import pickle |
| 208 | +
|
| 209 | + def _action(): |
| 210 | + return 42 |
| 211 | +
|
| 212 | + def round_trip(): |
| 213 | + s = pickle.dumps(_action) |
| 214 | + return pickle.loads(s) |
| 215 | + """ |
| 216 | + ) |
| 217 | + ) |
| 218 | + |
| 219 | + module = import_path(fn, root=tmp_path) |
| 220 | + round_trip = module.round_trip |
| 221 | + action = round_trip() |
| 222 | + assert action() == 42 |
| 223 | + |
| 224 | + |
| 225 | +def test_importmode_importlib_with_pickle_separate_modules(tmp_path: Path) -> None: |
| 226 | + """ |
| 227 | + Ensure that importlib mode works can load pickles that look similar but are |
| 228 | + defined in separate modules. |
| 229 | + """ |
| 230 | + fn1 = tmp_path.joinpath("_src/m1/project/task.py") |
| 231 | + fn1.parent.mkdir(parents=True) |
| 232 | + fn1.write_text( |
| 233 | + textwrap.dedent( |
| 234 | + """ |
| 235 | + import dataclasses |
| 236 | + import pickle |
| 237 | +
|
| 238 | + @dataclasses.dataclass |
| 239 | + class Data: |
| 240 | + x: int = 42 |
| 241 | + """ |
| 242 | + ) |
| 243 | + ) |
| 244 | + |
| 245 | + fn2 = tmp_path.joinpath("_src/m2/project/task.py") |
| 246 | + fn2.parent.mkdir(parents=True) |
| 247 | + fn2.write_text( |
| 248 | + textwrap.dedent( |
| 249 | + """ |
| 250 | + import dataclasses |
| 251 | + import pickle |
| 252 | +
|
| 253 | + @dataclasses.dataclass |
| 254 | + class Data: |
| 255 | + x: str = "" |
| 256 | + """ |
| 257 | + ) |
| 258 | + ) |
| 259 | + |
| 260 | + import pickle |
| 261 | + |
| 262 | + def round_trip(obj): |
| 263 | + s = pickle.dumps(obj) |
| 264 | + return pickle.loads(s) # noqa: S301 |
| 265 | + |
| 266 | + module = import_path(fn1, root=tmp_path) |
| 267 | + Data1 = module.Data # noqa: N806 |
| 268 | + |
| 269 | + module = import_path(fn2, root=tmp_path) |
| 270 | + Data2 = module.Data # noqa: N806 |
| 271 | + |
| 272 | + assert round_trip(Data1(20)) == Data1(20) |
| 273 | + assert round_trip(Data2("hello")) == Data2("hello") |
| 274 | + assert Data1.__module__ == "_src.m1.project.task" |
| 275 | + assert Data2.__module__ == "_src.m2.project.task" |
| 276 | + |
| 277 | + |
| 278 | +def test_module_name_from_path(tmp_path: Path) -> None: |
| 279 | + result = _module_name_from_path(tmp_path / "src/project/task_foo.py", tmp_path) |
| 280 | + assert result == "src.project.task_foo" |
| 281 | + |
| 282 | + # Path is not relative to root dir: use the full path to obtain the module name. |
| 283 | + result = _module_name_from_path(Path("/home/foo/task_foo.py"), Path("/bar")) |
| 284 | + assert result == "home.foo.task_foo" |
| 285 | + |
| 286 | + |
| 287 | +def test_insert_missing_modules( |
| 288 | + monkeypatch: pytest.MonkeyPatch, tmp_path: Path |
| 289 | +) -> None: |
| 290 | + monkeypatch.chdir(tmp_path) |
| 291 | + # Use 'xxx' and 'xxy' as parent names as they are unlikely to exist and |
| 292 | + # don't end up being imported. |
| 293 | + modules = {"xxx.project.foo": ModuleType("xxx.project.foo")} |
| 294 | + _insert_missing_modules(modules, "xxx.project.foo") |
| 295 | + assert sorted(modules) == ["xxx", "xxx.project", "xxx.project.foo"] |
| 296 | + |
| 297 | + mod = ModuleType("mod", doc="My Module") |
| 298 | + modules = {"xxy": mod} |
| 299 | + _insert_missing_modules(modules, "xxy") |
| 300 | + assert modules == {"xxy": mod} |
| 301 | + |
| 302 | + modules = {} |
| 303 | + _insert_missing_modules(modules, "") |
| 304 | + assert not modules |
0 commit comments