Skip to content

Instantly share code, notes, and snippets.

@a-recknagel
Last active January 15, 2024 12:11
Show Gist options
  • Save a-recknagel/82c7aca6daf0fdb21fc6d54490d2bbeb to your computer and use it in GitHub Desktop.
Save a-recknagel/82c7aca6daf0fdb21fc6d54490d2bbeb to your computer and use it in GitHub Desktop.
Pseudo installer fixture for pytest
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