from __future__ import annotations
import locale
import os
import subprocess
from collections.abc import Iterable
from contextlib import contextmanager
from itertools import chain, combinations
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
from _pytest.mark.structures import MarkDecorator
from onyo.lib.consts import (
ANCHOR_FILE_NAME,
ASSET_DIR_FILE_NAME,
)
from onyo.lib.faker import OnyoProvider
from onyo.lib.git import GitRepo
from onyo.lib.inventory import Inventory
from onyo.lib.onyo import OnyoRepo
from onyo.lib.items import Item
if TYPE_CHECKING:
from typing import (
Generator,
List,
Tuple,
Type,
)
########################################
#
# general
#
########################################
[docs]
@pytest.fixture(scope="function", autouse=True)
def clean_env(request) -> None:
r"""Ensure that ``$EDITOR`` is unset.
Makes sure that the ``$EDITOR`` environment variable is not inherited from
the user environment nor other tests.
"""
try:
del os.environ['EDITOR']
except KeyError:
pass
[docs]
@pytest.fixture(scope="function", autouse=True)
def clean_locale(request) -> None:
r"""Ensure that the locale is set to 'en_US.UTF-8'.
Tests that involve sorting can be impacted by the locale. This sets it to a
known, static target.
"""
lc = 'en_US.UTF-8'
locale.setlocale(locale.LC_ALL, lc)
locale.setlocale(locale.LC_COLLATE, lc)
locale.setlocale(locale.LC_CTYPE, lc)
locale.setlocale(locale.LC_MESSAGES, lc)
locale.setlocale(locale.LC_MONETARY, lc)
locale.setlocale(locale.LC_NUMERIC, lc)
locale.setlocale(locale.LC_TIME, lc)
[docs]
def params(d: dict) -> MarkDecorator:
r"""Parameterize a dictionary with human-friendly names.
Allows for meaningful variant names to be printed to the CLI when <variable>
is not easily string-ify-able.
For example, to run tests with a variable ``variant`` with the value
``<variable>`` and ``<id>`` as the test ID::
{
"<id>": {"variant": <variable>},
...
}
"""
return pytest.mark.parametrize(
argnames=(argnames := sorted({k for v in d.values() for k in v.keys()})),
argvalues=[[v.get(k) for k in argnames] for v in d.values()],
ids=d.keys(),
)
########################################
#
# tmp_path
#
########################################
[docs]
@pytest.fixture(scope="class")
def tmp_path_class_scope(tmp_path_factory,
request):
r"""Scope the ``tmp_path`` parameter fixture for classes."""
yield tmp_path_factory.mktemp(request.node.name)
[docs]
@pytest.fixture(scope="module")
def tmp_path_module_scope(tmp_path_factory,
request):
r"""Scope the ``tmp_path`` parameter fixture for modules."""
yield tmp_path_factory.mktemp(request.node.name)
[docs]
@pytest.fixture(scope="session")
def tmp_path_session_scope(tmp_path_factory,
request):
r"""Scope the ``tmp_path`` parameter fixture for sessions."""
yield tmp_path_factory.mktemp(request.node.name)
########################################
#
# gitrepo
#
########################################
[docs]
class AnnotatedGitRepo(GitRepo):
r"""Annotate a ``GitRepo`` object to ease testing.
Populated files and directories are stored in ``.test_annotation``.
"""
[docs]
def __init__(self,
path: Path,
find_root: bool = False) -> None:
r"""Instantiate an ``AnnotatedGitRepo`` object with ``path`` as the root directory.
Parameters
----------
path
Absolute Path of a git repository.
find_root
Replace ``path`` with the results of :py:func:`onyo.lib.git.GitRepo.find_root`.
Thus any directory of a git repository can be passed as ``path``,
not just the repo root.
"""
super().__init__(path, find_root)
self.test_annotation = None
[docs]
@contextmanager
def fixture_gitrepo(tmp_path: Path,
request) -> Generator[AnnotatedGitRepo, None, None]:
r"""Yield an AnnotatedGitRepo object, populated from fixtures.
A fresh repository is created in a unique temporary directory. It is
populated via the following marker (which is then stored in
``.test_annotation`` for later reference by tests):
- ``gitrepo_contents()``
Parent directories of all items are automatically created.
Example markers::
gitrepo_contents((Path('.gitignore'), "dir_to_ignore/"),
(Path("dir_to_ignore/some.pdf"), "0xDEADBEEF"),
(Path("a/b/c/"), ""),
(Path("1/2/3/"), ""),
)
Example annotations::
for dir_path in onyorepo.test_annotation['directories']:
assert dir_path.is_dir() is True
"""
subprocess.run(['git', 'init', str(tmp_path)])
gr = AnnotatedGitRepo(tmp_path)
gr.test_annotation = {'files': [],
'directories': []}
m = request.node.get_closest_marker('gitrepo_contents')
if m:
for spec in list(m.args):
path = spec[0]
content = spec[1]
abs_path = (tmp_path / path)
abs_path.parent.mkdir(parents=True, exist_ok=True)
abs_path.write_text(content)
# TODO: Figure out what's needed for annotation.
# Including: paths absolute/relative?
gr.test_annotation['files'].append(abs_path)
gr.test_annotation['directories'].extend(gr.root / p for p in path.parents
if p != Path('.'))
subprocess.run(['git', 'add', '.'], cwd=gr.root) # leave .gitignored stuff uncommitted
subprocess.run(['git', 'commit', '-m', 'Test repo setup'], cwd=gr.root)
yield gr
[docs]
@pytest.fixture(scope='function', name='gitrepo')
def gitrepo_function_scope(tmp_path: Path,
request) -> Generator:
r"""Scope the ``gitrepo`` parameter fixture for functions."""
with fixture_gitrepo(tmp_path, request) as result:
yield result
[docs]
@pytest.fixture(scope='class')
def gitrepo_class_scope(tmp_path_class_scope: Path,
request) -> Generator:
r"""Scope the ``gitrepo`` parameter fixture for classes."""
with fixture_gitrepo(tmp_path_class_scope, request) as result:
yield result
[docs]
@pytest.fixture(scope='module')
def gitrepo_module_scope(tmp_path_module_scope: Path,
request) -> Generator:
r"""Scope the ``gitrepo`` parameter fixture for modules."""
with fixture_gitrepo(tmp_path_module_scope, request) as result:
yield result
[docs]
@pytest.fixture(scope='session')
def gitrepo_session_scope(tmp_path: Path,
request) -> Generator:
r"""Scope the ``gitrepo`` parameter fixture for sessions."""
with fixture_gitrepo(tmp_path, request) as result:
yield result
########################################
#
# onyorepo
#
########################################
[docs]
class AnnotatedOnyoRepo(OnyoRepo):
r"""Annotate an ``OnyoRepo`` object to ease testing.
Populated inventory items are stored in ``.test_annotation``.
"""
[docs]
def __init__(self,
path: Path,
init: bool = False,
find_root: bool = False) -> None:
r"""Instantiate an ``AnnotatedOnyoRepo`` object with ``path`` as the root directory.
Parameters
----------
path
Absolute path to the root of the Onyo Repository.
init
Initialize ``path`` as a git repo and create/populate the subdir
``.onyo/``. Cannot be used with ``find_root=True``.
find_root
Replace ``path`` with the results of :py:func:`onyo.lib.onyo.OnyoRepo.find_root`.
Thus any directory of a git repository can be passed as ``path``,
not just the repo root. Cannot be used with ``init==True``.
"""
super().__init__(path, init, find_root)
self.test_annotation = None
[docs]
@contextmanager
def fixture_onyorepo(gitrepo,
request) -> Generator[AnnotatedOnyoRepo, None, None]:
r"""Yield an AnnotatedOnyoRepo object, populated from fixtures.
A fresh repository is created in a unique temporary directory. It is
populated via these markers (which are then stored in ``.test_annotation``
for later reference by tests):
- ``inventory_assets()``
- ``inventory_dirs()``
- ``inventory_templates()``
Parent directories of all items are automatically created.
Example markers::
inventory_assets()
inventory_assets(Item(type="type", make="make", model="model", serial=1,
onyo.path.parent=Path("here/")),
Item(type="type", make="make", model="model", serial=2,
onyo.path.parent=Path("there/")),
)
inventory_dirs(Path('a/b/c/'),
Path('1/2/3/'),
)
inventory_templates()
inventory_templates((onyo.lib.consts.TEMPLATE_DIR / "generic" / "laptop",
"---\ntype: laptop\n"),
(onyo.lib.consts.TEMPLATE_DIR / "generic" / "display",
"---\ntype: display\n"),
)
Example annotations::
for asset_path in onyorepo.test_annotation['assets']:
assert onyorepo.is_asset_path(asset_path) is True
"""
from importlib import resources
from shutil import copytree
from onyo.lib.utils import deduplicate
onyo = AnnotatedOnyoRepo(gitrepo.root, init=True)
with resources.path("onyo.tests.templates") as p:
copytree(p, onyo.template_dir / "packaged_templates")
onyo.git.commit(onyo.template_dir, message="Add packaged test templates")
def get_resources_recursive(anchor: str) -> Generator[Path, None, None]:
for res in resources.files(anchor).iterdir():
if res.is_file():
yield Path(res.name)
else:
for p in get_resources_recursive(anchor + "." + res.name):
yield Path(res.name) / p
resource_template_files = []
for p in get_resources_recursive("onyo.tests.templates"):
if p.name == ANCHOR_FILE_NAME:
continue # TODO: Eventually we want to register plain dirs as templates as well.
# Requires adaption of a couple of tests, though.
if p.name == ASSET_DIR_FILE_NAME:
resource_template_files.append(onyo.template_dir / "packaged_templates" / p.parent)
continue
resource_template_files.append(onyo.template_dir / "packaged_templates" / p)
onyo.test_annotation = {'assets': [],
'dirs': [],
'templates': [onyo.template_dir / "laptop.example"] + resource_template_files,
'git': gitrepo}
to_commit = []
m = request.node.get_closest_marker('inventory_assets')
if m:
for spec in list(m.args):
spec['onyo.path.absolute'] = gitrepo.root / spec['onyo.path.relative']
implicit_dirs = [d for d in spec['onyo.path.absolute'].parents
if gitrepo.root in d.parents]
if spec.get('onyo.is.directory'):
implicit_dirs.append(spec['onyo.path.absolute'])
to_commit += onyo.mk_inventory_dirs(implicit_dirs)
onyo.test_annotation['dirs'].extend(implicit_dirs)
onyo.write_asset(spec)
onyo.test_annotation['assets'].append(spec)
to_commit.append(spec['onyo.path.absolute'])
m = request.node.get_closest_marker('inventory_dirs')
if m:
dirs = [gitrepo.root / p for p in list(m.args)]
new_anchors = onyo.mk_inventory_dirs(dirs)
to_commit += new_anchors
onyo.test_annotation['dirs'].extend(p.parent for p in new_anchors)
m = request.node.get_closest_marker('inventory_templates')
if m:
for spec in list(m.args):
path = spec[0]
content = spec[1]
abs_path = gitrepo.root / path
abs_path.parent.mkdir(parents=True, exist_ok=True)
abs_path.write_text(content)
onyo.test_annotation['templates'].append(abs_path)
to_commit.append(abs_path)
if onyo.test_annotation['dirs']:
onyo.test_annotation['dirs'] = deduplicate(onyo.test_annotation['dirs'])
if to_commit:
onyo.commit(deduplicate(to_commit), "onyorepo: setup") # pyre-ignore[6] - not None if `to_commit` is not None
with pytest.MonkeyPatch.context() as m:
m.chdir(gitrepo.root)
yield onyo
[docs]
@pytest.fixture(scope='function', name='onyorepo')
def onyorepo_function_scope(gitrepo,
request) -> Generator:
r"""Scope the ``onyorepo`` parameter fixture for functions."""
with fixture_onyorepo(gitrepo, request) as result:
yield result
[docs]
@pytest.fixture(scope='class')
def onyorepo_class_scope(gitrepo_class_scope,
request) -> Generator:
r"""Scope the ``onyorepo`` parameter fixture for classes."""
with fixture_onyorepo(gitrepo_class_scope, request) as result:
yield result
[docs]
@pytest.fixture(scope='module')
def onyorepo_module_scope(gitrepo_module_scope,
request) -> Generator:
r"""Scope the ``onyorepo`` parameter fixture for modules."""
with fixture_onyorepo(gitrepo_module_scope, request) as result:
yield result
[docs]
@pytest.fixture(scope='session')
def onyorepo_session_scope(gitrepo_session_scope,
request) -> Generator:
r"""Scope the ``onyorepo`` parameter fixture for sessions."""
with fixture_onyorepo(gitrepo_session_scope, request) as result:
yield result
########################################
#
# repo
#
########################################
[docs]
@contextmanager
def fixture_repo(tmp_path: Path,
request) -> Generator[OnyoRepo, None, None]:
r"""Yield an OnyoRepo object, populated from fixtures.
A fresh repository is created in a unique temporary directory. It is then
populated via these markers:
- ``repo_dirs()``
- ``repo_files()`` (parent directories of files are automatically created)
Example::
repo_dirs("a/b/c", "1/2/3")
repo_files("here/type_make_model.1", "there/type_make_model.2")
"""
repo_path = tmp_path
dirs = set()
files = set()
contents = list()
# initialize repo
repo_ = OnyoRepo(repo_path, init=True)
repo_.set_config("onyo.assets.name-format", "{type}_{make}_{model.name}.{serial}")
repo_.git.commit(repo_.onyo_config, message="Asset name config w/ dot")
# collect files to populate the repo
m = request.node.get_closest_marker('repo_files')
if m:
files = {(repo_path / x) for x in m.args}
# collect dirs to populate the repo
m = request.node.get_closest_marker('repo_dirs')
if m:
dirs = set(m.args)
# collect contents to populate the repo
m = request.node.get_closest_marker('repo_contents')
if m:
contents = list(m.args)
# collect files from contents list too
files |= {(repo_path / x[0]) for x in contents}
# collect dirs from files list too
dirs |= {x.parent for x in files if not x.parent.exists()}
# populate the repo
if dirs:
anchors = repo_.mk_inventory_dirs([repo_path / d for d in dirs])
repo_.commit(paths=anchors,
message="populate dirs for tests")
for i in files:
i.touch()
if files:
if contents:
for file in contents:
(repo_path / file[0]).write_text(file[1])
repo_.commit(paths=files,
message="populate files for tests")
with pytest.MonkeyPatch.context() as m:
m.chdir(repo_path)
yield repo_
[docs]
@pytest.fixture(scope='function', name='repo')
def repo_function_scope(tmp_path: Path,
request) -> Generator:
r"""Scope the ``repo`` parameter fixture for functions."""
with fixture_repo(tmp_path, request) as result:
yield result
[docs]
@pytest.fixture(scope='class')
def repo_class_scope(tmp_path_class_scope: Path,
request) -> Generator:
r"""Scope the ``repo`` parameter fixture for classes."""
with fixture_repo(tmp_path_class_scope, request) as result:
yield result
[docs]
@pytest.fixture(scope='module')
def repo_module_scope(tmp_path_module_scope: Path,
request) -> Generator:
r"""Scope the ``repo`` parameter fixture for modules."""
with fixture_repo(tmp_path_module_scope, request) as result:
yield result
[docs]
@pytest.fixture(scope='session')
def repo_session_scope(tmp_path_session_scope: Path,
request) -> Generator:
r"""Scope the ``repo`` parameter fixture for sessions."""
with fixture_repo(tmp_path_session_scope, request) as result:
yield result
########################################
#
# inventory
#
########################################
[docs]
@contextmanager
def fixture_inventory(repo: OnyoRepo) -> Generator[Inventory, None, None]:
r"""Yield a populated Inventory object.
The inventory is populated with the following directories:
- different/place/
- empty/
- somewhere/nested/
And the following asset:
- somewhere/nested/TYPE_MAKER_MODEL.SERIAL
"""
# TODO: This is currently not in line with `repo`, where files and dirs are defined differently.
# Paths to created items should be delivered somehow.
inventory = Inventory(repo=repo)
inventory.add_asset(Item(
dict(some_key="some_value",
type="TYPE",
make="MAKER",
model=dict(name="MODEL"),
serial="SERIAL",
other=1,
directory=repo.git.root / "somewhere" / "nested"),
repo=repo))
inventory.add_directory(Item(repo.git.root / 'empty', repo=inventory.repo))
inventory.add_directory(Item(repo.git.root / 'different' / 'place', repo=inventory.repo))
inventory.commit("First asset added")
yield inventory
[docs]
@pytest.fixture(scope='function', name='inventory')
def inventory_function_scope(repo: OnyoRepo) -> Generator:
r"""Scope the ``inventory`` parameter fixture for functions."""
with fixture_inventory(repo) as result:
yield result
[docs]
@pytest.fixture(scope='class')
def inventory_class_scope(repo_class_scope: OnyoRepo) -> Generator:
r"""Scope the ``inventory`` parameter fixture for classes."""
with fixture_inventory(repo_class_scope) as result:
yield result
[docs]
@pytest.fixture(scope='module')
def inventory_module_scope(repo_module_scope: OnyoRepo) -> Generator:
r"""Scope the ``inventory`` parameter fixture for modules."""
with fixture_inventory(repo_module_scope) as result:
yield result
[docs]
@pytest.fixture(scope='session')
def inventory_session_scope(repo_session_scope: OnyoRepo) -> Generator:
r"""Scope the ``inventory`` parameter fixture for sessions."""
with fixture_inventory(repo_session_scope) as result:
yield result
########################################
#
# helpers
#
########################################
[docs]
class Helpers:
r"""A collection of helper utilities for tests."""
[docs]
@staticmethod
def flatten(xs: Iterable) -> Generator:
r"""Yield a flattened Iterable.
Flatten a multidimensional list into a single dimension.
"""
for x in xs:
if isinstance(x, Iterable) and not isinstance(x, (str, bytes)):
yield from Helpers.flatten(x)
else:
yield x
[docs]
@staticmethod
def onyo_flags() -> List[List[List[str]] | List[str]]:
r"""Return a List of all top level flags."""
return [['-d', '--debug'],
[['-C', '/tmp'], ['--onyopath', '/tmp']],
]
[docs]
@staticmethod
def powerset(iterable: Iterable) -> chain[Tuple]:
r"""Yield the powerset.
Each subset is returned as its own Tuple. For example::
powerset([1,2,3]) --> () (1,) (2,) (3,) (1,2) (1,3) (2,3) (1,2,3)
"""
s = list(iterable)
return chain.from_iterable(combinations(s, r) for r in range(len(s) + 1))
[docs]
@contextmanager
def fixture_helpers() -> Generator[Type[Helpers], None, None]:
r"""Return a Helper object with various helper utilities.
See Also
--------
Helpers
"""
yield Helpers
[docs]
@pytest.fixture(scope='function', name='helpers')
def helpers_function_scope() -> Generator:
r"""Scope the ``helpers`` parameter fixture for functions."""
with fixture_helpers() as result:
yield result
[docs]
@pytest.fixture(scope='class')
def helpers_class_scope() -> Generator:
r"""Scope the ``helpers`` parameter fixture for classes."""
with fixture_helpers() as result:
yield result
[docs]
@pytest.fixture(scope='module')
def helpers_module_scope() -> Generator:
r"""Scope the ``helpers`` parameter fixture for modules."""
with fixture_helpers() as result:
yield result
[docs]
@pytest.fixture(scope='session')
def helpers_session_scope() -> Generator:
r"""Scope the ``helpers`` parameter fixture for sessions."""
with fixture_helpers() as result:
yield result
########################################
#
# ui
#
########################################
[docs]
@contextmanager
def fixture_ui(request) -> Generator:
r"""Configure :py:class:`onyo.lib.ui.UI`.
Applies values from a dict defined by the ``ui`` marker.
Supported keys are: ``'yes'``, ``'quiet'``, and ``'debug'``. All accept
booleans.
See Also
--------
onyo.lib.ui.UI
"""
from onyo.lib.ui import ui
m = request.node.get_closest_marker('ui')
if m:
ui.set_yes(m.args[0].get('yes', False))
ui.set_quiet(m.args[0].get('quiet', False))
ui.set_debug(m.args[0].get('debug', False))
yield
[docs]
@pytest.fixture(scope='function', autouse=True)
def ui_function_scope(request) -> Generator:
r"""Scope the ``ui`` marker for functions."""
with fixture_ui(request) as result:
yield result
[docs]
@pytest.fixture(scope='class', autouse=True)
def ui_class_scope(request) -> Generator:
r"""Scope the ``ui`` marker for classes."""
with fixture_ui(request) as result:
yield result
[docs]
@pytest.fixture(scope='module', autouse=True)
def ui_module_scope(request) -> Generator:
r"""Scope the ``ui`` marker for modules."""
with fixture_ui(request) as result:
yield result
[docs]
@pytest.fixture(scope='session', autouse=True)
def ui_session_scope(request) -> Generator:
r"""Scope the ``ui`` marker for sessions."""
with fixture_ui(request) as result:
yield result
########################################
#
# fake
#
########################################
[docs]
@contextmanager
def fixture_fake() -> Generator:
r"""Yield a Faker object with the Onyo provider loaded.
See Also
--------
onyo.lib.faker.OnyoProvider
"""
from faker import Faker
_fake = Faker()
_fake.add_provider(OnyoProvider)
yield _fake
[docs]
@pytest.fixture(scope='function', name='fake')
def fake_function_scope() -> Generator:
r"""Scope the ``fake`` parameter fixture for functions."""
with fixture_fake() as result:
yield result
[docs]
@pytest.fixture(scope='class')
def fake_class_scope() -> Generator:
r"""Scope the ``fake`` parameter fixture for classes."""
with fixture_fake() as result:
yield result
[docs]
@pytest.fixture(scope='module')
def fake_module_scope() -> Generator:
r"""Scope the ``fake`` parameter fixture for modules."""
with fixture_fake() as result:
yield result
[docs]
@pytest.fixture(scope='session')
def fake_session_scope() -> Generator:
r"""Scope the ``fake`` parameter fixture for sessions."""
with fixture_fake() as result:
yield result