From 30348e02456225ab16dce391a0b245df0bef4ba4 Mon Sep 17 00:00:00 2001 From: Enji Cooper Date: Wed, 6 May 2026 16:15:40 -0700 Subject: [PATCH 1/8] Officially support python 3.13/3.14 with this project Signed-off-by: Enji Cooper --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 172475d..a172d81 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", ] From 99f4e0783310c2245e2369fe2c4456e9034b4dba Mon Sep 17 00:00:00 2001 From: Enji Cooper Date: Wed, 6 May 2026 16:36:23 -0700 Subject: [PATCH 2/8] .gitignore: prepend the github provided .gitignore This ignores a lot more autogenerated paths which should not be checked in to :master. Signed-off-by: Enji Cooper --- .gitignore | 179 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 178 insertions(+), 1 deletion(-) 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 + From 54a1f23a977d0e32884890fbadea392738c8751f Mon Sep 17 00:00:00 2001 From: Enji Cooper Date: Wed, 20 May 2026 18:14:20 -0700 Subject: [PATCH 3/8] Resolve RUF005 issues RUF005 (`collection-literal-concatenation`) suggests that the `+` operator should not be used when concatenating collections as `+` is less efficient than using the `*` or `**` operators unpacking the relevant collections. Signed-off-by: Enji Cooper --- src/bricoler/bricoler.py | 5 +++-- src/bricoler/git.py | 2 +- src/bricoler/vm.py | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/bricoler/bricoler.py b/src/bricoler/bricoler.py index 9775073..0983a6a 100644 --- a/src/bricoler/bricoler.py +++ b/src/bricoler/bricoler.py @@ -97,7 +97,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) @@ -975,7 +975,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) 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/vm.py b/src/bricoler/vm.py index 8db13d7..6a02ba0 100644 --- a/src/bricoler/vm.py +++ b/src/bricoler/vm.py @@ -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): From 2e0b6fed3a0675abcc58118e34f23e577be5667e Mon Sep 17 00:00:00 2001 From: Enji Cooper Date: Wed, 20 May 2026 18:17:20 -0700 Subject: [PATCH 4/8] Re-raise exceptions using the `raise` instead of `raise e` The latter pattern mangles the traceback stack and should not be used. Signed-off-by: Enji Cooper --- src/bricoler/bricoler.py | 4 ++-- src/bricoler/vm.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/bricoler/bricoler.py b/src/bricoler/bricoler.py index 0983a6a..e384e26 100644 --- a/src/bricoler/bricoler.py +++ b/src/bricoler/bricoler.py @@ -995,7 +995,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, @@ -1702,7 +1702,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/vm.py b/src/bricoler/vm.py index 6a02ba0..9279579 100644 --- a/src/bricoler/vm.py +++ b/src/bricoler/vm.py @@ -428,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) From 2099bede82d89e3162182e75de25dd5b9819c47f Mon Sep 17 00:00:00 2001 From: Enji Cooper Date: Wed, 20 May 2026 18:28:15 -0700 Subject: [PATCH 5/8] Apply best practice when forming SQLite3 query As noted in the sqlite3 module documentation, string interpolation using %s, f-strings, etc, is discouraged because of potential security risks. In this case, there isn't an exploitable path, but applying the best-practice is wise here in case the pattern is copied to other places by accident and it becomes a true exploit concern. Signed-off-by: Enji Cooper --- src/bricoler/bricoler.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/bricoler/bricoler.py b/src/bricoler/bricoler.py index e384e26..6594df1 100644 --- a/src/bricoler/bricoler.py +++ b/src/bricoler/bricoler.py @@ -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] From 65e3505496eb56e68f4f95d21fa864d76d92473e Mon Sep 17 00:00:00 2001 From: Enji Cooper Date: Wed, 20 May 2026 18:48:08 -0700 Subject: [PATCH 6/8] Fix makefs(8) command Add missing comma between command arguments so the 2 string literals aren't pasted together. Signed-off-by: Enji Cooper --- src/bricoler/bricoler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bricoler/bricoler.py b/src/bricoler/bricoler.py index 6594df1..9b5bf16 100644 --- a/src/bricoler/bricoler.py +++ b/src/bricoler/bricoler.py @@ -604,7 +604,7 @@ def pkg_cmd(*args, **kwargs): "-t", "ffs", "-Z", "-o", "softupdates=1", - "-o" "version=2" + "-o", "version=2" ] else: makefs_cmd += [ From 661df5bd821a895e7ac03ad5130c328a16c110b8 Mon Sep 17 00:00:00 2001 From: Enji Cooper Date: Wed, 20 May 2026 18:56:28 -0700 Subject: [PATCH 7/8] Adjust enum module use to leverage IntEnum and StrEnum `IntEnum` and `StrEnum` provide certain facilities out of the box that the `Enum` class doesn't provide -- in particular, they can be treated like `int` and `str` in certain scenarios. Use `auto` while here to avoid hardcoding constants with `ANSIColour`. Finally, fix Enum definitions: trailing commas are not technically allowed when defining enums; python treats values with trailing commas like tuples instead of scalars. Signed-off-by: Enji Cooper --- src/bricoler/bricoler.py | 18 +++++++++--------- src/bricoler/util.py | 22 +++++++++++----------- src/bricoler/vm.py | 36 ++++++++++++++++++------------------ 3 files changed, 38 insertions(+), 38 deletions(-) diff --git a/src/bricoler/bricoler.py b/src/bricoler/bricoler.py index 9b5bf16..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 @@ -309,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): 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 9279579..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 @@ -65,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, @@ -115,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 From ac87cfea4655cdc7aab3ea8fc9696a84ba8ec117 Mon Sep 17 00:00:00 2001 From: Enji Cooper Date: Wed, 20 May 2026 20:08:41 -0700 Subject: [PATCH 8/8] Add some initial rudimentary tests and tox/ruff integration This helps provides the building blocks for improving on this tool. - ruff provides consistent linting capabilities. - tox helps with running automated checks/tests. I started with the `bricoler.utils` module because it's the most self-contained piece of code that can be easily tested. Other pieces of the tool are much more tightly coupled, and thus more difficult to test in isolation. Signed-off-by: Enji Cooper --- pyproject.toml | 79 +++++++++++++++++++++++++++++++++++++++++++++- tests/test_util.py | 33 +++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 tests/test_util.py diff --git a/pyproject.toml b/pyproject.toml index a172d81..0336277 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,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" @@ -52,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] @@ -63,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/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()