Last active
January 15, 2024 12:11
-
-
Save a-recknagel/82c7aca6daf0fdb21fc6d54490d2bbeb to your computer and use it in GitHub Desktop.
Pseudo installer fixture for pytest
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from __future__ import annotations | |
import shutil | |
import sys | |
from pathlib import Path | |
from typing import Union | |
import pytest | |
SourceFileTree = dict[str, Union[str, Path, "SourceFileTree"]] | |
r"""The format you have to use to specify an installable source file tree. | |
While it is possible to "install" multiple packages by providing multiple keys on the | |
top level, it is custom to have a single top-level namespace to keep things managable. | |
Rules for the keys: | |
- Key values must conform to python variable naming rules, i.e. `[\w_][\w\d_]*`. | |
Good: `{"foo": ""}`, `{"_f_o_o_1": ""}`; Bad: `{"1_foo": ""}`, `{"foo-bar": ""}`, | |
`{"foo.py": ""}` | |
Rules for values: | |
- A string value means that its key is a module, and the value itself is just the | |
verbatim content of said module. | |
- A path object value also means that its key is a module, and the file at the path's | |
location is the content of the module. | |
- A dict value means that its key is a (sub)package, and the dictionary itself describes | |
its content. Thus, its keys and values need to conform to these SourceFileTree rules | |
as well. | |
""" | |
class Pip: | |
def __init__(self, monkeypatch, tmp_path): | |
self.monkeypatch = monkeypatch | |
self.tmp_path = tmp_path | |
self.installed: list[str] = [] | |
def install(self, source: SourceFileTree): | |
def create_files_from_dict(path: Path, files: SourceFileTree): | |
for file_name, file_content in files.items(): | |
if isinstance(file_content, str): | |
with open(path / f"{file_name}.py", "w") as f: | |
f.write(file_content) | |
elif isinstance(file_content, Path): | |
shutil.copy(file_content, path / f"{file_name}.py") | |
elif isinstance(file_content, dict): | |
package = path / file_name | |
package.mkdir() | |
create_files_from_dict(package, file_content) | |
else: | |
raise RuntimeError( | |
f"Bad format for {files=}, keys should be valid module names " | |
f"and values should be python file contents, a path object, " | |
f"or a recursion." | |
) | |
self.monkeypatch.syspath_prepend(str(self.tmp_path)) | |
create_files_from_dict(self.tmp_path, source) | |
self.installed.extend(source) | |
def _cleanup(self): | |
for name in self.installed: | |
for key in [*sys.modules.keys()]: | |
if key == name or key.startswith(f"{key}."): | |
del sys.modules[key] | |
@pytest.fixture() | |
def pip(monkeypatch, tmp_path): | |
"""Not actually pip. | |
But for testing purposes it's close enough, easier to clean up after, and a lot | |
faster. Note that the toplevel name in the source dict will end up being the | |
name of the importable. Take care that it doesn't clash with already existing second | |
or third party package names. | |
Example: | |
```python | |
from pathlib import Path | |
def test_package(pip): | |
pip.install( | |
{ | |
"foo": { # package name | |
"bar": { # sub-package | |
"baz": "var = 42" # module with content | |
}, | |
"qux": "from foo.bar.baz import var", # other module with content | |
"quux": Path("some/local/file.py"), # yet another module with | |
# its content copied from | |
} # some local file - let's | |
} # say "var = 43" | |
): | |
# will work | |
from foo import qux | |
assert qux.var == 42 | |
# will also work | |
from foo import quux | |
assert quux.var == 43 | |
def test_package_is_gone(): | |
# different tests can't see installs from other tests | |
with pytest.raises(ModuleNotFoundError): | |
import foo | |
``` | |
""" | |
_pip = Pip(monkeypatch, tmp_path) | |
yield _pip | |
_pip._cleanup() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment