diff --git a/.gitignore b/.gitignore index 3cd7a21..2a0e6fd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,180 @@ -dist/* +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +/build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +/py/**/lib/** +!/py/**/lib/**.py +!/py/**/lib/**.pyx +lib64/ +parts/ +/py/**/sdist/** +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + bricoler.json .claude + diff --git a/pyproject.toml b/pyproject.toml index 172475d..0336277 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,9 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ] @@ -26,6 +29,16 @@ dependencies = [ "pexpect", ] +[project.optional-dependencies] +coverage = [ + "coverage~=7.0", + "pytest-cov>=5.0.0" +] +test = [ + "pytest~=8.0.0", + "pytest-timeout~=2.0.0" +] + [project.scripts] bricoler = "bricoler.bricoler:main" @@ -49,9 +62,10 @@ check = "mypy --install-types --non-interactive {args:src/bricoler tests}" source_pkgs = ["bricoler", "tests"] branch = true parallel = true +source = ["src/"] [tool.coverage.paths] -bricoler = ["src/bricoler", "*/bricoler/src/bricoler"] +bricoler = ["*/src/bricoler", "*/bricoler/src/bricoler"] tests = ["tests", "*/bricoler/tests"] [tool.coverage.report] @@ -60,3 +74,69 @@ exclude_lines = [ "if __name__ == .__main__.:", "if TYPE_CHECKING:", ] + +[tool.pytest.ini_options] +timeout = 10 + +[tool.ruff] +target-version = "py310" +line-length = 88 +exclude = [ + "build", + "dist" +] +lint.select = [ + "ANN", + "B", + "D", + "E", + "F", + "I", + "PERF", + "PLW", + "PYI", + "RUF", + "S", + "W" +] +lint.ignore = [ + "D107", # Document __init__ nags. +] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = [ + "S" +] + +[tool.tox] +legacy_tox_ini = """ +[tox] +min_version = 4.0 +env_list = + coverage + py310 + py311 + py312 + py313 + py314 + +[testenv] +extras = + test +commands = + {envpython} -m pytest {posargs:-v tests} + +[testenv:coverage] +extras = + coverage + test +commands = + {envpython} -m pytest --cov=bricoler --cov-report=term --cov-report=html \ + {posargs:-vv tests} + +[testenv:type] +deps = + mypy +commands = + {envpython} -m mypy {posargs:src} +""" diff --git a/src/bricoler/bricoler.py b/src/bricoler/bricoler.py index 9775073..63ebd5b 100644 --- a/src/bricoler/bricoler.py +++ b/src/bricoler/bricoler.py @@ -14,7 +14,7 @@ import sys import textwrap import time -from enum import Enum +from enum import StrEnum from importlib import resources from pathlib import Path from typing import Dict, List, Optional, Tuple, Type, Union @@ -30,11 +30,11 @@ class KyuaDB: SCHEMA_VERSION = 3 - class Result(Enum): - PASSED = 'passed' - FAILED = 'failed' - SKIPPED = 'skipped' - BROKEN = 'broken' + class Result(StrEnum): + PASSED = "passed" + FAILED = "failed" + SKIPPED = "skipped" + BROKEN = "broken" def __init__(self, path: Path): self.path = path @@ -49,13 +49,14 @@ def __init__(self, path: Path): @functools.cache def _results(self, restype: Result) -> List[str]: cursor = self.conn.cursor() - cursor.execute(f""" + args = (restype.value, ) + cursor.execute(""" SELECT tp.relative_path, tc.name FROM test_results tr JOIN test_cases tc ON tr.test_case_id = tc.test_case_id JOIN test_programs tp ON tc.test_program_id = tp.test_program_id - WHERE tr.result_type = '{restype.value}' - """) + WHERE tr.result_type = ? + """, args) results = cursor.fetchall() return [f"{row[0]}:{row[1]}" for row in results] @@ -97,7 +98,7 @@ def get___FreeBSD_version(self) -> int: ) def make(self, args: List[str], **kwargs): - cmd = ['make', '-C', self.path.resolve()] + args + cmd = ['make', '-C', self.path.resolve(), *args] # Don't skip the command if we need to capture output. skip = self._no_cmds and not kwargs.get('capture_output', False) return run_cmd(cmd, skip=skip, **kwargs) @@ -308,9 +309,9 @@ class FreeBSDPkgBaseBuildTask(FreeBSDSrcBuildTask): make_targets = "buildworld buildkernel packages" -class FreeBSDVMImageFilesystem(Enum): - UFS = 'ufs' - ZFS = 'zfs' +class FreeBSDVMImageFilesystem(StrEnum): + UFS = "ufs" + ZFS = "zfs" class FreeBSDVMImageTask(Task): @@ -603,7 +604,7 @@ def pkg_cmd(*args, **kwargs): "-t", "ffs", "-Z", "-o", "softupdates=1", - "-o" "version=2" + "-o", "version=2" ] else: makefs_cmd += [ @@ -975,7 +976,8 @@ def run(self, ctx): "-j", str(self.parallelism), "-r", "/root/kyua.db", "-o", "/root/kyua-report.txt", - ] + self.tests.split() + *self.tests.split(), + ] vm.sendcmd(cmd) vm.wait_for_prompt(timeout=10*3600) @@ -994,7 +996,7 @@ def run(self, ctx): except FreeBSDVM.PanicException as e: if self.gdb_on_panic: self._gdb("-ex", f"thread {e.cpuid + 1}") - raise e + raise return { 'report_db_path': report_db_path, 'report_txt_path': report_txt_path, @@ -1701,7 +1703,7 @@ def run(self, ctx): except FreeBSDVM.PanicException as e: if sys.stdin.isatty(): self._gdb("-ex", f"thread {e.cpuid + 1}") - raise e + raise return outputs diff --git a/src/bricoler/git.py b/src/bricoler/git.py index 0bd52c0..9a21af9 100644 --- a/src/bricoler/git.py +++ b/src/bricoler/git.py @@ -45,7 +45,7 @@ def __init__( def git(self, cmd: List[str], *args, **kwargs): if not self.path: raise ValueError("Repository has not been cloned yet") - return run_cmd(['git', '-C', self.path] + cmd, *args, **kwargs) + return run_cmd(['git', '-C', self.path, *cmd], *args, **kwargs) def checked_out_branch(self) -> str: return self.git(["rev-parse", "--abbrev-ref", "HEAD"], capture_output=True).stdout.decode().strip() diff --git a/src/bricoler/util.py b/src/bricoler/util.py index 98979b5..0c82045 100644 --- a/src/bricoler/util.py +++ b/src/bricoler/util.py @@ -11,7 +11,7 @@ import subprocess import sys from contextlib import contextmanager -from enum import Enum +from enum import auto, IntEnum from pathlib import Path from typing import Any, Dict, List, Optional, Tuple @@ -43,19 +43,19 @@ def send(self, mail_to: str, mail_from: str): ) -class ANSIColour(Enum): - BLACK = 30, - RED = 31, - GREEN = 32, - YELLOW = 33, - BLUE = 34, - MAGENTA = 35, - CYAN = 36, - WHITE = 37, +class ANSIColour(IntEnum): + BLACK = 30 + RED = auto() + GREEN = auto() + YELLOW = auto() + BLUE = auto() + MAGENTA = auto() + CYAN = auto() + WHITE = auto() def colour(text: str, colour: ANSIColour) -> str: - return f"\033[{colour.value[0]}m{text}\033[0m" + return f"\033[{colour.value}m{text}\033[0m" @contextmanager diff --git a/src/bricoler/vm.py b/src/bricoler/vm.py index 8db13d7..fe94a44 100644 --- a/src/bricoler/vm.py +++ b/src/bricoler/vm.py @@ -9,7 +9,7 @@ import sys import uuid from abc import abstractmethod -from enum import Enum +from enum import IntEnum, StrEnum from pathlib import Path from typing import Any, Dict, List, Optional, Tuple, Union @@ -32,7 +32,8 @@ def run_cmd(self, cmd: List[str] = [], **kwargs): "-p", str(self.port), "-i", str(self.key), f"root@{self.addr}", - ] + cmd + *cmd, + ] return run_cmd(ssh_cmd, check_result=True, **kwargs) def scp_from(self, src: Path, dst: Path): @@ -64,22 +65,22 @@ def select(self, d: Dict[str, str], default=None) -> str: return val -class VMHypervisor(Enum): - BHYVE = 'bhyve' - QEMU = 'qemu' - RVVM = 'rvvm' +class VMHypervisor(StrEnum): + BHYVE = "bhyve" + QEMU = "qemu" + RVVM = "rvvm" class VMRun: - class BlockDriver(Enum): - VIRTIO = 1, - AHCI = 2, - NVME = 3, + class BlockDriver(IntEnum): + VIRTIO = 1 + AHCI = 2 + NVME = 3 - class NetworkDriver(Enum): - VIRTIO = 1, - E1000 = 2, - NONE = 3, + class NetworkDriver(IntEnum): + VIRTIO = 1 + E1000 = 2 + NONE = 3 def __init__( self, @@ -114,11 +115,11 @@ def ssh_handle(self): class BhyveRun(VMRun): - class PrivModel(Enum): - INVALID = 1, # Cannot run bhyve. - UNPRIV = 2, # Can run bhyve with current privileges. - MDO = 3, # Can run bhyve with mdo(1). - SUDO = 4, # Can run bhyve with sudo. + class PrivModel(IntEnum): + INVALID = 1 # Cannot run bhyve. + UNPRIV = 2 # Can run bhyve with current privileges. + MDO = 3 # Can run bhyve with mdo(1). + SUDO = 4 # Can run bhyve with sudo. @functools.cache @staticmethod @@ -427,9 +428,9 @@ def boot_to_login(self): self.expect("login:", timeout=600) self.sendline("root") self.wait_for_prompt() - except self.PanicException as e: - e.args = (f"VM panicked during boot: {e.panicstr}",) - raise e + except self.PanicException as err: + err_msg = f"VM panicked during boot: {err.panicstr}" + raise self.PanicException(err_msg) from err def wait_for_prompt(self, **kwargs): self.expect("root@.*#", **kwargs) diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 0000000..57b6246 --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,33 @@ +import os +from pathlib import Path + +import pytest + +from bricoler import util + + +requires_freebsd = pytest.mark.skipif(os.uname().sysname.lower() != "freebsd", reason="Test must be run on FreeBSD") + + +def test_chdir(tmpdir): + """Test .util.chdir(..) context manager.""" + tmpdir_p = Path(tmpdir) + assert Path.cwd() != tmpdir_p + with util.chdir(tmpdir_p): + assert Path.cwd() == tmpdir_p + assert Path.cwd() != tmpdir_p + + +def test_colour(): + """Test .util.colour(..).""" + colour = util.ANSIColour.RED + text = "my fancy message" + retval = util.colour(text, colour) + assert retval == f"\033[{colour.value}m{text}\033[0m" + + +@requires_freebsd +def test_host_machine(): + """Test .util.host_machine().""" + + util.host_machine()