Repository: opendp/tumult-tools
Pull request #1: Create first version of nox-utils
Author: login: tmager
Created: 2025-05-13T00:02:18Z | Merged: 2025-05-14T02:09:19Z

This `nox_utils` is based on `nox_utils` as it existed in opendp/tumult-analytics@f534dd21c6d8d05dcb38fe151b480076337f156a, but it contains some significant changes:
* Sessions now use Poetry dependency groups to figure out what packages to install, rather than listing all of the packages explicitly. As a result, `nox_poetry` is no longer needed to ensure that the package versions nox installs match those in the Poetry lock. This should both allow for more per-package customization and minimize the number of code changes required in this repository.
* The main class for creating sessions has been renamed and its constructor has been significantly changed. Most notably, it now takes options directly as keyword arguments.
* `nox_utils` is now linted, and uses itself to set up its own noxfile.

Not all sessions that were previously available have been set up yet. So far lint, test, docs, and dependency audit sessions are available; more will be added over time, but this should cover basic development needs up until we need to make a release.


Diff:
diff --git a/nox-utils/.gitignore b/nox-utils/.gitignore
new file mode 100644
index 0000000..2a9f6f9
--- /dev/null
+++ b/nox-utils/.gitignore
@@ -0,0 +1,7 @@
+.venv*
+.nox/
+
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
diff --git a/nox-utils/README.md b/nox-utils/README.md
new file mode 100644
index 0000000..300af99
--- /dev/null
+++ b/nox-utils/README.md
@@ -0,0 +1,3 @@
+# Nox utilities
+
+A collection of common utilities and sessions for [`nox`](https://nox.thea.codes/en/stable/).
diff --git a/nox-utils/noxfile.py b/nox-utils/noxfile.py
new file mode 100644
index 0000000..3bef229
--- /dev/null
+++ b/nox-utils/noxfile.py
@@ -0,0 +1,23 @@
+from pathlib import Path
+
+import nox
+from nox import session as session
+from tmlt.nox_utils import SessionManager, install_group
+
+# Use uv for managing virtualenvs, if it's available. This significantly reduces
+# the overhead associated with using nox sessions in their own virtualenvs
+# versus running with --no-venv.
+nox.options.default_venv_backend = "uv|virtualenv"
+
+PACKAGE_NAME = "tmlt.nox_utils"
+"""Name of the package."""
+CWD = Path(".").resolve()
+
+sm = SessionManager(
+    PACKAGE_NAME, CWD,
+)
+sm.black()
+sm.isort()
+sm.mypy()
+sm.pylint()
+sm.pydocstyle()
diff --git a/nox-utils/poetry.lock b/nox-utils/poetry.lock
new file mode 100644
index 0000000..c495541
--- /dev/null
+++ b/nox-utils/poetry.lock
@@ -0,0 +1,523 @@
+# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
+
+[[package]]
+name = "argcomplete"
+version = "3.6.2"
+description = "Bash tab completion for argparse"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+    {file = "argcomplete-3.6.2-py3-none-any.whl", hash = "sha256:65b3133a29ad53fb42c48cf5114752c7ab66c1c38544fdf6460f450c09b42591"},
+    {file = "argcomplete-3.6.2.tar.gz", hash = "sha256:d0519b1bc867f5f4f4713c41ad0aba73a4a5f007449716b16f385f2166dc6adf"},
+]
+
+[package.extras]
+test = ["coverage", "mypy", "pexpect", "ruff", "wheel"]
+
+[[package]]
+name = "astroid"
+version = "3.3.10"
+description = "An abstract syntax tree for Python with inference support."
+optional = false
+python-versions = ">=3.9.0"
+groups = ["pylint"]
+files = [
+    {file = "astroid-3.3.10-py3-none-any.whl", hash = "sha256:104fb9cb9b27ea95e847a94c003be03a9e039334a8ebca5ee27dafaf5c5711eb"},
+    {file = "astroid-3.3.10.tar.gz", hash = "sha256:c332157953060c6deb9caa57303ae0d20b0fbdb2e59b4a4f2a6ba49d0a7961ce"},
+]
+
+[package.dependencies]
+typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""}
+
+[[package]]
+name = "attrs"
+version = "25.3.0"
+description = "Classes Without Boilerplate"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+    {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"},
+    {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"},
+]
+
+[package.extras]
+benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"]
+cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"]
+dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"]
+docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier"]
+tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"]
+tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""]
+
+[[package]]
+name = "black"
+version = "23.12.1"
+description = "The uncompromising code formatter."
+optional = false
+python-versions = ">=3.8"
+groups = ["black"]
+files = [
+    {file = "black-23.12.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0aaf6041986767a5e0ce663c7a2f0e9eaf21e6ff87a5f95cbf3675bfd4c41d2"},
+    {file = "black-23.12.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c88b3711d12905b74206227109272673edce0cb29f27e1385f33b0163c414bba"},
+    {file = "black-23.12.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a920b569dc6b3472513ba6ddea21f440d4b4c699494d2e972a1753cdc25df7b0"},
+    {file = "black-23.12.1-cp310-cp310-win_amd64.whl", hash = "sha256:3fa4be75ef2a6b96ea8d92b1587dd8cb3a35c7e3d51f0738ced0781c3aa3a5a3"},
+    {file = "black-23.12.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8d4df77958a622f9b5a4c96edb4b8c0034f8434032ab11077ec6c56ae9f384ba"},
+    {file = "black-23.12.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:602cfb1196dc692424c70b6507593a2b29aac0547c1be9a1d1365f0d964c353b"},
+    {file = "black-23.12.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c4352800f14be5b4864016882cdba10755bd50805c95f728011bcb47a4afd59"},
+    {file = "black-23.12.1-cp311-cp311-win_amd64.whl", hash = "sha256:0808494f2b2df923ffc5723ed3c7b096bd76341f6213989759287611e9837d50"},
+    {file = "black-23.12.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:25e57fd232a6d6ff3f4478a6fd0580838e47c93c83eaf1ccc92d4faf27112c4e"},
+    {file = "black-23.12.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d9e13db441c509a3763a7a3d9a49ccc1b4e974a47be4e08ade2a228876500ec"},
+    {file = "black-23.12.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d1bd9c210f8b109b1762ec9fd36592fdd528485aadb3f5849b2740ef17e674e"},
+    {file = "black-23.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:ae76c22bde5cbb6bfd211ec343ded2163bba7883c7bc77f6b756a1049436fbb9"},
+    {file = "black-23.12.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1fa88a0f74e50e4487477bc0bb900c6781dbddfdfa32691e780bf854c3b4a47f"},
+    {file = "black-23.12.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a4d6a9668e45ad99d2f8ec70d5c8c04ef4f32f648ef39048d010b0689832ec6d"},
+    {file = "black-23.12.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b18fb2ae6c4bb63eebe5be6bd869ba2f14fd0259bda7d18a46b764d8fb86298a"},
+    {file = "black-23.12.1-cp38-cp38-win_amd64.whl", hash = "sha256:c04b6d9d20e9c13f43eee8ea87d44156b8505ca8a3c878773f68b4e4812a421e"},
+    {file = "black-23.12.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3e1b38b3135fd4c025c28c55ddfc236b05af657828a8a6abe5deec419a0b7055"},
+    {file = "black-23.12.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4f0031eaa7b921db76decd73636ef3a12c942ed367d8c3841a0739412b260a54"},
+    {file = "black-23.12.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97e56155c6b737854e60a9ab1c598ff2533d57e7506d97af5481141671abf3ea"},
+    {file = "black-23.12.1-cp39-cp39-win_amd64.whl", hash = "sha256:dd15245c8b68fe2b6bd0f32c1556509d11bb33aec9b5d0866dd8e2ed3dba09c2"},
+    {file = "black-23.12.1-py3-none-any.whl", hash = "sha256:78baad24af0f033958cad29731e27363183e140962595def56423e626f4bee3e"},
+    {file = "black-23.12.1.tar.gz", hash = "sha256:4ce3ef14ebe8d9509188014d96af1c456a910d5b5cbf434a09fef7e024b3d0d5"},
+]
+
+[package.dependencies]
+click = ">=8.0.0"
+mypy-extensions = ">=0.4.3"
+packaging = ">=22.0"
+pathspec = ">=0.9.0"
+platformdirs = ">=2"
+tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
+typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""}
+
+[package.extras]
+colorama = ["colorama (>=0.4.3)"]
+d = ["aiohttp (>=3.7.4) ; sys_platform != \"win32\" or implementation_name != \"pypy\"", "aiohttp (>=3.7.4,!=3.9.0) ; sys_platform == \"win32\" and implementation_name == \"pypy\""]
+jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
+uvloop = ["uvloop (>=0.15.2)"]
+
+[[package]]
+name = "click"
+version = "8.1.8"
+description = "Composable command line interface toolkit"
+optional = false
+python-versions = ">=3.7"
+groups = ["black"]
+files = [
+    {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"},
+    {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "platform_system == \"Windows\""}
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+description = "Cross-platform colored terminal text."
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+groups = ["main", "black", "pylint"]
+files = [
+    {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+    {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+]
+markers = {main = "sys_platform == \"win32\"", black = "platform_system == \"Windows\"", pylint = "sys_platform == \"win32\""}
+
+[[package]]
+name = "colorlog"
+version = "6.9.0"
+description = "Add colours to the output of Python's logging module."
+optional = false
+python-versions = ">=3.6"
+groups = ["main"]
+files = [
+    {file = "colorlog-6.9.0-py3-none-any.whl", hash = "sha256:5906e71acd67cb07a71e779c47c4bcb45fb8c2993eebe9e5adcd6a6f1b283eff"},
+    {file = "colorlog-6.9.0.tar.gz", hash = "sha256:bfba54a1b93b94f54e1f4fe48395725a3d92fd2a4af702f6bd70946bdc0c6ac2"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+
+[package.extras]
+development = ["black", "flake8", "mypy", "pytest", "types-colorama"]
+
+[[package]]
+name = "dependency-groups"
+version = "1.3.1"
+description = "A tool for resolving PEP 735 Dependency Group data"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+    {file = "dependency_groups-1.3.1-py3-none-any.whl", hash = "sha256:51aeaa0dfad72430fcfb7bcdbefbd75f3792e5919563077f30bc0d73f4493030"},
+    {file = "dependency_groups-1.3.1.tar.gz", hash = "sha256:78078301090517fd938c19f64a53ce98c32834dfe0dee6b88004a569a6adfefd"},
+]
+
+[package.dependencies]
+packaging = "*"
+tomli = {version = "*", markers = "python_version < \"3.11\""}
+
+[package.extras]
+cli = ["tomli ; python_version < \"3.11\""]
+
+[[package]]
+name = "dill"
+version = "0.4.0"
+description = "serialize all of Python"
+optional = false
+python-versions = ">=3.8"
+groups = ["pylint"]
+files = [
+    {file = "dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049"},
+    {file = "dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0"},
+]
+
+[package.extras]
+graph = ["objgraph (>=1.7.2)"]
+profile = ["gprof2dot (>=2022.7.29)"]
+
+[[package]]
+name = "distlib"
+version = "0.3.9"
+description = "Distribution utilities"
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+    {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"},
+    {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"},
+]
+
+[[package]]
+name = "filelock"
+version = "3.18.0"
+description = "A platform independent file lock."
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+    {file = "filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de"},
+    {file = "filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2"},
+]
+
+[package.extras]
+docs = ["furo (>=2024.8.6)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"]
+testing = ["covdefaults (>=2.3)", "coverage (>=7.6.10)", "diff-cover (>=9.2.1)", "pytest (>=8.3.4)", "pytest-asyncio (>=0.25.2)", "pytest-cov (>=6)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.28.1)"]
+typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""]
+
+[[package]]
+name = "isort"
+version = "5.13.2"
+description = "A Python utility / library to sort Python imports."
+optional = false
+python-versions = ">=3.8.0"
+groups = ["isort", "pylint"]
+files = [
+    {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"},
+    {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"},
+]
+
+[package.extras]
+colors = ["colorama (>=0.4.6)"]
+
+[[package]]
+name = "mccabe"
+version = "0.7.0"
+description = "McCabe checker, plugin for flake8"
+optional = false
+python-versions = ">=3.6"
+groups = ["pylint"]
+files = [
+    {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
+    {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
+]
+
+[[package]]
+name = "mypy"
+version = "1.15.0"
+description = "Optional static typing for Python"
+optional = false
+python-versions = ">=3.9"
+groups = ["mypy"]
+files = [
+    {file = "mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13"},
+    {file = "mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559"},
+    {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b"},
+    {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3"},
+    {file = "mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b"},
+    {file = "mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828"},
+    {file = "mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f"},
+    {file = "mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5"},
+    {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e"},
+    {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c"},
+    {file = "mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f"},
+    {file = "mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f"},
+    {file = "mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd"},
+    {file = "mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f"},
+    {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464"},
+    {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee"},
+    {file = "mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e"},
+    {file = "mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22"},
+    {file = "mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445"},
+    {file = "mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d"},
+    {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5"},
+    {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036"},
+    {file = "mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357"},
+    {file = "mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf"},
+    {file = "mypy-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e601a7fa172c2131bff456bb3ee08a88360760d0d2f8cbd7a75a65497e2df078"},
+    {file = "mypy-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:712e962a6357634fef20412699a3655c610110e01cdaa6180acec7fc9f8513ba"},
+    {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95579473af29ab73a10bada2f9722856792a36ec5af5399b653aa28360290a5"},
+    {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f8722560a14cde92fdb1e31597760dc35f9f5524cce17836c0d22841830fd5b"},
+    {file = "mypy-1.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fbb8da62dc352133d7d7ca90ed2fb0e9d42bb1a32724c287d3c76c58cbaa9c2"},
+    {file = "mypy-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:d10d994b41fb3497719bbf866f227b3489048ea4bbbb5015357db306249f7980"},
+    {file = "mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e"},
+    {file = "mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43"},
+]
+
+[package.dependencies]
+mypy_extensions = ">=1.0.0"
+tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
+typing_extensions = ">=4.6.0"
+
+[package.extras]
+dmypy = ["psutil (>=4.0)"]
+faster-cache = ["orjson"]
+install-types = ["pip"]
+mypyc = ["setuptools (>=50)"]
+reports = ["lxml"]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.1.0"
+description = "Type system extensions for programs checked with the mypy type checker."
+optional = false
+python-versions = ">=3.8"
+groups = ["black", "mypy"]
+files = [
+    {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"},
+    {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"},
+]
+
+[[package]]
+name = "nox"
+version = "2025.5.1"
+description = "Flexible test automation."
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+    {file = "nox-2025.5.1-py3-none-any.whl", hash = "sha256:56abd55cf37ff523c254fcec4d152ed51e5fe80e2ab8317221d8b828ac970a31"},
+    {file = "nox-2025.5.1.tar.gz", hash = "sha256:2a571dfa7a58acc726521ac3cd8184455ebcdcbf26401c7b737b5bc6701427b2"},
+]
+
+[package.dependencies]
+argcomplete = ">=1.9.4,<4"
+attrs = ">=23.1"
+colorlog = ">=2.6.1,<7"
+dependency-groups = ">=1.1"
+packaging = ">=20.9"
+tomli = {version = ">=1", markers = "python_version < \"3.11\""}
+virtualenv = ">=20.14.1"
+
+[package.extras]
+tox-to-nox = ["importlib-resources ; python_version < \"3.9\"", "jinja2", "tox (>=4)"]
+uv = ["uv (>=0.1.6)"]
+
+[[package]]
+name = "packaging"
+version = "25.0"
+description = "Core utilities for Python packages"
+optional = false
+python-versions = ">=3.8"
+groups = ["main", "black"]
+files = [
+    {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
+    {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
+]
+
+[[package]]
+name = "pathspec"
+version = "0.12.1"
+description = "Utility library for gitignore style pattern matching of file paths."
+optional = false
+python-versions = ">=3.8"
+groups = ["black"]
+files = [
+    {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"},
+    {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.3.8"
+description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
+optional = false
+python-versions = ">=3.9"
+groups = ["main", "black", "pylint"]
+files = [
+    {file = "platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"},
+    {file = "platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc"},
+]
+
+[package.extras]
+docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"]
+test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"]
+type = ["mypy (>=1.14.1)"]
+
+[[package]]
+name = "pydocstyle"
+version = "6.3.0"
+description = "Python docstring style checker"
+optional = false
+python-versions = ">=3.6"
+groups = ["pydocstyle"]
+files = [
+    {file = "pydocstyle-6.3.0-py3-none-any.whl", hash = "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019"},
+    {file = "pydocstyle-6.3.0.tar.gz", hash = "sha256:7ce43f0c0ac87b07494eb9c0b462c0b73e6ff276807f204d6b53edc72b7e44e1"},
+]
+
+[package.dependencies]
+snowballstemmer = ">=2.2.0"
+tomli = {version = ">=1.2.3", optional = true, markers = "python_version < \"3.11\" and extra == \"toml\""}
+
+[package.extras]
+toml = ["tomli (>=1.2.3) ; python_version < \"3.11\""]
+
+[[package]]
+name = "pylint"
+version = "3.3.7"
+description = "python code static checker"
+optional = false
+python-versions = ">=3.9.0"
+groups = ["pylint"]
+files = [
+    {file = "pylint-3.3.7-py3-none-any.whl", hash = "sha256:43860aafefce92fca4cf6b61fe199cdc5ae54ea28f9bf4cd49de267b5195803d"},
+    {file = "pylint-3.3.7.tar.gz", hash = "sha256:2b11de8bde49f9c5059452e0c310c079c746a0a8eeaa789e5aa966ecc23e4559"},
+]
+
+[package.dependencies]
+astroid = ">=3.3.8,<=3.4.0.dev0"
+colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""}
+dill = [
+    {version = ">=0.2", markers = "python_version < \"3.11\""},
+    {version = ">=0.3.7", markers = "python_version >= \"3.12\""},
+    {version = ">=0.3.6", markers = "python_version == \"3.11\""},
+]
+isort = ">=4.2.5,<5.13 || >5.13,<7"
+mccabe = ">=0.6,<0.8"
+platformdirs = ">=2.2"
+tomli = {version = ">=1.1", markers = "python_version < \"3.11\""}
+tomlkit = ">=0.10.1"
+typing-extensions = {version = ">=3.10", markers = "python_version < \"3.10\""}
+
+[package.extras]
+spelling = ["pyenchant (>=3.2,<4.0)"]
+testutils = ["gitpython (>3)"]
+
+[[package]]
+name = "snowballstemmer"
+version = "3.0.1"
+description = "This package provides 32 stemmers for 30 languages generated from Snowball algorithms."
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*"
+groups = ["pydocstyle"]
+files = [
+    {file = "snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064"},
+    {file = "snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895"},
+]
+
+[[package]]
+name = "tomli"
+version = "2.2.1"
+description = "A lil' TOML parser"
+optional = false
+python-versions = ">=3.8"
+groups = ["main", "black", "mypy", "pydocstyle", "pylint"]
+markers = "python_version < \"3.11\""
+files = [
+    {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"},
+    {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"},
+    {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"},
+    {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"},
+    {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"},
+    {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"},
+    {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"},
+    {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"},
+    {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"},
+    {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"},
+    {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"},
+    {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"},
+    {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"},
+    {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"},
+    {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"},
+    {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"},
+    {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"},
+    {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"},
+    {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"},
+    {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"},
+    {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"},
+    {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"},
+    {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"},
+    {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"},
+    {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"},
+    {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"},
+    {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"},
+    {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"},
+    {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"},
+    {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"},
+    {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"},
+    {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"},
+]
+
+[[package]]
+name = "tomlkit"
+version = "0.13.2"
+description = "Style preserving TOML library"
+optional = false
+python-versions = ">=3.8"
+groups = ["pylint"]
+files = [
+    {file = "tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde"},
+    {file = "tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79"},
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.13.2"
+description = "Backported and Experimental Type Hints for Python 3.8+"
+optional = false
+python-versions = ">=3.8"
+groups = ["black", "mypy", "pylint"]
+files = [
+    {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"},
+    {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"},
+]
+markers = {black = "python_version < \"3.11\"", pylint = "python_version < \"3.11\""}
+
+[[package]]
+name = "virtualenv"
+version = "20.31.2"
+description = "Virtual Python Environment builder"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+    {file = "virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11"},
+    {file = "virtualenv-20.31.2.tar.gz", hash = "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af"},
+]
+
+[package.dependencies]
+distlib = ">=0.3.7,<1"
+filelock = ">=3.12.2,<4"
+platformdirs = ">=3.9.1,<5"
+
+[package.extras]
+docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
+test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""]
+
+[metadata]
+lock-version = "2.1"
+python-versions = ">=3.9"
+content-hash = "140b544a3684b6d3e91f7fd4d0d6fb87d190c74cde90cb4644b1027322a8738c"
diff --git a/nox-utils/pyproject.toml b/nox-utils/pyproject.toml
new file mode 100644
index 0000000..08c038b
--- /dev/null
+++ b/nox-utils/pyproject.toml
@@ -0,0 +1,133 @@
+[project]
+name = "tmlt.nox_utils"
+version = "0.0.0"
+description = ""
+authors = []
+readme = "README.md"
+requires-python = ">=3.9"
+dependencies = [
+    "nox>=2022.8.7",
+]
+
+[tool.poetry]
+requires-poetry = ">=2.0"
+packages = [{include = "tmlt", from="src"}]
+
+[tool.poetry.group.black.dependencies]
+black = "^23.3"
+
+[tool.poetry.group.isort.dependencies]
+isort = { version = "^5.11", extras = ["pyproject"] }
+
+[tool.poetry.group.mypy.dependencies]
+mypy = "^1.2"
+
+[tool.poetry.group.pydocstyle.dependencies]
+pydocstyle = { version = "^6.3", extras = ["toml"] }
+
+[tool.poetry.group.pylint.dependencies]
+pylint = "^3.2.5"
+
+[build-system]
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"
+
+[tool.black]
+force-exclude = "noxfile.py"
+
+[tool.isort]
+profile = "black"
+multi_line_output = 3
+include_trailing_comma = true
+force_grid_wrap = 0
+use_parentheses = true
+line_length = 88
+
+[tool.mypy]
+mypy_path = "$MYPY_CONFIG_FILE_DIR/src"
+explicit_package_bases = true
+follow_imports = "silent"
+ignore_missing_imports = true
+namespace_packages = true
+disallow_untyped_defs = true
+warn_redundant_casts = true
+warn_unused_ignores = true
+# TODO: Re-enable this and fix the resulting warnings
+# warn_unreachable = true
+
+[[tool.mypy.overrides]]
+module = "test.*"
+disallow_untyped_defs = false
+check_untyped_defs = true
+
+[tool.pylint.master]
+# See https://github.com/PyCQA/pylint/issues/1975#issuecomment-387924981
+extension-pkg-whitelist = ['numpy']
+load-plugins = ['pylint.extensions.docparams']
+# Only check param docs in docstrings that contain an Args: section.
+# Set to "no" to show docstrings missing argument documentation.
+accept-no-param-doc = true
+
+[tool.pylint.'MESSAGES CONTROL']
+enable = [
+  # Note: there is a false positive on 'useless-suppression' when you
+  # use 'disable=line-too-long' at the end of a docstring.
+  # See: https://github.com/pylint-dev/pylint/issues/8301
+  'useless-suppression'
+]
+# By default, informational rules like useless-suppression don't cause PyLint to
+# produce an error.
+fail-on = ['useless-suppression']
+disable = [
+  'arguments-differ',
+  'consider-using-f-string',
+  'duplicate-code',
+  'fixme',
+  'invalid-name',
+  'logging-format-interpolation',
+  'logging-fstring-interpolation',
+  'missing-raises-doc',
+  'missing-return-doc',
+  'no-else-return',
+  'super-init-not-called',
+  'too-few-public-methods',
+  'too-many-ancestors',
+  'too-many-arguments',
+  'too-many-branches',
+  'too-many-instance-attributes',
+  'too-many-lines',
+  'too-many-locals',
+  'too-many-positional-arguments',
+  'too-many-public-methods',
+  'too-many-return-statements',
+  'too-many-statements',
+  'unbalanced-tuple-unpacking',
+  'unnecessary-lambda-assignment',
+  'unsubscriptable-object',
+  'use-dict-literal',
+  # There are a lot of false positives for unsupported-binary-operation
+  # on Python 3.8 and 3.9;
+  # see: https://github.com/pylint-dev/pylint/issues/7381
+  'unsupported-binary-operation',
+  # black and isort group tmlt.core separately from tmlt.analytics,
+  # but pylint thinks they should both be grouped as 'tmlt'.
+  'ungrouped-imports',
+  'wrong-import-order',
+
+  # There are lots of nested methods in nox_utils where we don't care about docstrings
+  'missing-function-docstring'
+]
+
+[tool.pylint.FORMAT]
+max-line-length = 88
+
+[tool.pydocstyle]
+convention = "google"
+add-ignore = [
+  # `D200: One-line docstring should fit on one line with quotes`
+  # conflicts with pylint's `max-line-length`.
+  "D200",
+
+  # 'Missing docstring in public method' -- again, many places where it doesn't matter
+  "D102",
+]
diff --git a/nox-utils/src/tmlt/nox_utils/__init__.py b/nox-utils/src/tmlt/nox_utils/__init__.py
new file mode 100644
index 0000000..b223f4a
--- /dev/null
+++ b/nox-utils/src/tmlt/nox_utils/__init__.py
@@ -0,0 +1,4 @@
+"""A central, largely package-agnostic collection of nox utilities."""
+
+from ._dependencies import install_group, show_installed
+from ._session_manager import SessionManager
diff --git a/nox-utils/src/tmlt/nox_utils/_dependencies.py b/nox-utils/src/tmlt/nox_utils/_dependencies.py
new file mode 100644
index 0000000..2b2267d
--- /dev/null
+++ b/nox-utils/src/tmlt/nox_utils/_dependencies.py
@@ -0,0 +1,45 @@
+"""Utilities for managing and inspecting dependencies in nox environments."""
+
+from functools import wraps
+from typing import Any, Callable
+
+from nox import Session
+
+
+def install_group(
+    group: str,
+) -> Callable[[Callable[[Session], Any]], Callable[[Session], Any]]:
+    """Install the given Poetry dependency group.
+
+    Note that this does not install the root package and its dependencies, just
+    the contents of the given dependency group.
+    """
+
+    def decorator(f: Callable[[Session], Any]) -> Callable[[Session], Any]:
+        @wraps(f)
+        def inner(sess: Session) -> Any:
+            if not sess.virtualenv.is_sandboxed:
+                sess.log(
+                    "Not in a sandboxed environment, skipping package installation"
+                )
+                return f(sess)
+
+            sess.run_install(
+                "poetry", "install", "--only", group, "--no-root", external=True
+            )
+            return f(sess)
+
+        return inner
+
+    return decorator
+
+
+def show_installed(f: Callable[[Session], Any]) -> Callable[[Session], Any]:
+    """Print all installed packages in the current environment."""
+
+    @wraps(f)
+    def inner(sess: Session) -> Any:
+        sess.run("pip", "freeze", external=True)
+        return f(sess)
+
+    return inner
diff --git a/nox-utils/src/tmlt/nox_utils/_environment.py b/nox-utils/src/tmlt/nox_utils/_environment.py
new file mode 100644
index 0000000..8046c9b
--- /dev/null
+++ b/nox-utils/src/tmlt/nox_utils/_environment.py
@@ -0,0 +1,31 @@
+"""Utilities for inspecting the environment where nox is running."""
+
+import os
+import platform
+import subprocess
+
+from nox import Session
+
+
+def in_ci() -> bool:
+    """Return whether nox is running in a CI pipeline."""
+    return bool(os.environ.get("CI"))
+
+
+def num_cpus(sess: Session) -> int:
+    """Return the number of CPU cores on the current machine."""
+    try:
+        if platform.system() == "Darwin":
+            command = "sysctl -n hw.physicalcpu"
+        elif platform.system() == "Linux":
+            command = "nproc --all"
+        else:
+            sess.log(
+                f"Unable to detect CPU count on {platform.system()}, defaulting to 1"
+            )
+            return 1
+        cores = int(subprocess.check_output(command, shell=True).strip())
+    except (subprocess.CalledProcessError, ValueError) as e:
+        sess.warn(f"Error getting CPU count, defaulting to 1: {e}")
+        cores = 1
+    return cores
diff --git a/nox-utils/src/tmlt/nox_utils/_session_manager.py b/nox-utils/src/tmlt/nox_utils/_session_manager.py
new file mode 100644
index 0000000..169c216
--- /dev/null
+++ b/nox-utils/src/tmlt/nox_utils/_session_manager.py
@@ -0,0 +1,404 @@
+"""A central, largely package-agnostic collection of nox utilities.
+
+This module defines SessionManager, which takes in a collection of configuration
+options and exposes methods that can be called to generate nox sessions.
+"""
+
+import subprocess
+from functools import wraps
+from pathlib import Path
+from typing import Any, Callable, Optional, Union
+
+import nox
+from nox import Session, session
+
+from ._dependencies import install_group, show_installed
+from ._environment import in_ci, num_cpus
+
+
+class SessionManager:
+    """Class for creating common Nox sessions based on project-specific configuration.
+
+    To add a session to a particular noxfile, create an instance of this class
+    using your desired options, then call the methods corresponding to the
+    sessions you want.
+
+    This class leverages the fact that *any* function that is defined with the
+    ``@session`` decorator is picked up as a Nox session, no matter where it is
+    defined. By wrapping these definitions inside of class methods, we can
+    access the class attributes to do project-specific things while still
+    maintaining a common collection of sessions that can be reused across
+    projects, and can avoid needing boilerplate in the noxfile itself.
+    """
+
+    def __init__(
+        self,
+        package: str,
+        directory: Path,
+        *,
+        default_python_version: str = "3.9",
+        custom_build: Optional[Callable[[Session], None]] = None,
+        smoketest_script: Optional[str] = None,
+        parallel_tests: bool = True,
+        min_coverage: int = 80,
+        audit_versions: Optional[list[str]] = None,
+        audit_suppressions: Optional[list[str]] = None,
+    ) -> None:
+        """Configure options for generating nox sessions.
+
+        Args:
+            package: The name of the package, e.g. ``tmlt.core``.
+            directory: The root directory of the package.
+            default_python_version: The Python minor version that will be used by
+                default for most generated sessions. In general, this should be the
+                oldest supported Python version.
+            custom_build: A function that overrides the standard build
+                process. This function should take a nox Session as its only
+                argument, and have no return value. When the function returns,
+                wheel and a source distribution for the current version of the
+                package should exist in the ``dist/`` subdirectory of the
+                package directory.
+            smoketest_script: A Python program to execute as a smoke-test for an
+                installation of the package -- this should be the script
+                content, not the path to an external script. It is considered to
+                pass as long as executing the script with `python -c` doesn't
+                produce an error. If unset, defaults to just importing the package.
+            parallel_tests: Controls whether tests should be run in parallel.
+            min_coverage: The minimum coverage needed for the test/test-fast sessions
+                to pass.
+            audit_versions: A list of Python minor versions on which to audit package
+                dependencies for vulnerabilities. If unset, defaults to only
+                ``default_python_version``.
+            audit_suppressions: A list of vulnerability IDs to ignore.
+        """
+        self._package = package
+        self._package_version = (
+            subprocess.run(["poetry", "version", "-s"], capture_output=True, check=True)
+            .stdout.decode("utf-8")
+            .strip()
+        )
+        self._directory = directory
+        self._default_python_version = default_python_version
+        if custom_build is not None and not callable(custom_build):
+            raise ValueError(
+                f"Provided custom_build must be callable, but '{custom_build}' is not"
+            )
+        self._custom_build = custom_build
+        self._smoketest_script = smoketest_script or f"import {self._package}"
+        self._parallel_tests = parallel_tests
+        self._min_coverage = min_coverage
+        self._audit_versions = audit_versions or [default_python_version]
+        self._audit_suppressions = audit_suppressions or []
+
+    @property
+    def _code_dirs(self) -> list[Path]:
+        return [
+            p
+            for p in [
+                self._directory / "src",
+                self._directory / "test",
+            ]
+            if p.exists()
+        ]
+
+    def _build(self, sess: Session) -> None:
+        """Build sdists and wheels for the package.
+
+        If the build process for a package requires custom logic, pass the
+        "build" option to SessionBuilder, with the value being function taking
+        one argument (a nox Session) that builds the package. If this option is
+        not passed, the package is just built with `poetry build`.
+        """
+        if self._custom_build is not None:
+            sess.log("Using custom build function")
+            self._custom_build(sess)
+        else:
+            sess.run("poetry", "build", external=True)
+
+    def _current_wheel_available(self, sess: Session) -> bool:
+        temp_dir = sess.create_tmp()
+        package = f"{self._package}=={self._package_version}"
+        out = sess.run(
+            "pip",
+            "download",
+            package,
+            "--find-links",
+            f"{self._directory}/dist/",
+            "--only-binary",
+            self._package,
+            "-d",
+            temp_dir,
+            "--no-deps",
+            silent=True,
+            success_codes=[0, 1],
+            external=True,
+        )
+        assert out is not None, "sess.run should not return None when silent=True"
+        return "No matching distribution" not in out
+
+    def _install_package(self, f: Callable[[Session], Any]) -> Callable[[Session], Any]:
+        """Install the main package and its dependencies from Poetry lock."""
+
+        @wraps(f)
+        def inner(sess: Session) -> Any:
+            if not sess.virtualenv.is_sandboxed:
+                sess.log(
+                    "Not in a sandboxed environment, skipping package installation"
+                )
+                return f(sess)
+
+            # In the CI we want to use the wheel from the pipeline, but locally
+            # it's easier to let Poetry handle figuring out what to install and
+            # how to build it if needed.
+            if not in_ci():
+                sess.run_install("poetry", "install", "--only", "main", external=True)
+                return f(sess)
+
+            package = f"{self._package}=={self._package_version}"
+
+            if not self._current_wheel_available(sess):
+                sess.error(
+                    f"No wheel found for '{package}'; "
+                    "wheel must exist when running in CI"
+                )
+
+            sess.install(
+                package,
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "--no-deps",
+            )
+            return install_group("main")(f)(sess)
+
+        return inner
+
+    def _install_package_with_unlocked_deps(
+        self, f: Callable[[Session], Any]
+    ) -> Callable[[Session], Any]:
+        """Install the main package and its dependencies as resolved by pip.
+
+        Unlike _install_package, this method ignores the Poetry lock and relies
+        on pip to install whatever versions of dependencies it likes, simulating
+        how the package might be installed on a user's machine. Also unlike
+        _install_package, this method will only install from a built wheel in
+        dist/ -- locally, it will try to build a new wheel if it can't find one
+        -- and it will refuse to run with ``--no-venv``.
+        """
+
+        @wraps(f)
+        def inner(sess: Session) -> Any:
+            if not sess.virtualenv.is_sandboxed:
+                sess.error("Release checks cannot be run with --no-venv")
+
+            package = f"{self._package}=={self._package_version}"
+
+            if not self._current_wheel_available(sess):
+                if in_ci():
+                    sess.error(
+                        f"No wheel found for '{package}'; "
+                        "wheel must exist when running in CI"
+                    )
+                else:
+                    sess.log("No wheel found, building one")
+                    self._build(sess)
+
+            sess.install(
+                package,
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+            )
+            return f(sess)
+
+        return inner
+
+    def build(self) -> None:
+        @session(name="build", python=self._default_python_version)
+        def build(sess: Session) -> None:
+            """Build source distributions and wheels for this package."""
+            self._build(sess)
+
+    def black(self) -> None:
+        @session(name="black", tags=["lint"], python=self._default_python_version)
+        @install_group("black")
+        def black(sess: Session) -> None:
+            """Run black."""
+            check_flags = ["--check", "--diff"] if "--check" in sess.posargs else []
+            sess.run("black", *check_flags, *self._code_dirs)
+
+    def isort(self) -> None:
+        @session(name="isort", tags=["lint"], python=self._default_python_version)
+        @install_group("isort")
+        def isort(sess: Session) -> None:
+            """Run isort."""
+            check_flags = ["--check", "--diff"] if "--check" in sess.posargs else []
+            sess.run("isort", *check_flags, *self._code_dirs)
+
+    def mypy(self) -> None:
+        @session(name="mypy", tags=["lint"], python=self._default_python_version)
+        @self._install_package
+        @install_group("mypy")
+        def mypy(sess: Session) -> None:
+            """Run mypy."""
+            sess.run("mypy", *self._code_dirs)
+
+    def pylint(self) -> None:
+        @session(name="pylint", tags=["lint"], python=self._default_python_version)
+        @self._install_package
+        @install_group("pylint")
+        def pylint(sess: Session) -> None:
+            """Run pylint."""
+            sess.run("pylint", "--score=no", *self._code_dirs)
+
+    def pydocstyle(self) -> None:
+        @session(name="pydocstyle", tags=["lint"], python=self._default_python_version)
+        @install_group("pydocstyle")
+        def pydocstyle(sess: Session) -> None:
+            """Run pydocstyle."""
+            sess.run("pydocstyle", *self._code_dirs)
+
+    def smoketest(self) -> None:
+        @session(name="smoketest", tags=["test"], python=self._default_python_version)
+        @self._install_package
+        def smoketest(sess: Session) -> None:
+            """Run a simple smoke-test of the package."""
+            sess.run("python", "-c", self._smoketest_script)
+
+    def release_smoketest(self) -> None:
+        @session(
+            name="release-smoketest",
+            tags=["release"],
+            python=self._default_python_version,
+        )
+        @self._install_package_with_unlocked_deps
+        @show_installed
+        def release_smoketest(sess: Session) -> None:
+            """Run a simple smoke-test of the package."""
+            sess.run("python", "-c", self._smoketest_script)
+
+    def _test(
+        self,
+        sess: Session,
+        markers: str = "",
+        *,
+        min_coverage: Optional[int] = None,
+        extra_args: Optional[list[str]] = None,
+    ) -> None:
+        test_paths = self._code_dirs
+        extra_args = extra_args or []
+        if sess.posargs:
+            test_paths = []
+            extra_args.extend(sess.posargs)
+        args: list[Union[str, Path]] = [
+            "-r fEs",
+            "--verbose",
+            "--disable-warnings",
+            f"--junitxml={self._directory}/junit.xml",
+            "--durations=10",
+            f"--cov={self._package}",
+            "--cov-fail-under={}".format(
+                self._min_coverage if min_coverage is None else min_coverage
+            ),
+            "--cov-report=term",
+            f"--cov-report=html:{self._directory}/coverage/",
+            f"--cov-report=xml:{self._directory}/coverage.xml",
+            *extra_args,
+            *test_paths,
+        ]
+        cpus = num_cpus(sess)
+        if self._parallel_tests and cpus > 3:
+            args.append(f"--numprocesses={cpus//2}")
+        if markers:
+            args.extend(["-m", markers])
+
+        sess.run("pytest", *args)
+
+    def test(self) -> None:
+        @session(name="test", python=self._default_python_version)
+        @self._install_package
+        @install_group("test")
+        def test(sess: Session) -> None:
+            """Run all tests."""
+            self._test(sess)
+
+    def test_fast(self) -> None:
+        @session(name="test-fast", python=self._default_python_version)
+        @self._install_package
+        @install_group("test")
+        def test_fast(sess: Session) -> None:
+            """Run fast tests."""
+            self._test(sess, "not slow")
+
+    def test_slow(self) -> None:
+        @session(name="test-slow", python=self._default_python_version)
+        @self._install_package
+        @install_group("test")
+        def test_slow(sess: Session) -> None:
+            """Run slow tests."""
+            self._test(sess, "slow", min_coverage=0)
+
+    def test_doctest(self) -> None:
+        @session(
+            name="test-doctest", tags=["test"], python=self._default_python_version
+        )
+        @self._install_package
+        @install_group("test")
+        def test_doctest(sess: Session) -> None:
+            """Run tests on code examples in docstrings."""
+            self._test(sess, min_coverage=0, extra_args=["--doctest-modules"])
+
+    def _run_sphinx(self, sess: Session, builder: str) -> None:
+        sphinx_options = ["-n", "-W", "--keep-going"]
+        sess.run("sphinx-build", "doc/", "public/", f"-b={builder}", *sphinx_options)
+
+    def docs_linkcheck(self) -> None:
+        @session(
+            name="docs-linkcheck", tags=["docs"], python=self._default_python_version
+        )
+        @self._install_package
+        @install_group("docs")
+        def docs_linkcheck(sess: Session) -> None:
+            """Check links in documentation."""
+            self._run_sphinx(sess, "linkcheck")
+
+    def docs_doctest(self) -> None:
+        @session(
+            name="docs-doctest", tags=["docs"], python=self._default_python_version
+        )
+        @self._install_package
+        @install_group("docs")
+        @install_group("docs-examples")
+        def docs_linkcheck(sess: Session) -> None:
+            """Check code examples in documentation."""
+            self._run_sphinx(sess, "doctest")
+
+    def docs(self) -> None:
+        @session(name="docs", tags=["docs"], python=self._default_python_version)
+        @self._install_package
+        @install_group("docs")
+        def docs(sess: Session) -> None:
+            """Build HTML documentation."""
+            self._run_sphinx(sess, "html")
+
+    def audit(self) -> None:
+        """Generate the audit parameterized Nox session.
+
+        To be useful, this function needs the `audit_versions` option to be
+        set. It should contain a list of Python versions to be audited.
+        """
+        # Should produce --ignore-vuln [vuln_id] for each vulnerability ID
+        ignore_options = [
+            i for vuln in self._audit_suppressions for i in ("--ignore-vuln", vuln)
+        ]
+
+        @session(name="audit")
+        @self._install_package
+        @install_group("audit")
+        @show_installed
+        @nox.parametrize("python", self._audit_versions)
+        def audit(sess: Session) -> None:
+            """Check for vulnerable dependencies."""
+            sess.run("pip-audit", "-v", "--progress-spinner", "off", *ignore_options)


Reviews:
user: login: Maegereg
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T18:18:07Z
user: login: Maegereg
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T18:19:19Z
user: login: tmager
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T22:10:27Z
user: login: tmager
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T22:10:38Z
user: login: tmager
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T22:11:41Z
user: login: tmager
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T22:11:45Z
user: login: tmager
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T22:11:47Z
user: login: tmager
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T22:12:16Z
user: login: tmager
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T22:13:29Z
user: login: tmager
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T22:13:38Z
user: login: tmager
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T22:13:43Z
user: login: tmager
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T22:14:05Z
user: login: Maegereg
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T22:22:31Z
user: login: Maegereg
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T22:23:51Z
user: login: Maegereg
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T22:30:18Z
user: login: Maegereg
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T22:30:44Z
user: login: Maegereg
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T22:31:36Z
user: login: tmager
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T23:06:14Z
user: login: tmager
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T23:10:04Z
user: login: tmager
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T23:14:20Z
user: login: Maegereg
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T00:45:32Z
user: login: Maegereg
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T00:45:55Z
user: login: Maegereg
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T00:48:30Z
user: login: tmager
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T01:55:51Z
user: login: tmager
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T01:55:56Z
user: login: Maegereg
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T01:58:53Z

Review comments:
diff_hunk: @@ -0,0 +1,3 @@
+# Nox utilities
+
+A collection of common utilities and sessions for [`nox`](https://nox.thea.codes/en/stable/).
path: nox-utils/README.md
user: login: Maegereg
body: Maybe also worth saying which repositories it's used by?
created_at: 2025-05-13T17:21:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,135 @@
+[project]
+name = "tmlt-nox-utils"
+version = "0.0.0"
+description = ""
+authors = []
+readme = "README.md"
+
+[tool.poetry]
+requires-poetry = ">=2.0"
+packages = [{include = "tmlt", from="src"}]
+
+[tool.poetry.dependencies]
+python = "^3.9"
+
+nox = ">=2022.8.7"
+nox_poetry = "1.0.1"
path: nox-utils/pyproject.toml
user: login: Maegereg
body: I thought you said nox_poetry is unnecessary now?
created_at: 2025-05-13T17:22:46Z
author_association: CONTRIBUTOR
path: nox-utils/src/tmlt/nox_utils/__init__.py
user: login: Maegereg
body: High-level comment: it was always very weird to me that the bulk of the nox_utils code is in __init__.py. Seems like bad form.
created_at: 2025-05-13T17:43:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,23 @@
+from pathlib import Path
+
+import nox
+from nox import session as session
+from tmlt.nox_utils import SessionManager, install_group
+
+# Use uv for managing virtualenvs, if it's available. This significantly reduces
+# the overhead associated with using nox sessions in their own virtualenvs
+# versus running with --no-venv.
+nox.options.default_venv_backend = "uv|virtualenv"
+
+PACKAGE_NAME = "tmlt.tools"
path: nox-utils/noxfile.py
user: login: Maegereg
body: This package name doesn't seem to match the directory structure?
created_at: 2025-05-13T17:45:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,407 @@
+"""A central, largely package-agnostic collection of nox utilities.
+
+This module defines SessionManager, which takes in a collection of configuration
+options and exposes methods that can be called to generate nox sessions.
+"""
+
+import subprocess
+from functools import wraps
+from pathlib import Path
+from typing import Any, Callable, Optional, Union
+
+import nox
+from nox import Session, session
+
+from .dependencies import install_group, show_installed
+from .environment import in_ci, num_cpus
+
+
+class SessionManager:
+    """Class for creating common Nox sessions based on project-specific configuration.
+
+    To add a session to a particular noxfile, create an instance of this class
+    using your desired options, then call the methods corresponding to the
+    sessions you want.
+
+    This class leverages the fact that *any* function that is defined with the
+    ``@session`` decorator is picked up as a Nox session, no matter where it is
+    defined. By wrapping these definitions inside of class methods, we can
+    access the class attributes to do project-specific things while still
+    maintaining a common collection of sessions that can be reused across
+    projects, and can avoid needing boilerplate in the noxfile itself.
+    """
+
+    def __init__(
+        self,
+        package: str,
+        directory: Path,
+        *,
+        default_python_version: str = "3.9",
+        custom_build: Optional[Callable[[Session], None]] = None,
+        smoketest_script: Optional[str] = None,
+        parallel_tests: bool = True,
+        min_coverage: int = 80,
+        audit_versions: Optional[list[str]] = None,
+        audit_suppressions: Optional[list[str]] = None,
+    ) -> None:
+        """Configure options for generating nox sessions.
+
+        Args:
+            package: The name of the package, e.g. "tmlt.core"
+            directory: The root directory of the package
+            default_python_version: The Python minor version that will be used by
+                default for most generated sessions. In general, this should be the
+                oldest supported Python version.
+            custom_build: A function that overrides the standard build process.
path: nox-utils/src/tmlt/nox_utils/__init__.py
user: login: Maegereg
body: We should say more about the shape of the function, i.e. that it should take the session.
created_at: 2025-05-13T18:06:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,407 @@
+"""A central, largely package-agnostic collection of nox utilities.
+
+This module defines SessionManager, which takes in a collection of configuration
+options and exposes methods that can be called to generate nox sessions.
+"""
+
+import subprocess
+from functools import wraps
+from pathlib import Path
+from typing import Any, Callable, Optional, Union
+
+import nox
+from nox import Session, session
+
+from .dependencies import install_group, show_installed
+from .environment import in_ci, num_cpus
+
+
+class SessionManager:
+    """Class for creating common Nox sessions based on project-specific configuration.
+
+    To add a session to a particular noxfile, create an instance of this class
+    using your desired options, then call the methods corresponding to the
+    sessions you want.
+
+    This class leverages the fact that *any* function that is defined with the
+    ``@session`` decorator is picked up as a Nox session, no matter where it is
+    defined. By wrapping these definitions inside of class methods, we can
+    access the class attributes to do project-specific things while still
+    maintaining a common collection of sessions that can be reused across
+    projects, and can avoid needing boilerplate in the noxfile itself.
+    """
+
+    def __init__(
+        self,
+        package: str,
+        directory: Path,
+        *,
+        default_python_version: str = "3.9",
+        custom_build: Optional[Callable[[Session], None]] = None,
+        smoketest_script: Optional[str] = None,
+        parallel_tests: bool = True,
+        min_coverage: int = 80,
+        audit_versions: Optional[list[str]] = None,
+        audit_suppressions: Optional[list[str]] = None,
+    ) -> None:
+        """Configure options for generating nox sessions.
+
+        Args:
+            package: The name of the package, e.g. "tmlt.core"
+            directory: The root directory of the package
+            default_python_version: The Python minor version that will be used by
+                default for most generated sessions. In general, this should be the
+                oldest supported Python version.
+            custom_build: A function that overrides the standard build process.
+            smoketest_script: A Python program to execute as a smoke-test for an
+                installation of the package -- this should be the script
+                content, not the path to an external script. It is considered to
+                pass as long as executing the script with `python -c` doesn't
+                produce an error. If unset, defaults to just importing the package.
+            parallel_tests: Controls whether tests should be run in parallel.
+            min_coverage: The minimum coverage needed for the test/test-fast sessions
+                to pass.
+            audit_versions: A list of Python minor versions on which to audit package
+                dependencies for vulnerabilities. If unset, defaults to only
+                ``default_python_version``.
+            audit_suppressions: A list of vulnerability IDs to ignore.
+        """
+        self._package = package
+        self._package_version = (
+            subprocess.run(["poetry", "version", "-s"], capture_output=True, check=True)
+            .stdout.decode("utf-8")
+            .strip()
+        )
+        self._directory = directory
+        self._default_python_version = default_python_version
+        if custom_build is not None and not callable(custom_build):
+            raise ValueError(
+                f"Provided custom_build must be callable, but '{custom_build}' is not"
+            )
+        self._custom_build = custom_build
+        self._smoketest_script = smoketest_script or f"import {self._package}"
+        self._parallel_tests = parallel_tests
+        self._min_coverage = min_coverage
+        self._audit_versions = audit_versions or [default_python_version]
+        self._audit_suppressions = audit_suppressions or []
+
+    @property
+    def _code_dirs(self) -> list[Path]:
+        return [
+            p
+            for p in [
+                self._directory / "src",
+                self._directory / "test",
+            ]
+            if p.exists()
+        ]
+
+    def _build(self, sess: Session) -> None:
+        """Build sdists and wheels for the package.
+
+        If the build process for a package requires custom logic, pass the
+        "build" option to SessionBuilder, with the value being function taking
+        one argument (a nox Session) that builds the package. If this option is
+        not passed, the package is just built with `poetry build`.
+        """
+        if self._custom_build is not None:
+            sess.log("Using custom build function")
+            self._custom_build(sess)
+        else:
+            sess.run("poetry", "build", external=True)
+
+    def _install_package(self, f: Callable[[Session], Any]) -> Callable[[Session], Any]:
+        """Install the main package and its dependencies from Poetry lock."""
+
+        @wraps(f)
+        def inner(sess: Session) -> Any:
+            if not sess.virtualenv.is_sandboxed:
+                sess.log(
+                    "Not in a sandboxed environment, skipping package installation"
+                )
+                return f(sess)
+
+            # In the CI we want to use the wheel from the pipeline, but locally
+            # it's easier to let Poetry handle figuring out what to install and
+            # how to build it if needed.
+            if not in_ci():
+                sess.run_install("poetry", "install", "--only", "main", external=True)
+                return f(sess)
+
+            temp_dir = sess.create_tmp()
+            package = f"{self._package}=={self._package_version}"
+            out = sess.run(
+                "pip",
+                "download",
+                package,
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "-d",
+                temp_dir,
+                "--no-deps",
+                silent=True,
+                success_codes=[0, 1],
+                external=True,
+            )
+            assert out is not None, "sess.run should not return None when silent=True"
+            if "No matching distribution" in out:
+                sess.error(
+                    f"No wheel found for {package}; wheel must exist when running in CI"
+                )
+
+            sess.install(
+                f"{self._package}=={self._package_version}",
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "--no-deps",
+            )
+            return install_group("main")(f)(sess)
+
+        return inner
+
+    def _install_package_release(
path: nox-utils/src/tmlt/nox_utils/__init__.py
user: login: Maegereg
body: 1) I don't love the name - it's not clear to me why "release" implies "with dependencies". Maybe just `_install_package_with_deps` (and `install_package_without_deps` as the other option?)

2) There seems to be substantial code duplication between this and the previous method - can we reduce that?
created_at: 2025-05-13T18:11:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,407 @@
+"""A central, largely package-agnostic collection of nox utilities.
+
+This module defines SessionManager, which takes in a collection of configuration
+options and exposes methods that can be called to generate nox sessions.
+"""
+
+import subprocess
+from functools import wraps
+from pathlib import Path
+from typing import Any, Callable, Optional, Union
+
+import nox
+from nox import Session, session
+
+from .dependencies import install_group, show_installed
+from .environment import in_ci, num_cpus
+
+
+class SessionManager:
+    """Class for creating common Nox sessions based on project-specific configuration.
+
+    To add a session to a particular noxfile, create an instance of this class
+    using your desired options, then call the methods corresponding to the
+    sessions you want.
+
+    This class leverages the fact that *any* function that is defined with the
+    ``@session`` decorator is picked up as a Nox session, no matter where it is
+    defined. By wrapping these definitions inside of class methods, we can
+    access the class attributes to do project-specific things while still
+    maintaining a common collection of sessions that can be reused across
+    projects, and can avoid needing boilerplate in the noxfile itself.
+    """
+
+    def __init__(
+        self,
+        package: str,
+        directory: Path,
+        *,
+        default_python_version: str = "3.9",
+        custom_build: Optional[Callable[[Session], None]] = None,
+        smoketest_script: Optional[str] = None,
+        parallel_tests: bool = True,
+        min_coverage: int = 80,
+        audit_versions: Optional[list[str]] = None,
+        audit_suppressions: Optional[list[str]] = None,
+    ) -> None:
+        """Configure options for generating nox sessions.
+
+        Args:
+            package: The name of the package, e.g. "tmlt.core"
+            directory: The root directory of the package
+            default_python_version: The Python minor version that will be used by
+                default for most generated sessions. In general, this should be the
+                oldest supported Python version.
+            custom_build: A function that overrides the standard build process.
+            smoketest_script: A Python program to execute as a smoke-test for an
+                installation of the package -- this should be the script
+                content, not the path to an external script. It is considered to
+                pass as long as executing the script with `python -c` doesn't
+                produce an error. If unset, defaults to just importing the package.
+            parallel_tests: Controls whether tests should be run in parallel.
+            min_coverage: The minimum coverage needed for the test/test-fast sessions
+                to pass.
+            audit_versions: A list of Python minor versions on which to audit package
+                dependencies for vulnerabilities. If unset, defaults to only
+                ``default_python_version``.
+            audit_suppressions: A list of vulnerability IDs to ignore.
+        """
+        self._package = package
+        self._package_version = (
+            subprocess.run(["poetry", "version", "-s"], capture_output=True, check=True)
+            .stdout.decode("utf-8")
+            .strip()
+        )
+        self._directory = directory
+        self._default_python_version = default_python_version
+        if custom_build is not None and not callable(custom_build):
+            raise ValueError(
+                f"Provided custom_build must be callable, but '{custom_build}' is not"
+            )
+        self._custom_build = custom_build
+        self._smoketest_script = smoketest_script or f"import {self._package}"
+        self._parallel_tests = parallel_tests
+        self._min_coverage = min_coverage
+        self._audit_versions = audit_versions or [default_python_version]
+        self._audit_suppressions = audit_suppressions or []
+
+    @property
+    def _code_dirs(self) -> list[Path]:
+        return [
+            p
+            for p in [
+                self._directory / "src",
+                self._directory / "test",
+            ]
+            if p.exists()
+        ]
+
+    def _build(self, sess: Session) -> None:
+        """Build sdists and wheels for the package.
+
+        If the build process for a package requires custom logic, pass the
+        "build" option to SessionBuilder, with the value being function taking
+        one argument (a nox Session) that builds the package. If this option is
+        not passed, the package is just built with `poetry build`.
+        """
+        if self._custom_build is not None:
+            sess.log("Using custom build function")
+            self._custom_build(sess)
+        else:
+            sess.run("poetry", "build", external=True)
+
+    def _install_package(self, f: Callable[[Session], Any]) -> Callable[[Session], Any]:
+        """Install the main package and its dependencies from Poetry lock."""
+
+        @wraps(f)
+        def inner(sess: Session) -> Any:
+            if not sess.virtualenv.is_sandboxed:
+                sess.log(
+                    "Not in a sandboxed environment, skipping package installation"
+                )
+                return f(sess)
+
+            # In the CI we want to use the wheel from the pipeline, but locally
+            # it's easier to let Poetry handle figuring out what to install and
+            # how to build it if needed.
+            if not in_ci():
+                sess.run_install("poetry", "install", "--only", "main", external=True)
+                return f(sess)
+
+            temp_dir = sess.create_tmp()
+            package = f"{self._package}=={self._package_version}"
+            out = sess.run(
+                "pip",
+                "download",
+                package,
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "-d",
+                temp_dir,
+                "--no-deps",
+                silent=True,
+                success_codes=[0, 1],
+                external=True,
+            )
+            assert out is not None, "sess.run should not return None when silent=True"
+            if "No matching distribution" in out:
+                sess.error(
+                    f"No wheel found for {package}; wheel must exist when running in CI"
+                )
+
+            sess.install(
+                f"{self._package}=={self._package_version}",
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "--no-deps",
+            )
+            return install_group("main")(f)(sess)
+
+        return inner
+
+    def _install_package_release(
+        self, f: Callable[[Session], Any]
+    ) -> Callable[[Session], Any]:
+        """Install the main package and its dependencies as resolved by pip.
+
+        Unlike _install_package, this method ignores the Poetry lock and relies
+        on pip to install whatever versions of dependencies it likes. Also
+        unlike _install_package, this method will only install from a built
+        wheel in dist/ -- locally, it will try to build a new wheel if it can't
+        find one.
+        """
+
+        @wraps(f)
+        def inner(sess: Session) -> Any:
+            if not sess.virtualenv.is_sandboxed:
+                sess.error("Release checks cannot be run with --no-venv")
+
+            temp_dir = sess.create_tmp()
+            package = f"{self._package}=={self._package_version}"
+            out = sess.run(
+                "pip",
+                "download",
+                package,
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "-d",
+                temp_dir,
+                "--no-deps",
+                silent=True,
+                success_codes=[0, 1],
+                external=True,
+            )
+            assert out is not None, "sess.run should not return None when silent=True"
+            if "No matching distribution" in out:
+                if in_ci():
+                    sess.error(
+                        f"No wheel found for {package}; "
+                        "wheel must exist when running in CI"
+                    )
+                else:
+                    sess.log("No wheel found, building one")
+                    self._build(sess)
+
+            sess.install(
+                f"{self._package}=={self._package_version}",
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+            )
+            return f(sess)
+
+        return inner
+
+    def build(self) -> None:
+        @session(name="build", python=self._default_python_version)
+        def build(sess: Session) -> None:
+            """Build source distributions and wheels for this package."""
+            self._build(sess)
+
+    def black(self) -> None:
+        @session(name="black", tags=["lint"], python=self._default_python_version)
+        @install_group("black")
+        def black(sess: Session) -> None:
+            """Run black."""
+            check_flags = ["--check", "--diff"] if "--check" in sess.posargs else []
+            sess.run("black", *check_flags, *self._code_dirs)
+
+    def isort(self) -> None:
+        @session(name="isort", tags=["lint"], python=self._default_python_version)
+        @install_group("isort")
+        def isort(sess: Session) -> None:
+            """Run isort."""
+            check_flags = ["--check", "--diff"] if "--check" in sess.posargs else []
+            sess.run("isort", *check_flags, *self._code_dirs)
+
+    def mypy(self) -> None:
+        @session(name="mypy", tags=["lint"], python=self._default_python_version)
+        @self._install_package
+        @install_group("mypy")
+        def mypy(sess: Session) -> None:
+            """Run mypy."""
+            sess.run("mypy", *self._code_dirs)
+
+    def pylint(self) -> None:
+        @session(name="pylint", tags=["lint"], python=self._default_python_version)
+        @self._install_package
+        @install_group("pylint")
+        def pylint(sess: Session) -> None:
+            """Run pylint."""
+            sess.run("pylint", "--score=no", *self._code_dirs)
+
+    def pydocstyle(self) -> None:
+        @session(name="pydocstyle", tags=["lint"], python=self._default_python_version)
+        @install_group("pydocstyle")
+        def pydocstyle(sess: Session) -> None:
+            """Run pydocstyle."""
+            sess.run("pydocstyle", *self._code_dirs)
+
+    def smoketest(self) -> None:
+        @session(name="smoketest", tags=["test"], python=self._default_python_version)
+        @self._install_package
+        def smoketest(sess: Session) -> None:
+            """Run a simple smoke-test of the package."""
+            sess.run("python", "-c", self._smoketest_script)
+
+    def release_smoketest(self) -> None:
+        @session(
+            name="release-smoketest",
+            tags=["release"],
+            python=self._default_python_version,
+        )
+        @self._install_package_release
+        def smoketest(sess: Session) -> None:
+            """Run a simple smoke-test of the package."""
+            sess.run("python", "-c", self._smoketest_script)
path: nox-utils/src/tmlt/nox_utils/__init__.py
user: login: Maegereg
body: Why do we need both of these?
created_at: 2025-05-13T18:13:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,407 @@
+"""A central, largely package-agnostic collection of nox utilities.
+
+This module defines SessionManager, which takes in a collection of configuration
+options and exposes methods that can be called to generate nox sessions.
+"""
+
+import subprocess
+from functools import wraps
+from pathlib import Path
+from typing import Any, Callable, Optional, Union
+
+import nox
+from nox import Session, session
+
+from .dependencies import install_group, show_installed
+from .environment import in_ci, num_cpus
+
+
+class SessionManager:
+    """Class for creating common Nox sessions based on project-specific configuration.
+
+    To add a session to a particular noxfile, create an instance of this class
+    using your desired options, then call the methods corresponding to the
+    sessions you want.
+
+    This class leverages the fact that *any* function that is defined with the
+    ``@session`` decorator is picked up as a Nox session, no matter where it is
+    defined. By wrapping these definitions inside of class methods, we can
+    access the class attributes to do project-specific things while still
+    maintaining a common collection of sessions that can be reused across
+    projects, and can avoid needing boilerplate in the noxfile itself.
+    """
+
+    def __init__(
+        self,
+        package: str,
+        directory: Path,
+        *,
+        default_python_version: str = "3.9",
+        custom_build: Optional[Callable[[Session], None]] = None,
+        smoketest_script: Optional[str] = None,
+        parallel_tests: bool = True,
+        min_coverage: int = 80,
+        audit_versions: Optional[list[str]] = None,
+        audit_suppressions: Optional[list[str]] = None,
+    ) -> None:
+        """Configure options for generating nox sessions.
+
+        Args:
+            package: The name of the package, e.g. "tmlt.core"
+            directory: The root directory of the package
+            default_python_version: The Python minor version that will be used by
+                default for most generated sessions. In general, this should be the
+                oldest supported Python version.
+            custom_build: A function that overrides the standard build process.
+            smoketest_script: A Python program to execute as a smoke-test for an
+                installation of the package -- this should be the script
+                content, not the path to an external script. It is considered to
+                pass as long as executing the script with `python -c` doesn't
+                produce an error. If unset, defaults to just importing the package.
+            parallel_tests: Controls whether tests should be run in parallel.
+            min_coverage: The minimum coverage needed for the test/test-fast sessions
+                to pass.
+            audit_versions: A list of Python minor versions on which to audit package
+                dependencies for vulnerabilities. If unset, defaults to only
+                ``default_python_version``.
+            audit_suppressions: A list of vulnerability IDs to ignore.
+        """
+        self._package = package
+        self._package_version = (
+            subprocess.run(["poetry", "version", "-s"], capture_output=True, check=True)
+            .stdout.decode("utf-8")
+            .strip()
+        )
+        self._directory = directory
+        self._default_python_version = default_python_version
+        if custom_build is not None and not callable(custom_build):
+            raise ValueError(
+                f"Provided custom_build must be callable, but '{custom_build}' is not"
+            )
+        self._custom_build = custom_build
+        self._smoketest_script = smoketest_script or f"import {self._package}"
+        self._parallel_tests = parallel_tests
+        self._min_coverage = min_coverage
+        self._audit_versions = audit_versions or [default_python_version]
+        self._audit_suppressions = audit_suppressions or []
+
+    @property
+    def _code_dirs(self) -> list[Path]:
+        return [
+            p
+            for p in [
+                self._directory / "src",
+                self._directory / "test",
+            ]
+            if p.exists()
+        ]
+
+    def _build(self, sess: Session) -> None:
+        """Build sdists and wheels for the package.
+
+        If the build process for a package requires custom logic, pass the
+        "build" option to SessionBuilder, with the value being function taking
+        one argument (a nox Session) that builds the package. If this option is
+        not passed, the package is just built with `poetry build`.
+        """
+        if self._custom_build is not None:
+            sess.log("Using custom build function")
+            self._custom_build(sess)
+        else:
+            sess.run("poetry", "build", external=True)
+
+    def _install_package(self, f: Callable[[Session], Any]) -> Callable[[Session], Any]:
+        """Install the main package and its dependencies from Poetry lock."""
+
+        @wraps(f)
+        def inner(sess: Session) -> Any:
+            if not sess.virtualenv.is_sandboxed:
+                sess.log(
+                    "Not in a sandboxed environment, skipping package installation"
+                )
+                return f(sess)
+
+            # In the CI we want to use the wheel from the pipeline, but locally
+            # it's easier to let Poetry handle figuring out what to install and
+            # how to build it if needed.
+            if not in_ci():
+                sess.run_install("poetry", "install", "--only", "main", external=True)
+                return f(sess)
+
+            temp_dir = sess.create_tmp()
+            package = f"{self._package}=={self._package_version}"
+            out = sess.run(
+                "pip",
+                "download",
+                package,
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "-d",
+                temp_dir,
+                "--no-deps",
+                silent=True,
+                success_codes=[0, 1],
+                external=True,
+            )
+            assert out is not None, "sess.run should not return None when silent=True"
+            if "No matching distribution" in out:
+                sess.error(
+                    f"No wheel found for {package}; wheel must exist when running in CI"
+                )
+
+            sess.install(
+                f"{self._package}=={self._package_version}",
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "--no-deps",
+            )
+            return install_group("main")(f)(sess)
+
+        return inner
+
+    def _install_package_release(
+        self, f: Callable[[Session], Any]
+    ) -> Callable[[Session], Any]:
+        """Install the main package and its dependencies as resolved by pip.
+
+        Unlike _install_package, this method ignores the Poetry lock and relies
+        on pip to install whatever versions of dependencies it likes. Also
+        unlike _install_package, this method will only install from a built
+        wheel in dist/ -- locally, it will try to build a new wheel if it can't
+        find one.
+        """
+
+        @wraps(f)
+        def inner(sess: Session) -> Any:
+            if not sess.virtualenv.is_sandboxed:
+                sess.error("Release checks cannot be run with --no-venv")
+
+            temp_dir = sess.create_tmp()
+            package = f"{self._package}=={self._package_version}"
+            out = sess.run(
+                "pip",
+                "download",
+                package,
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "-d",
+                temp_dir,
+                "--no-deps",
+                silent=True,
+                success_codes=[0, 1],
+                external=True,
+            )
+            assert out is not None, "sess.run should not return None when silent=True"
+            if "No matching distribution" in out:
+                if in_ci():
+                    sess.error(
+                        f"No wheel found for {package}; "
+                        "wheel must exist when running in CI"
+                    )
+                else:
+                    sess.log("No wheel found, building one")
+                    self._build(sess)
+
+            sess.install(
+                f"{self._package}=={self._package_version}",
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+            )
+            return f(sess)
+
+        return inner
+
+    def build(self) -> None:
+        @session(name="build", python=self._default_python_version)
+        def build(sess: Session) -> None:
+            """Build source distributions and wheels for this package."""
+            self._build(sess)
+
+    def black(self) -> None:
+        @session(name="black", tags=["lint"], python=self._default_python_version)
+        @install_group("black")
+        def black(sess: Session) -> None:
+            """Run black."""
+            check_flags = ["--check", "--diff"] if "--check" in sess.posargs else []
+            sess.run("black", *check_flags, *self._code_dirs)
+
+    def isort(self) -> None:
+        @session(name="isort", tags=["lint"], python=self._default_python_version)
+        @install_group("isort")
+        def isort(sess: Session) -> None:
+            """Run isort."""
+            check_flags = ["--check", "--diff"] if "--check" in sess.posargs else []
+            sess.run("isort", *check_flags, *self._code_dirs)
+
+    def mypy(self) -> None:
+        @session(name="mypy", tags=["lint"], python=self._default_python_version)
+        @self._install_package
+        @install_group("mypy")
+        def mypy(sess: Session) -> None:
+            """Run mypy."""
+            sess.run("mypy", *self._code_dirs)
+
+    def pylint(self) -> None:
+        @session(name="pylint", tags=["lint"], python=self._default_python_version)
+        @self._install_package
+        @install_group("pylint")
+        def pylint(sess: Session) -> None:
+            """Run pylint."""
+            sess.run("pylint", "--score=no", *self._code_dirs)
+
+    def pydocstyle(self) -> None:
+        @session(name="pydocstyle", tags=["lint"], python=self._default_python_version)
+        @install_group("pydocstyle")
+        def pydocstyle(sess: Session) -> None:
+            """Run pydocstyle."""
+            sess.run("pydocstyle", *self._code_dirs)
+
+    def smoketest(self) -> None:
+        @session(name="smoketest", tags=["test"], python=self._default_python_version)
+        @self._install_package
+        def smoketest(sess: Session) -> None:
+            """Run a simple smoke-test of the package."""
+            sess.run("python", "-c", self._smoketest_script)
+
+    def release_smoketest(self) -> None:
+        @session(
+            name="release-smoketest",
+            tags=["release"],
+            python=self._default_python_version,
+        )
+        @self._install_package_release
+        def smoketest(sess: Session) -> None:
+            """Run a simple smoke-test of the package."""
+            sess.run("python", "-c", self._smoketest_script)
+
+    def _test(
+        self,
+        sess: Session,
+        markers: str = "",
+        *,
+        min_coverage: Optional[int] = None,
+        extra_args: Optional[list[str]] = None,
+    ) -> None:
+        test_paths = self._code_dirs
+        extra_args = extra_args or []
+        if sess.posargs:
+            test_paths = []
+            extra_args.extend(sess.posargs)
+        args: list[Union[str, Path]] = [
+            "-r fEs",
+            "--verbose",
+            "--disable-warnings",
+            f"--junitxml={self._directory}/junit.xml",
+            "--durations=10",
+            f"--cov={self._package}",
+            "--cov-fail-under={}".format(
+                self._min_coverage if min_coverage is None else min_coverage
+            ),
+            "--cov-report=term",
+            f"--cov-report=html:{self._directory}/coverage/",
+            f"--cov-report=xml:{self._directory}/coverage.xml",
+            *extra_args,
+            *test_paths,
+        ]
+        cpus = num_cpus(sess)
+        if self._parallel_tests and cpus > 3:
+            args.append(f"--numprocesses={cpus//2}")
+        if markers:
+            args.extend(["-m", markers])
+
+        sess.run("pytest", *args)
+
+    def test(self) -> None:
+        @session(name="test", python=self._default_python_version)
+        @self._install_package
+        @install_group("test")
+        def test(sess: Session) -> None:
+            """Run all tests."""
+            self._test(sess)
+
+    def test_fast(self) -> None:
+        @session(name="test-fast", python=self._default_python_version)
+        @self._install_package
+        @install_group("test")
+        def test_fast(sess: Session) -> None:
+            """Run fast tests."""
+            self._test(sess, "not slow")
+
+    def test_slow(self) -> None:
+        @session(name="test-slow", python=self._default_python_version)
+        @self._install_package
+        @install_group("test")
+        def test_slow(sess: Session) -> None:
+            """Run slow tests."""
+            self._test(sess, "slow", min_coverage=0)
+
+    def test_doctest(self) -> None:
+        @session(
+            name="test-doctest", tags=["test"], python=self._default_python_version
+        )
+        @self._install_package
+        @install_group("test")
+        def test_doctest(sess: Session) -> None:
+            """Run tests on code examples in docstrings."""
+            self._test(sess, "slow", min_coverage=0, extra_args=["--doctest-modules"])
path: nox-utils/src/tmlt/nox_utils/__init__.py
user: login: Maegereg
body: Why does this specify the `slow` tag?
created_at: 2025-05-13T18:16:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,30 @@
+"""Utilities for inspecting the environment where nox is running."""
+import os
+import platform
+import subprocess
+
+from nox import Session
+
+
+def in_ci() -> bool:
+    """Return whether nox is running in a CI pipeline."""
+    return bool(os.environ.get("CI"))
path: nox-utils/src/tmlt/nox_utils/environment.py
user: login: Maegereg
body: Do we know whether this works with github actions?
created_at: 2025-05-13T18:17:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,135 @@
+[project]
+name = "tmlt-nox-utils"
+version = "0.0.0"
+description = ""
+authors = []
+readme = "README.md"
+
+[tool.poetry]
+requires-poetry = ">=2.0"
+packages = [{include = "tmlt", from="src"}]
+
+[tool.poetry.dependencies]
+python = "^3.9"
+
+nox = ">=2022.8.7"
path: nox-utils/pyproject.toml
user: login: Maegereg
body: We're setting the venv backend to default to `uv`, so do we want to install nox[uv] instead?
created_at: 2025-05-13T18:19:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,3 @@
+# Nox utilities
+
+A collection of common utilities and sessions for [`nox`](https://nox.thea.codes/en/stable/).
path: nox-utils/README.md
user: login: tmager
body: I guess? They're already linked in the top-level readme, but we could add them here as well.
created_at: 2025-05-13T22:10:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,135 @@
+[project]
+name = "tmlt-nox-utils"
+version = "0.0.0"
+description = ""
+authors = []
+readme = "README.md"
+
+[tool.poetry]
+requires-poetry = ">=2.0"
+packages = [{include = "tmlt", from="src"}]
+
+[tool.poetry.dependencies]
+python = "^3.9"
+
+nox = ">=2022.8.7"
+nox_poetry = "1.0.1"
path: nox-utils/pyproject.toml
user: login: tmager
body: Ah, yes, I didn't revisit this file after discovering that we could remove it completely. Fixed.
created_at: 2025-05-13T22:10:38Z
author_association: CONTRIBUTOR
path: nox-utils/src/tmlt/nox_utils/__init__.py
user: login: tmager
body: Moved into a separate file and switched to importing from `__init__`. Also switched to private submodules for the various helper functions, so the `tmlt.nox_utils` module provides all of the relevant imports.
created_at: 2025-05-13T22:11:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,23 @@
+from pathlib import Path
+
+import nox
+from nox import session as session
+from tmlt.nox_utils import SessionManager, install_group
+
+# Use uv for managing virtualenvs, if it's available. This significantly reduces
+# the overhead associated with using nox sessions in their own virtualenvs
+# versus running with --no-venv.
+nox.options.default_venv_backend = "uv|virtualenv"
+
+PACKAGE_NAME = "tmlt.tools"
path: nox-utils/noxfile.py
user: login: tmager
body: Fixed.
created_at: 2025-05-13T22:11:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,407 @@
+"""A central, largely package-agnostic collection of nox utilities.
+
+This module defines SessionManager, which takes in a collection of configuration
+options and exposes methods that can be called to generate nox sessions.
+"""
+
+import subprocess
+from functools import wraps
+from pathlib import Path
+from typing import Any, Callable, Optional, Union
+
+import nox
+from nox import Session, session
+
+from .dependencies import install_group, show_installed
+from .environment import in_ci, num_cpus
+
+
+class SessionManager:
+    """Class for creating common Nox sessions based on project-specific configuration.
+
+    To add a session to a particular noxfile, create an instance of this class
+    using your desired options, then call the methods corresponding to the
+    sessions you want.
+
+    This class leverages the fact that *any* function that is defined with the
+    ``@session`` decorator is picked up as a Nox session, no matter where it is
+    defined. By wrapping these definitions inside of class methods, we can
+    access the class attributes to do project-specific things while still
+    maintaining a common collection of sessions that can be reused across
+    projects, and can avoid needing boilerplate in the noxfile itself.
+    """
+
+    def __init__(
+        self,
+        package: str,
+        directory: Path,
+        *,
+        default_python_version: str = "3.9",
+        custom_build: Optional[Callable[[Session], None]] = None,
+        smoketest_script: Optional[str] = None,
+        parallel_tests: bool = True,
+        min_coverage: int = 80,
+        audit_versions: Optional[list[str]] = None,
+        audit_suppressions: Optional[list[str]] = None,
+    ) -> None:
+        """Configure options for generating nox sessions.
+
+        Args:
+            package: The name of the package, e.g. "tmlt.core"
+            directory: The root directory of the package
+            default_python_version: The Python minor version that will be used by
+                default for most generated sessions. In general, this should be the
+                oldest supported Python version.
+            custom_build: A function that overrides the standard build process.
path: nox-utils/src/tmlt/nox_utils/__init__.py
user: login: tmager
body: Added.
created_at: 2025-05-13T22:11:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,407 @@
+"""A central, largely package-agnostic collection of nox utilities.
+
+This module defines SessionManager, which takes in a collection of configuration
+options and exposes methods that can be called to generate nox sessions.
+"""
+
+import subprocess
+from functools import wraps
+from pathlib import Path
+from typing import Any, Callable, Optional, Union
+
+import nox
+from nox import Session, session
+
+from .dependencies import install_group, show_installed
+from .environment import in_ci, num_cpus
+
+
+class SessionManager:
+    """Class for creating common Nox sessions based on project-specific configuration.
+
+    To add a session to a particular noxfile, create an instance of this class
+    using your desired options, then call the methods corresponding to the
+    sessions you want.
+
+    This class leverages the fact that *any* function that is defined with the
+    ``@session`` decorator is picked up as a Nox session, no matter where it is
+    defined. By wrapping these definitions inside of class methods, we can
+    access the class attributes to do project-specific things while still
+    maintaining a common collection of sessions that can be reused across
+    projects, and can avoid needing boilerplate in the noxfile itself.
+    """
+
+    def __init__(
+        self,
+        package: str,
+        directory: Path,
+        *,
+        default_python_version: str = "3.9",
+        custom_build: Optional[Callable[[Session], None]] = None,
+        smoketest_script: Optional[str] = None,
+        parallel_tests: bool = True,
+        min_coverage: int = 80,
+        audit_versions: Optional[list[str]] = None,
+        audit_suppressions: Optional[list[str]] = None,
+    ) -> None:
+        """Configure options for generating nox sessions.
+
+        Args:
+            package: The name of the package, e.g. "tmlt.core"
+            directory: The root directory of the package
+            default_python_version: The Python minor version that will be used by
+                default for most generated sessions. In general, this should be the
+                oldest supported Python version.
+            custom_build: A function that overrides the standard build process.
+            smoketest_script: A Python program to execute as a smoke-test for an
+                installation of the package -- this should be the script
+                content, not the path to an external script. It is considered to
+                pass as long as executing the script with `python -c` doesn't
+                produce an error. If unset, defaults to just importing the package.
+            parallel_tests: Controls whether tests should be run in parallel.
+            min_coverage: The minimum coverage needed for the test/test-fast sessions
+                to pass.
+            audit_versions: A list of Python minor versions on which to audit package
+                dependencies for vulnerabilities. If unset, defaults to only
+                ``default_python_version``.
+            audit_suppressions: A list of vulnerability IDs to ignore.
+        """
+        self._package = package
+        self._package_version = (
+            subprocess.run(["poetry", "version", "-s"], capture_output=True, check=True)
+            .stdout.decode("utf-8")
+            .strip()
+        )
+        self._directory = directory
+        self._default_python_version = default_python_version
+        if custom_build is not None and not callable(custom_build):
+            raise ValueError(
+                f"Provided custom_build must be callable, but '{custom_build}' is not"
+            )
+        self._custom_build = custom_build
+        self._smoketest_script = smoketest_script or f"import {self._package}"
+        self._parallel_tests = parallel_tests
+        self._min_coverage = min_coverage
+        self._audit_versions = audit_versions or [default_python_version]
+        self._audit_suppressions = audit_suppressions or []
+
+    @property
+    def _code_dirs(self) -> list[Path]:
+        return [
+            p
+            for p in [
+                self._directory / "src",
+                self._directory / "test",
+            ]
+            if p.exists()
+        ]
+
+    def _build(self, sess: Session) -> None:
+        """Build sdists and wheels for the package.
+
+        If the build process for a package requires custom logic, pass the
+        "build" option to SessionBuilder, with the value being function taking
+        one argument (a nox Session) that builds the package. If this option is
+        not passed, the package is just built with `poetry build`.
+        """
+        if self._custom_build is not None:
+            sess.log("Using custom build function")
+            self._custom_build(sess)
+        else:
+            sess.run("poetry", "build", external=True)
+
+    def _install_package(self, f: Callable[[Session], Any]) -> Callable[[Session], Any]:
+        """Install the main package and its dependencies from Poetry lock."""
+
+        @wraps(f)
+        def inner(sess: Session) -> Any:
+            if not sess.virtualenv.is_sandboxed:
+                sess.log(
+                    "Not in a sandboxed environment, skipping package installation"
+                )
+                return f(sess)
+
+            # In the CI we want to use the wheel from the pipeline, but locally
+            # it's easier to let Poetry handle figuring out what to install and
+            # how to build it if needed.
+            if not in_ci():
+                sess.run_install("poetry", "install", "--only", "main", external=True)
+                return f(sess)
+
+            temp_dir = sess.create_tmp()
+            package = f"{self._package}=={self._package_version}"
+            out = sess.run(
+                "pip",
+                "download",
+                package,
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "-d",
+                temp_dir,
+                "--no-deps",
+                silent=True,
+                success_codes=[0, 1],
+                external=True,
+            )
+            assert out is not None, "sess.run should not return None when silent=True"
+            if "No matching distribution" in out:
+                sess.error(
+                    f"No wheel found for {package}; wheel must exist when running in CI"
+                )
+
+            sess.install(
+                f"{self._package}=={self._package_version}",
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "--no-deps",
+            )
+            return install_group("main")(f)(sess)
+
+        return inner
+
+    def _install_package_release(
path: nox-utils/src/tmlt/nox_utils/__init__.py
user: login: tmager
body: They both install the dependencies -- `_install_package` installs them from the Poetry lock (as when running `poetry install --only main`), while `_install_package_release` just installs whatever versions `pip` happens to want to pull in at that moment.

The naming matches how this kind of thing was named previously (e.g. the `test` job used the Poetry lock while `test_release` installed whatever `pip` found), but it is a bit unclear, I'm open to renaming it.


created_at: 2025-05-13T22:12:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,407 @@
+"""A central, largely package-agnostic collection of nox utilities.
+
+This module defines SessionManager, which takes in a collection of configuration
+options and exposes methods that can be called to generate nox sessions.
+"""
+
+import subprocess
+from functools import wraps
+from pathlib import Path
+from typing import Any, Callable, Optional, Union
+
+import nox
+from nox import Session, session
+
+from .dependencies import install_group, show_installed
+from .environment import in_ci, num_cpus
+
+
+class SessionManager:
+    """Class for creating common Nox sessions based on project-specific configuration.
+
+    To add a session to a particular noxfile, create an instance of this class
+    using your desired options, then call the methods corresponding to the
+    sessions you want.
+
+    This class leverages the fact that *any* function that is defined with the
+    ``@session`` decorator is picked up as a Nox session, no matter where it is
+    defined. By wrapping these definitions inside of class methods, we can
+    access the class attributes to do project-specific things while still
+    maintaining a common collection of sessions that can be reused across
+    projects, and can avoid needing boilerplate in the noxfile itself.
+    """
+
+    def __init__(
+        self,
+        package: str,
+        directory: Path,
+        *,
+        default_python_version: str = "3.9",
+        custom_build: Optional[Callable[[Session], None]] = None,
+        smoketest_script: Optional[str] = None,
+        parallel_tests: bool = True,
+        min_coverage: int = 80,
+        audit_versions: Optional[list[str]] = None,
+        audit_suppressions: Optional[list[str]] = None,
+    ) -> None:
+        """Configure options for generating nox sessions.
+
+        Args:
+            package: The name of the package, e.g. "tmlt.core"
+            directory: The root directory of the package
+            default_python_version: The Python minor version that will be used by
+                default for most generated sessions. In general, this should be the
+                oldest supported Python version.
+            custom_build: A function that overrides the standard build process.
+            smoketest_script: A Python program to execute as a smoke-test for an
+                installation of the package -- this should be the script
+                content, not the path to an external script. It is considered to
+                pass as long as executing the script with `python -c` doesn't
+                produce an error. If unset, defaults to just importing the package.
+            parallel_tests: Controls whether tests should be run in parallel.
+            min_coverage: The minimum coverage needed for the test/test-fast sessions
+                to pass.
+            audit_versions: A list of Python minor versions on which to audit package
+                dependencies for vulnerabilities. If unset, defaults to only
+                ``default_python_version``.
+            audit_suppressions: A list of vulnerability IDs to ignore.
+        """
+        self._package = package
+        self._package_version = (
+            subprocess.run(["poetry", "version", "-s"], capture_output=True, check=True)
+            .stdout.decode("utf-8")
+            .strip()
+        )
+        self._directory = directory
+        self._default_python_version = default_python_version
+        if custom_build is not None and not callable(custom_build):
+            raise ValueError(
+                f"Provided custom_build must be callable, but '{custom_build}' is not"
+            )
+        self._custom_build = custom_build
+        self._smoketest_script = smoketest_script or f"import {self._package}"
+        self._parallel_tests = parallel_tests
+        self._min_coverage = min_coverage
+        self._audit_versions = audit_versions or [default_python_version]
+        self._audit_suppressions = audit_suppressions or []
+
+    @property
+    def _code_dirs(self) -> list[Path]:
+        return [
+            p
+            for p in [
+                self._directory / "src",
+                self._directory / "test",
+            ]
+            if p.exists()
+        ]
+
+    def _build(self, sess: Session) -> None:
+        """Build sdists and wheels for the package.
+
+        If the build process for a package requires custom logic, pass the
+        "build" option to SessionBuilder, with the value being function taking
+        one argument (a nox Session) that builds the package. If this option is
+        not passed, the package is just built with `poetry build`.
+        """
+        if self._custom_build is not None:
+            sess.log("Using custom build function")
+            self._custom_build(sess)
+        else:
+            sess.run("poetry", "build", external=True)
+
+    def _install_package(self, f: Callable[[Session], Any]) -> Callable[[Session], Any]:
+        """Install the main package and its dependencies from Poetry lock."""
+
+        @wraps(f)
+        def inner(sess: Session) -> Any:
+            if not sess.virtualenv.is_sandboxed:
+                sess.log(
+                    "Not in a sandboxed environment, skipping package installation"
+                )
+                return f(sess)
+
+            # In the CI we want to use the wheel from the pipeline, but locally
+            # it's easier to let Poetry handle figuring out what to install and
+            # how to build it if needed.
+            if not in_ci():
+                sess.run_install("poetry", "install", "--only", "main", external=True)
+                return f(sess)
+
+            temp_dir = sess.create_tmp()
+            package = f"{self._package}=={self._package_version}"
+            out = sess.run(
+                "pip",
+                "download",
+                package,
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "-d",
+                temp_dir,
+                "--no-deps",
+                silent=True,
+                success_codes=[0, 1],
+                external=True,
+            )
+            assert out is not None, "sess.run should not return None when silent=True"
+            if "No matching distribution" in out:
+                sess.error(
+                    f"No wheel found for {package}; wheel must exist when running in CI"
+                )
+
+            sess.install(
+                f"{self._package}=={self._package_version}",
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "--no-deps",
+            )
+            return install_group("main")(f)(sess)
+
+        return inner
+
+    def _install_package_release(
+        self, f: Callable[[Session], Any]
+    ) -> Callable[[Session], Any]:
+        """Install the main package and its dependencies as resolved by pip.
+
+        Unlike _install_package, this method ignores the Poetry lock and relies
+        on pip to install whatever versions of dependencies it likes. Also
+        unlike _install_package, this method will only install from a built
+        wheel in dist/ -- locally, it will try to build a new wheel if it can't
+        find one.
+        """
+
+        @wraps(f)
+        def inner(sess: Session) -> Any:
+            if not sess.virtualenv.is_sandboxed:
+                sess.error("Release checks cannot be run with --no-venv")
+
+            temp_dir = sess.create_tmp()
+            package = f"{self._package}=={self._package_version}"
+            out = sess.run(
+                "pip",
+                "download",
+                package,
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "-d",
+                temp_dir,
+                "--no-deps",
+                silent=True,
+                success_codes=[0, 1],
+                external=True,
+            )
+            assert out is not None, "sess.run should not return None when silent=True"
+            if "No matching distribution" in out:
+                if in_ci():
+                    sess.error(
+                        f"No wheel found for {package}; "
+                        "wheel must exist when running in CI"
+                    )
+                else:
+                    sess.log("No wheel found, building one")
+                    self._build(sess)
+
+            sess.install(
+                f"{self._package}=={self._package_version}",
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+            )
+            return f(sess)
+
+        return inner
+
+    def build(self) -> None:
+        @session(name="build", python=self._default_python_version)
+        def build(sess: Session) -> None:
+            """Build source distributions and wheels for this package."""
+            self._build(sess)
+
+    def black(self) -> None:
+        @session(name="black", tags=["lint"], python=self._default_python_version)
+        @install_group("black")
+        def black(sess: Session) -> None:
+            """Run black."""
+            check_flags = ["--check", "--diff"] if "--check" in sess.posargs else []
+            sess.run("black", *check_flags, *self._code_dirs)
+
+    def isort(self) -> None:
+        @session(name="isort", tags=["lint"], python=self._default_python_version)
+        @install_group("isort")
+        def isort(sess: Session) -> None:
+            """Run isort."""
+            check_flags = ["--check", "--diff"] if "--check" in sess.posargs else []
+            sess.run("isort", *check_flags, *self._code_dirs)
+
+    def mypy(self) -> None:
+        @session(name="mypy", tags=["lint"], python=self._default_python_version)
+        @self._install_package
+        @install_group("mypy")
+        def mypy(sess: Session) -> None:
+            """Run mypy."""
+            sess.run("mypy", *self._code_dirs)
+
+    def pylint(self) -> None:
+        @session(name="pylint", tags=["lint"], python=self._default_python_version)
+        @self._install_package
+        @install_group("pylint")
+        def pylint(sess: Session) -> None:
+            """Run pylint."""
+            sess.run("pylint", "--score=no", *self._code_dirs)
+
+    def pydocstyle(self) -> None:
+        @session(name="pydocstyle", tags=["lint"], python=self._default_python_version)
+        @install_group("pydocstyle")
+        def pydocstyle(sess: Session) -> None:
+            """Run pydocstyle."""
+            sess.run("pydocstyle", *self._code_dirs)
+
+    def smoketest(self) -> None:
+        @session(name="smoketest", tags=["test"], python=self._default_python_version)
+        @self._install_package
+        def smoketest(sess: Session) -> None:
+            """Run a simple smoke-test of the package."""
+            sess.run("python", "-c", self._smoketest_script)
+
+    def release_smoketest(self) -> None:
+        @session(
+            name="release-smoketest",
+            tags=["release"],
+            python=self._default_python_version,
+        )
+        @self._install_package_release
+        def smoketest(sess: Session) -> None:
+            """Run a simple smoke-test of the package."""
+            sess.run("python", "-c", self._smoketest_script)
path: nox-utils/src/tmlt/nox_utils/__init__.py
user: login: tmager
body: See previous comment for the difference; dunno how useful having both is, though.
created_at: 2025-05-13T22:13:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,407 @@
+"""A central, largely package-agnostic collection of nox utilities.
+
+This module defines SessionManager, which takes in a collection of configuration
+options and exposes methods that can be called to generate nox sessions.
+"""
+
+import subprocess
+from functools import wraps
+from pathlib import Path
+from typing import Any, Callable, Optional, Union
+
+import nox
+from nox import Session, session
+
+from .dependencies import install_group, show_installed
+from .environment import in_ci, num_cpus
+
+
+class SessionManager:
+    """Class for creating common Nox sessions based on project-specific configuration.
+
+    To add a session to a particular noxfile, create an instance of this class
+    using your desired options, then call the methods corresponding to the
+    sessions you want.
+
+    This class leverages the fact that *any* function that is defined with the
+    ``@session`` decorator is picked up as a Nox session, no matter where it is
+    defined. By wrapping these definitions inside of class methods, we can
+    access the class attributes to do project-specific things while still
+    maintaining a common collection of sessions that can be reused across
+    projects, and can avoid needing boilerplate in the noxfile itself.
+    """
+
+    def __init__(
+        self,
+        package: str,
+        directory: Path,
+        *,
+        default_python_version: str = "3.9",
+        custom_build: Optional[Callable[[Session], None]] = None,
+        smoketest_script: Optional[str] = None,
+        parallel_tests: bool = True,
+        min_coverage: int = 80,
+        audit_versions: Optional[list[str]] = None,
+        audit_suppressions: Optional[list[str]] = None,
+    ) -> None:
+        """Configure options for generating nox sessions.
+
+        Args:
+            package: The name of the package, e.g. "tmlt.core"
+            directory: The root directory of the package
+            default_python_version: The Python minor version that will be used by
+                default for most generated sessions. In general, this should be the
+                oldest supported Python version.
+            custom_build: A function that overrides the standard build process.
+            smoketest_script: A Python program to execute as a smoke-test for an
+                installation of the package -- this should be the script
+                content, not the path to an external script. It is considered to
+                pass as long as executing the script with `python -c` doesn't
+                produce an error. If unset, defaults to just importing the package.
+            parallel_tests: Controls whether tests should be run in parallel.
+            min_coverage: The minimum coverage needed for the test/test-fast sessions
+                to pass.
+            audit_versions: A list of Python minor versions on which to audit package
+                dependencies for vulnerabilities. If unset, defaults to only
+                ``default_python_version``.
+            audit_suppressions: A list of vulnerability IDs to ignore.
+        """
+        self._package = package
+        self._package_version = (
+            subprocess.run(["poetry", "version", "-s"], capture_output=True, check=True)
+            .stdout.decode("utf-8")
+            .strip()
+        )
+        self._directory = directory
+        self._default_python_version = default_python_version
+        if custom_build is not None and not callable(custom_build):
+            raise ValueError(
+                f"Provided custom_build must be callable, but '{custom_build}' is not"
+            )
+        self._custom_build = custom_build
+        self._smoketest_script = smoketest_script or f"import {self._package}"
+        self._parallel_tests = parallel_tests
+        self._min_coverage = min_coverage
+        self._audit_versions = audit_versions or [default_python_version]
+        self._audit_suppressions = audit_suppressions or []
+
+    @property
+    def _code_dirs(self) -> list[Path]:
+        return [
+            p
+            for p in [
+                self._directory / "src",
+                self._directory / "test",
+            ]
+            if p.exists()
+        ]
+
+    def _build(self, sess: Session) -> None:
+        """Build sdists and wheels for the package.
+
+        If the build process for a package requires custom logic, pass the
+        "build" option to SessionBuilder, with the value being function taking
+        one argument (a nox Session) that builds the package. If this option is
+        not passed, the package is just built with `poetry build`.
+        """
+        if self._custom_build is not None:
+            sess.log("Using custom build function")
+            self._custom_build(sess)
+        else:
+            sess.run("poetry", "build", external=True)
+
+    def _install_package(self, f: Callable[[Session], Any]) -> Callable[[Session], Any]:
+        """Install the main package and its dependencies from Poetry lock."""
+
+        @wraps(f)
+        def inner(sess: Session) -> Any:
+            if not sess.virtualenv.is_sandboxed:
+                sess.log(
+                    "Not in a sandboxed environment, skipping package installation"
+                )
+                return f(sess)
+
+            # In the CI we want to use the wheel from the pipeline, but locally
+            # it's easier to let Poetry handle figuring out what to install and
+            # how to build it if needed.
+            if not in_ci():
+                sess.run_install("poetry", "install", "--only", "main", external=True)
+                return f(sess)
+
+            temp_dir = sess.create_tmp()
+            package = f"{self._package}=={self._package_version}"
+            out = sess.run(
+                "pip",
+                "download",
+                package,
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "-d",
+                temp_dir,
+                "--no-deps",
+                silent=True,
+                success_codes=[0, 1],
+                external=True,
+            )
+            assert out is not None, "sess.run should not return None when silent=True"
+            if "No matching distribution" in out:
+                sess.error(
+                    f"No wheel found for {package}; wheel must exist when running in CI"
+                )
+
+            sess.install(
+                f"{self._package}=={self._package_version}",
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "--no-deps",
+            )
+            return install_group("main")(f)(sess)
+
+        return inner
+
+    def _install_package_release(
+        self, f: Callable[[Session], Any]
+    ) -> Callable[[Session], Any]:
+        """Install the main package and its dependencies as resolved by pip.
+
+        Unlike _install_package, this method ignores the Poetry lock and relies
+        on pip to install whatever versions of dependencies it likes. Also
+        unlike _install_package, this method will only install from a built
+        wheel in dist/ -- locally, it will try to build a new wheel if it can't
+        find one.
+        """
+
+        @wraps(f)
+        def inner(sess: Session) -> Any:
+            if not sess.virtualenv.is_sandboxed:
+                sess.error("Release checks cannot be run with --no-venv")
+
+            temp_dir = sess.create_tmp()
+            package = f"{self._package}=={self._package_version}"
+            out = sess.run(
+                "pip",
+                "download",
+                package,
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "-d",
+                temp_dir,
+                "--no-deps",
+                silent=True,
+                success_codes=[0, 1],
+                external=True,
+            )
+            assert out is not None, "sess.run should not return None when silent=True"
+            if "No matching distribution" in out:
+                if in_ci():
+                    sess.error(
+                        f"No wheel found for {package}; "
+                        "wheel must exist when running in CI"
+                    )
+                else:
+                    sess.log("No wheel found, building one")
+                    self._build(sess)
+
+            sess.install(
+                f"{self._package}=={self._package_version}",
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+            )
+            return f(sess)
+
+        return inner
+
+    def build(self) -> None:
+        @session(name="build", python=self._default_python_version)
+        def build(sess: Session) -> None:
+            """Build source distributions and wheels for this package."""
+            self._build(sess)
+
+    def black(self) -> None:
+        @session(name="black", tags=["lint"], python=self._default_python_version)
+        @install_group("black")
+        def black(sess: Session) -> None:
+            """Run black."""
+            check_flags = ["--check", "--diff"] if "--check" in sess.posargs else []
+            sess.run("black", *check_flags, *self._code_dirs)
+
+    def isort(self) -> None:
+        @session(name="isort", tags=["lint"], python=self._default_python_version)
+        @install_group("isort")
+        def isort(sess: Session) -> None:
+            """Run isort."""
+            check_flags = ["--check", "--diff"] if "--check" in sess.posargs else []
+            sess.run("isort", *check_flags, *self._code_dirs)
+
+    def mypy(self) -> None:
+        @session(name="mypy", tags=["lint"], python=self._default_python_version)
+        @self._install_package
+        @install_group("mypy")
+        def mypy(sess: Session) -> None:
+            """Run mypy."""
+            sess.run("mypy", *self._code_dirs)
+
+    def pylint(self) -> None:
+        @session(name="pylint", tags=["lint"], python=self._default_python_version)
+        @self._install_package
+        @install_group("pylint")
+        def pylint(sess: Session) -> None:
+            """Run pylint."""
+            sess.run("pylint", "--score=no", *self._code_dirs)
+
+    def pydocstyle(self) -> None:
+        @session(name="pydocstyle", tags=["lint"], python=self._default_python_version)
+        @install_group("pydocstyle")
+        def pydocstyle(sess: Session) -> None:
+            """Run pydocstyle."""
+            sess.run("pydocstyle", *self._code_dirs)
+
+    def smoketest(self) -> None:
+        @session(name="smoketest", tags=["test"], python=self._default_python_version)
+        @self._install_package
+        def smoketest(sess: Session) -> None:
+            """Run a simple smoke-test of the package."""
+            sess.run("python", "-c", self._smoketest_script)
+
+    def release_smoketest(self) -> None:
+        @session(
+            name="release-smoketest",
+            tags=["release"],
+            python=self._default_python_version,
+        )
+        @self._install_package_release
+        def smoketest(sess: Session) -> None:
+            """Run a simple smoke-test of the package."""
+            sess.run("python", "-c", self._smoketest_script)
+
+    def _test(
+        self,
+        sess: Session,
+        markers: str = "",
+        *,
+        min_coverage: Optional[int] = None,
+        extra_args: Optional[list[str]] = None,
+    ) -> None:
+        test_paths = self._code_dirs
+        extra_args = extra_args or []
+        if sess.posargs:
+            test_paths = []
+            extra_args.extend(sess.posargs)
+        args: list[Union[str, Path]] = [
+            "-r fEs",
+            "--verbose",
+            "--disable-warnings",
+            f"--junitxml={self._directory}/junit.xml",
+            "--durations=10",
+            f"--cov={self._package}",
+            "--cov-fail-under={}".format(
+                self._min_coverage if min_coverage is None else min_coverage
+            ),
+            "--cov-report=term",
+            f"--cov-report=html:{self._directory}/coverage/",
+            f"--cov-report=xml:{self._directory}/coverage.xml",
+            *extra_args,
+            *test_paths,
+        ]
+        cpus = num_cpus(sess)
+        if self._parallel_tests and cpus > 3:
+            args.append(f"--numprocesses={cpus//2}")
+        if markers:
+            args.extend(["-m", markers])
+
+        sess.run("pytest", *args)
+
+    def test(self) -> None:
+        @session(name="test", python=self._default_python_version)
+        @self._install_package
+        @install_group("test")
+        def test(sess: Session) -> None:
+            """Run all tests."""
+            self._test(sess)
+
+    def test_fast(self) -> None:
+        @session(name="test-fast", python=self._default_python_version)
+        @self._install_package
+        @install_group("test")
+        def test_fast(sess: Session) -> None:
+            """Run fast tests."""
+            self._test(sess, "not slow")
+
+    def test_slow(self) -> None:
+        @session(name="test-slow", python=self._default_python_version)
+        @self._install_package
+        @install_group("test")
+        def test_slow(sess: Session) -> None:
+            """Run slow tests."""
+            self._test(sess, "slow", min_coverage=0)
+
+    def test_doctest(self) -> None:
+        @session(
+            name="test-doctest", tags=["test"], python=self._default_python_version
+        )
+        @self._install_package
+        @install_group("test")
+        def test_doctest(sess: Session) -> None:
+            """Run tests on code examples in docstrings."""
+            self._test(sess, "slow", min_coverage=0, extra_args=["--doctest-modules"])
path: nox-utils/src/tmlt/nox_utils/__init__.py
user: login: tmager
body: Don't write repetitive code when you're tired, kids. Fixed.
created_at: 2025-05-13T22:13:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,30 @@
+"""Utilities for inspecting the environment where nox is running."""
+import os
+import platform
+import subprocess
+
+from nox import Session
+
+
+def in_ci() -> bool:
+    """Return whether nox is running in a CI pipeline."""
+    return bool(os.environ.get("CI"))
path: nox-utils/src/tmlt/nox_utils/environment.py
user: login: tmager
body: It should: https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#default-environment-variables
created_at: 2025-05-13T22:13:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,135 @@
+[project]
+name = "tmlt-nox-utils"
+version = "0.0.0"
+description = ""
+authors = []
+readme = "README.md"
+
+[tool.poetry]
+requires-poetry = ">=2.0"
+packages = [{include = "tmlt", from="src"}]
+
+[tool.poetry.dependencies]
+python = "^3.9"
+
+nox = ">=2022.8.7"
path: nox-utils/pyproject.toml
user: login: tmager
body: At present `nox_utils` should be pretty independent of exactly how you install and manage your Pythons -- if `uv` is available `nox` will use it, but it should work regardless. IMO that's a feature, and at least right now we shouldn't require `uv` to be available for this to work. If we were to switch over to it for all of our packaging I might change that position, but even then IMO we should rely on the user's main `uv` installation rather than installing our own version.
created_at: 2025-05-13T22:14:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,135 @@
+[project]
+name = "tmlt-nox-utils"
+version = "0.0.0"
+description = ""
+authors = []
+readme = "README.md"
+
+[tool.poetry]
+requires-poetry = ">=2.0"
+packages = [{include = "tmlt", from="src"}]
+
+[tool.poetry.dependencies]
+python = "^3.9"
+
+nox = ">=2022.8.7"
path: nox-utils/pyproject.toml
user: login: Maegereg
body: Gotcha, sounds reasonable.
created_at: 2025-05-13T22:22:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,407 @@
+"""A central, largely package-agnostic collection of nox utilities.
+
+This module defines SessionManager, which takes in a collection of configuration
+options and exposes methods that can be called to generate nox sessions.
+"""
+
+import subprocess
+from functools import wraps
+from pathlib import Path
+from typing import Any, Callable, Optional, Union
+
+import nox
+from nox import Session, session
+
+from .dependencies import install_group, show_installed
+from .environment import in_ci, num_cpus
+
+
+class SessionManager:
+    """Class for creating common Nox sessions based on project-specific configuration.
+
+    To add a session to a particular noxfile, create an instance of this class
+    using your desired options, then call the methods corresponding to the
+    sessions you want.
+
+    This class leverages the fact that *any* function that is defined with the
+    ``@session`` decorator is picked up as a Nox session, no matter where it is
+    defined. By wrapping these definitions inside of class methods, we can
+    access the class attributes to do project-specific things while still
+    maintaining a common collection of sessions that can be reused across
+    projects, and can avoid needing boilerplate in the noxfile itself.
+    """
+
+    def __init__(
+        self,
+        package: str,
+        directory: Path,
+        *,
+        default_python_version: str = "3.9",
+        custom_build: Optional[Callable[[Session], None]] = None,
+        smoketest_script: Optional[str] = None,
+        parallel_tests: bool = True,
+        min_coverage: int = 80,
+        audit_versions: Optional[list[str]] = None,
+        audit_suppressions: Optional[list[str]] = None,
+    ) -> None:
+        """Configure options for generating nox sessions.
+
+        Args:
+            package: The name of the package, e.g. "tmlt.core"
+            directory: The root directory of the package
+            default_python_version: The Python minor version that will be used by
+                default for most generated sessions. In general, this should be the
+                oldest supported Python version.
+            custom_build: A function that overrides the standard build process.
+            smoketest_script: A Python program to execute as a smoke-test for an
+                installation of the package -- this should be the script
+                content, not the path to an external script. It is considered to
+                pass as long as executing the script with `python -c` doesn't
+                produce an error. If unset, defaults to just importing the package.
+            parallel_tests: Controls whether tests should be run in parallel.
+            min_coverage: The minimum coverage needed for the test/test-fast sessions
+                to pass.
+            audit_versions: A list of Python minor versions on which to audit package
+                dependencies for vulnerabilities. If unset, defaults to only
+                ``default_python_version``.
+            audit_suppressions: A list of vulnerability IDs to ignore.
+        """
+        self._package = package
+        self._package_version = (
+            subprocess.run(["poetry", "version", "-s"], capture_output=True, check=True)
+            .stdout.decode("utf-8")
+            .strip()
+        )
+        self._directory = directory
+        self._default_python_version = default_python_version
+        if custom_build is not None and not callable(custom_build):
+            raise ValueError(
+                f"Provided custom_build must be callable, but '{custom_build}' is not"
+            )
+        self._custom_build = custom_build
+        self._smoketest_script = smoketest_script or f"import {self._package}"
+        self._parallel_tests = parallel_tests
+        self._min_coverage = min_coverage
+        self._audit_versions = audit_versions or [default_python_version]
+        self._audit_suppressions = audit_suppressions or []
+
+    @property
+    def _code_dirs(self) -> list[Path]:
+        return [
+            p
+            for p in [
+                self._directory / "src",
+                self._directory / "test",
+            ]
+            if p.exists()
+        ]
+
+    def _build(self, sess: Session) -> None:
+        """Build sdists and wheels for the package.
+
+        If the build process for a package requires custom logic, pass the
+        "build" option to SessionBuilder, with the value being function taking
+        one argument (a nox Session) that builds the package. If this option is
+        not passed, the package is just built with `poetry build`.
+        """
+        if self._custom_build is not None:
+            sess.log("Using custom build function")
+            self._custom_build(sess)
+        else:
+            sess.run("poetry", "build", external=True)
+
+    def _install_package(self, f: Callable[[Session], Any]) -> Callable[[Session], Any]:
+        """Install the main package and its dependencies from Poetry lock."""
+
+        @wraps(f)
+        def inner(sess: Session) -> Any:
+            if not sess.virtualenv.is_sandboxed:
+                sess.log(
+                    "Not in a sandboxed environment, skipping package installation"
+                )
+                return f(sess)
+
+            # In the CI we want to use the wheel from the pipeline, but locally
+            # it's easier to let Poetry handle figuring out what to install and
+            # how to build it if needed.
+            if not in_ci():
+                sess.run_install("poetry", "install", "--only", "main", external=True)
+                return f(sess)
+
+            temp_dir = sess.create_tmp()
+            package = f"{self._package}=={self._package_version}"
+            out = sess.run(
+                "pip",
+                "download",
+                package,
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "-d",
+                temp_dir,
+                "--no-deps",
+                silent=True,
+                success_codes=[0, 1],
+                external=True,
+            )
+            assert out is not None, "sess.run should not return None when silent=True"
+            if "No matching distribution" in out:
+                sess.error(
+                    f"No wheel found for {package}; wheel must exist when running in CI"
+                )
+
+            sess.install(
+                f"{self._package}=={self._package_version}",
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "--no-deps",
+            )
+            return install_group("main")(f)(sess)
+
+        return inner
+
+    def _install_package_release(
path: nox-utils/src/tmlt/nox_utils/__init__.py
user: login: Maegereg
body: `install_package_with_poetry_deps` and `install_package_with_pip_deps`?
created_at: 2025-05-13T22:23:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,3 @@
+# Nox utilities
+
+A collection of common utilities and sessions for [`nox`](https://nox.thea.codes/en/stable/).
path: nox-utils/README.md
user: login: Maegereg
body: Ah, I missed that. That's fine then.
created_at: 2025-05-13T22:24:47Z
author_association: CONTRIBUTOR
path: nox-utils/src/tmlt/nox_utils/__init__.py
user: login: Maegereg
body: Excellent!
created_at: 2025-05-13T22:25:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,407 @@
+"""A central, largely package-agnostic collection of nox utilities.
+
+This module defines SessionManager, which takes in a collection of configuration
+options and exposes methods that can be called to generate nox sessions.
+"""
+
+import subprocess
+from functools import wraps
+from pathlib import Path
+from typing import Any, Callable, Optional, Union
+
+import nox
+from nox import Session, session
+
+from .dependencies import install_group, show_installed
+from .environment import in_ci, num_cpus
+
+
+class SessionManager:
+    """Class for creating common Nox sessions based on project-specific configuration.
+
+    To add a session to a particular noxfile, create an instance of this class
+    using your desired options, then call the methods corresponding to the
+    sessions you want.
+
+    This class leverages the fact that *any* function that is defined with the
+    ``@session`` decorator is picked up as a Nox session, no matter where it is
+    defined. By wrapping these definitions inside of class methods, we can
+    access the class attributes to do project-specific things while still
+    maintaining a common collection of sessions that can be reused across
+    projects, and can avoid needing boilerplate in the noxfile itself.
+    """
+
+    def __init__(
+        self,
+        package: str,
+        directory: Path,
+        *,
+        default_python_version: str = "3.9",
+        custom_build: Optional[Callable[[Session], None]] = None,
+        smoketest_script: Optional[str] = None,
+        parallel_tests: bool = True,
+        min_coverage: int = 80,
+        audit_versions: Optional[list[str]] = None,
+        audit_suppressions: Optional[list[str]] = None,
+    ) -> None:
+        """Configure options for generating nox sessions.
+
+        Args:
+            package: The name of the package, e.g. "tmlt.core"
+            directory: The root directory of the package
+            default_python_version: The Python minor version that will be used by
+                default for most generated sessions. In general, this should be the
+                oldest supported Python version.
+            custom_build: A function that overrides the standard build process.
path: nox-utils/src/tmlt/nox_utils/__init__.py
user: login: Maegereg
body: Did these changes not get pushed?
created_at: 2025-05-13T22:30:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,407 @@
+"""A central, largely package-agnostic collection of nox utilities.
+
+This module defines SessionManager, which takes in a collection of configuration
+options and exposes methods that can be called to generate nox sessions.
+"""
+
+import subprocess
+from functools import wraps
+from pathlib import Path
+from typing import Any, Callable, Optional, Union
+
+import nox
+from nox import Session, session
+
+from .dependencies import install_group, show_installed
+from .environment import in_ci, num_cpus
+
+
+class SessionManager:
+    """Class for creating common Nox sessions based on project-specific configuration.
+
+    To add a session to a particular noxfile, create an instance of this class
+    using your desired options, then call the methods corresponding to the
+    sessions you want.
+
+    This class leverages the fact that *any* function that is defined with the
+    ``@session`` decorator is picked up as a Nox session, no matter where it is
+    defined. By wrapping these definitions inside of class methods, we can
+    access the class attributes to do project-specific things while still
+    maintaining a common collection of sessions that can be reused across
+    projects, and can avoid needing boilerplate in the noxfile itself.
+    """
+
+    def __init__(
+        self,
+        package: str,
+        directory: Path,
+        *,
+        default_python_version: str = "3.9",
+        custom_build: Optional[Callable[[Session], None]] = None,
+        smoketest_script: Optional[str] = None,
+        parallel_tests: bool = True,
+        min_coverage: int = 80,
+        audit_versions: Optional[list[str]] = None,
+        audit_suppressions: Optional[list[str]] = None,
+    ) -> None:
+        """Configure options for generating nox sessions.
+
+        Args:
+            package: The name of the package, e.g. "tmlt.core"
+            directory: The root directory of the package
+            default_python_version: The Python minor version that will be used by
+                default for most generated sessions. In general, this should be the
+                oldest supported Python version.
+            custom_build: A function that overrides the standard build process.
+            smoketest_script: A Python program to execute as a smoke-test for an
+                installation of the package -- this should be the script
+                content, not the path to an external script. It is considered to
+                pass as long as executing the script with `python -c` doesn't
+                produce an error. If unset, defaults to just importing the package.
+            parallel_tests: Controls whether tests should be run in parallel.
+            min_coverage: The minimum coverage needed for the test/test-fast sessions
+                to pass.
+            audit_versions: A list of Python minor versions on which to audit package
+                dependencies for vulnerabilities. If unset, defaults to only
+                ``default_python_version``.
+            audit_suppressions: A list of vulnerability IDs to ignore.
+        """
+        self._package = package
+        self._package_version = (
+            subprocess.run(["poetry", "version", "-s"], capture_output=True, check=True)
+            .stdout.decode("utf-8")
+            .strip()
+        )
+        self._directory = directory
+        self._default_python_version = default_python_version
+        if custom_build is not None and not callable(custom_build):
+            raise ValueError(
+                f"Provided custom_build must be callable, but '{custom_build}' is not"
+            )
+        self._custom_build = custom_build
+        self._smoketest_script = smoketest_script or f"import {self._package}"
+        self._parallel_tests = parallel_tests
+        self._min_coverage = min_coverage
+        self._audit_versions = audit_versions or [default_python_version]
+        self._audit_suppressions = audit_suppressions or []
+
+    @property
+    def _code_dirs(self) -> list[Path]:
+        return [
+            p
+            for p in [
+                self._directory / "src",
+                self._directory / "test",
+            ]
+            if p.exists()
+        ]
+
+    def _build(self, sess: Session) -> None:
+        """Build sdists and wheels for the package.
+
+        If the build process for a package requires custom logic, pass the
+        "build" option to SessionBuilder, with the value being function taking
+        one argument (a nox Session) that builds the package. If this option is
+        not passed, the package is just built with `poetry build`.
+        """
+        if self._custom_build is not None:
+            sess.log("Using custom build function")
+            self._custom_build(sess)
+        else:
+            sess.run("poetry", "build", external=True)
+
+    def _install_package(self, f: Callable[[Session], Any]) -> Callable[[Session], Any]:
+        """Install the main package and its dependencies from Poetry lock."""
+
+        @wraps(f)
+        def inner(sess: Session) -> Any:
+            if not sess.virtualenv.is_sandboxed:
+                sess.log(
+                    "Not in a sandboxed environment, skipping package installation"
+                )
+                return f(sess)
+
+            # In the CI we want to use the wheel from the pipeline, but locally
+            # it's easier to let Poetry handle figuring out what to install and
+            # how to build it if needed.
+            if not in_ci():
+                sess.run_install("poetry", "install", "--only", "main", external=True)
+                return f(sess)
+
+            temp_dir = sess.create_tmp()
+            package = f"{self._package}=={self._package_version}"
+            out = sess.run(
+                "pip",
+                "download",
+                package,
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "-d",
+                temp_dir,
+                "--no-deps",
+                silent=True,
+                success_codes=[0, 1],
+                external=True,
+            )
+            assert out is not None, "sess.run should not return None when silent=True"
+            if "No matching distribution" in out:
+                sess.error(
+                    f"No wheel found for {package}; wheel must exist when running in CI"
+                )
+
+            sess.install(
+                f"{self._package}=={self._package_version}",
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "--no-deps",
+            )
+            return install_group("main")(f)(sess)
+
+        return inner
+
+    def _install_package_release(
+        self, f: Callable[[Session], Any]
+    ) -> Callable[[Session], Any]:
+        """Install the main package and its dependencies as resolved by pip.
+
+        Unlike _install_package, this method ignores the Poetry lock and relies
+        on pip to install whatever versions of dependencies it likes. Also
+        unlike _install_package, this method will only install from a built
+        wheel in dist/ -- locally, it will try to build a new wheel if it can't
+        find one.
+        """
+
+        @wraps(f)
+        def inner(sess: Session) -> Any:
+            if not sess.virtualenv.is_sandboxed:
+                sess.error("Release checks cannot be run with --no-venv")
+
+            temp_dir = sess.create_tmp()
+            package = f"{self._package}=={self._package_version}"
+            out = sess.run(
+                "pip",
+                "download",
+                package,
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "-d",
+                temp_dir,
+                "--no-deps",
+                silent=True,
+                success_codes=[0, 1],
+                external=True,
+            )
+            assert out is not None, "sess.run should not return None when silent=True"
+            if "No matching distribution" in out:
+                if in_ci():
+                    sess.error(
+                        f"No wheel found for {package}; "
+                        "wheel must exist when running in CI"
+                    )
+                else:
+                    sess.log("No wheel found, building one")
+                    self._build(sess)
+
+            sess.install(
+                f"{self._package}=={self._package_version}",
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+            )
+            return f(sess)
+
+        return inner
+
+    def build(self) -> None:
+        @session(name="build", python=self._default_python_version)
+        def build(sess: Session) -> None:
+            """Build source distributions and wheels for this package."""
+            self._build(sess)
+
+    def black(self) -> None:
+        @session(name="black", tags=["lint"], python=self._default_python_version)
+        @install_group("black")
+        def black(sess: Session) -> None:
+            """Run black."""
+            check_flags = ["--check", "--diff"] if "--check" in sess.posargs else []
+            sess.run("black", *check_flags, *self._code_dirs)
+
+    def isort(self) -> None:
+        @session(name="isort", tags=["lint"], python=self._default_python_version)
+        @install_group("isort")
+        def isort(sess: Session) -> None:
+            """Run isort."""
+            check_flags = ["--check", "--diff"] if "--check" in sess.posargs else []
+            sess.run("isort", *check_flags, *self._code_dirs)
+
+    def mypy(self) -> None:
+        @session(name="mypy", tags=["lint"], python=self._default_python_version)
+        @self._install_package
+        @install_group("mypy")
+        def mypy(sess: Session) -> None:
+            """Run mypy."""
+            sess.run("mypy", *self._code_dirs)
+
+    def pylint(self) -> None:
+        @session(name="pylint", tags=["lint"], python=self._default_python_version)
+        @self._install_package
+        @install_group("pylint")
+        def pylint(sess: Session) -> None:
+            """Run pylint."""
+            sess.run("pylint", "--score=no", *self._code_dirs)
+
+    def pydocstyle(self) -> None:
+        @session(name="pydocstyle", tags=["lint"], python=self._default_python_version)
+        @install_group("pydocstyle")
+        def pydocstyle(sess: Session) -> None:
+            """Run pydocstyle."""
+            sess.run("pydocstyle", *self._code_dirs)
+
+    def smoketest(self) -> None:
+        @session(name="smoketest", tags=["test"], python=self._default_python_version)
+        @self._install_package
+        def smoketest(sess: Session) -> None:
+            """Run a simple smoke-test of the package."""
+            sess.run("python", "-c", self._smoketest_script)
+
+    def release_smoketest(self) -> None:
+        @session(
+            name="release-smoketest",
+            tags=["release"],
+            python=self._default_python_version,
+        )
+        @self._install_package_release
+        def smoketest(sess: Session) -> None:
+            """Run a simple smoke-test of the package."""
+            sess.run("python", "-c", self._smoketest_script)
path: nox-utils/src/tmlt/nox_utils/__init__.py
user: login: Maegereg
body: Did we use to use both of these?
created_at: 2025-05-13T22:31:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,407 @@
+"""A central, largely package-agnostic collection of nox utilities.
+
+This module defines SessionManager, which takes in a collection of configuration
+options and exposes methods that can be called to generate nox sessions.
+"""
+
+import subprocess
+from functools import wraps
+from pathlib import Path
+from typing import Any, Callable, Optional, Union
+
+import nox
+from nox import Session, session
+
+from .dependencies import install_group, show_installed
+from .environment import in_ci, num_cpus
+
+
+class SessionManager:
+    """Class for creating common Nox sessions based on project-specific configuration.
+
+    To add a session to a particular noxfile, create an instance of this class
+    using your desired options, then call the methods corresponding to the
+    sessions you want.
+
+    This class leverages the fact that *any* function that is defined with the
+    ``@session`` decorator is picked up as a Nox session, no matter where it is
+    defined. By wrapping these definitions inside of class methods, we can
+    access the class attributes to do project-specific things while still
+    maintaining a common collection of sessions that can be reused across
+    projects, and can avoid needing boilerplate in the noxfile itself.
+    """
+
+    def __init__(
+        self,
+        package: str,
+        directory: Path,
+        *,
+        default_python_version: str = "3.9",
+        custom_build: Optional[Callable[[Session], None]] = None,
+        smoketest_script: Optional[str] = None,
+        parallel_tests: bool = True,
+        min_coverage: int = 80,
+        audit_versions: Optional[list[str]] = None,
+        audit_suppressions: Optional[list[str]] = None,
+    ) -> None:
+        """Configure options for generating nox sessions.
+
+        Args:
+            package: The name of the package, e.g. "tmlt.core"
+            directory: The root directory of the package
+            default_python_version: The Python minor version that will be used by
+                default for most generated sessions. In general, this should be the
+                oldest supported Python version.
+            custom_build: A function that overrides the standard build process.
path: nox-utils/src/tmlt/nox_utils/__init__.py
user: login: tmager
body: I added it to the body of the docstring rather than inline in the argument docs; the description was long enough that it was getting kind of annoying to read in the several-times-indented argument list. Open to moving it, though.
created_at: 2025-05-13T23:06:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,407 @@
+"""A central, largely package-agnostic collection of nox utilities.
+
+This module defines SessionManager, which takes in a collection of configuration
+options and exposes methods that can be called to generate nox sessions.
+"""
+
+import subprocess
+from functools import wraps
+from pathlib import Path
+from typing import Any, Callable, Optional, Union
+
+import nox
+from nox import Session, session
+
+from .dependencies import install_group, show_installed
+from .environment import in_ci, num_cpus
+
+
+class SessionManager:
+    """Class for creating common Nox sessions based on project-specific configuration.
+
+    To add a session to a particular noxfile, create an instance of this class
+    using your desired options, then call the methods corresponding to the
+    sessions you want.
+
+    This class leverages the fact that *any* function that is defined with the
+    ``@session`` decorator is picked up as a Nox session, no matter where it is
+    defined. By wrapping these definitions inside of class methods, we can
+    access the class attributes to do project-specific things while still
+    maintaining a common collection of sessions that can be reused across
+    projects, and can avoid needing boilerplate in the noxfile itself.
+    """
+
+    def __init__(
+        self,
+        package: str,
+        directory: Path,
+        *,
+        default_python_version: str = "3.9",
+        custom_build: Optional[Callable[[Session], None]] = None,
+        smoketest_script: Optional[str] = None,
+        parallel_tests: bool = True,
+        min_coverage: int = 80,
+        audit_versions: Optional[list[str]] = None,
+        audit_suppressions: Optional[list[str]] = None,
+    ) -> None:
+        """Configure options for generating nox sessions.
+
+        Args:
+            package: The name of the package, e.g. "tmlt.core"
+            directory: The root directory of the package
+            default_python_version: The Python minor version that will be used by
+                default for most generated sessions. In general, this should be the
+                oldest supported Python version.
+            custom_build: A function that overrides the standard build process.
+            smoketest_script: A Python program to execute as a smoke-test for an
+                installation of the package -- this should be the script
+                content, not the path to an external script. It is considered to
+                pass as long as executing the script with `python -c` doesn't
+                produce an error. If unset, defaults to just importing the package.
+            parallel_tests: Controls whether tests should be run in parallel.
+            min_coverage: The minimum coverage needed for the test/test-fast sessions
+                to pass.
+            audit_versions: A list of Python minor versions on which to audit package
+                dependencies for vulnerabilities. If unset, defaults to only
+                ``default_python_version``.
+            audit_suppressions: A list of vulnerability IDs to ignore.
+        """
+        self._package = package
+        self._package_version = (
+            subprocess.run(["poetry", "version", "-s"], capture_output=True, check=True)
+            .stdout.decode("utf-8")
+            .strip()
+        )
+        self._directory = directory
+        self._default_python_version = default_python_version
+        if custom_build is not None and not callable(custom_build):
+            raise ValueError(
+                f"Provided custom_build must be callable, but '{custom_build}' is not"
+            )
+        self._custom_build = custom_build
+        self._smoketest_script = smoketest_script or f"import {self._package}"
+        self._parallel_tests = parallel_tests
+        self._min_coverage = min_coverage
+        self._audit_versions = audit_versions or [default_python_version]
+        self._audit_suppressions = audit_suppressions or []
+
+    @property
+    def _code_dirs(self) -> list[Path]:
+        return [
+            p
+            for p in [
+                self._directory / "src",
+                self._directory / "test",
+            ]
+            if p.exists()
+        ]
+
+    def _build(self, sess: Session) -> None:
+        """Build sdists and wheels for the package.
+
+        If the build process for a package requires custom logic, pass the
+        "build" option to SessionBuilder, with the value being function taking
+        one argument (a nox Session) that builds the package. If this option is
+        not passed, the package is just built with `poetry build`.
+        """
+        if self._custom_build is not None:
+            sess.log("Using custom build function")
+            self._custom_build(sess)
+        else:
+            sess.run("poetry", "build", external=True)
+
+    def _install_package(self, f: Callable[[Session], Any]) -> Callable[[Session], Any]:
+        """Install the main package and its dependencies from Poetry lock."""
+
+        @wraps(f)
+        def inner(sess: Session) -> Any:
+            if not sess.virtualenv.is_sandboxed:
+                sess.log(
+                    "Not in a sandboxed environment, skipping package installation"
+                )
+                return f(sess)
+
+            # In the CI we want to use the wheel from the pipeline, but locally
+            # it's easier to let Poetry handle figuring out what to install and
+            # how to build it if needed.
+            if not in_ci():
+                sess.run_install("poetry", "install", "--only", "main", external=True)
+                return f(sess)
+
+            temp_dir = sess.create_tmp()
+            package = f"{self._package}=={self._package_version}"
+            out = sess.run(
+                "pip",
+                "download",
+                package,
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "-d",
+                temp_dir,
+                "--no-deps",
+                silent=True,
+                success_codes=[0, 1],
+                external=True,
+            )
+            assert out is not None, "sess.run should not return None when silent=True"
+            if "No matching distribution" in out:
+                sess.error(
+                    f"No wheel found for {package}; wheel must exist when running in CI"
+                )
+
+            sess.install(
+                f"{self._package}=={self._package_version}",
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "--no-deps",
+            )
+            return install_group("main")(f)(sess)
+
+        return inner
+
+    def _install_package_release(
path: nox-utils/src/tmlt/nox_utils/__init__.py
user: login: tmager
body: Or maybe `install_package`/`install_package_with_unlocked_deps` or something? The former is the one you want the vast majority of the time.
created_at: 2025-05-13T23:10:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,407 @@
+"""A central, largely package-agnostic collection of nox utilities.
+
+This module defines SessionManager, which takes in a collection of configuration
+options and exposes methods that can be called to generate nox sessions.
+"""
+
+import subprocess
+from functools import wraps
+from pathlib import Path
+from typing import Any, Callable, Optional, Union
+
+import nox
+from nox import Session, session
+
+from .dependencies import install_group, show_installed
+from .environment import in_ci, num_cpus
+
+
+class SessionManager:
+    """Class for creating common Nox sessions based on project-specific configuration.
+
+    To add a session to a particular noxfile, create an instance of this class
+    using your desired options, then call the methods corresponding to the
+    sessions you want.
+
+    This class leverages the fact that *any* function that is defined with the
+    ``@session`` decorator is picked up as a Nox session, no matter where it is
+    defined. By wrapping these definitions inside of class methods, we can
+    access the class attributes to do project-specific things while still
+    maintaining a common collection of sessions that can be reused across
+    projects, and can avoid needing boilerplate in the noxfile itself.
+    """
+
+    def __init__(
+        self,
+        package: str,
+        directory: Path,
+        *,
+        default_python_version: str = "3.9",
+        custom_build: Optional[Callable[[Session], None]] = None,
+        smoketest_script: Optional[str] = None,
+        parallel_tests: bool = True,
+        min_coverage: int = 80,
+        audit_versions: Optional[list[str]] = None,
+        audit_suppressions: Optional[list[str]] = None,
+    ) -> None:
+        """Configure options for generating nox sessions.
+
+        Args:
+            package: The name of the package, e.g. "tmlt.core"
+            directory: The root directory of the package
+            default_python_version: The Python minor version that will be used by
+                default for most generated sessions. In general, this should be the
+                oldest supported Python version.
+            custom_build: A function that overrides the standard build process.
+            smoketest_script: A Python program to execute as a smoke-test for an
+                installation of the package -- this should be the script
+                content, not the path to an external script. It is considered to
+                pass as long as executing the script with `python -c` doesn't
+                produce an error. If unset, defaults to just importing the package.
+            parallel_tests: Controls whether tests should be run in parallel.
+            min_coverage: The minimum coverage needed for the test/test-fast sessions
+                to pass.
+            audit_versions: A list of Python minor versions on which to audit package
+                dependencies for vulnerabilities. If unset, defaults to only
+                ``default_python_version``.
+            audit_suppressions: A list of vulnerability IDs to ignore.
+        """
+        self._package = package
+        self._package_version = (
+            subprocess.run(["poetry", "version", "-s"], capture_output=True, check=True)
+            .stdout.decode("utf-8")
+            .strip()
+        )
+        self._directory = directory
+        self._default_python_version = default_python_version
+        if custom_build is not None and not callable(custom_build):
+            raise ValueError(
+                f"Provided custom_build must be callable, but '{custom_build}' is not"
+            )
+        self._custom_build = custom_build
+        self._smoketest_script = smoketest_script or f"import {self._package}"
+        self._parallel_tests = parallel_tests
+        self._min_coverage = min_coverage
+        self._audit_versions = audit_versions or [default_python_version]
+        self._audit_suppressions = audit_suppressions or []
+
+    @property
+    def _code_dirs(self) -> list[Path]:
+        return [
+            p
+            for p in [
+                self._directory / "src",
+                self._directory / "test",
+            ]
+            if p.exists()
+        ]
+
+    def _build(self, sess: Session) -> None:
+        """Build sdists and wheels for the package.
+
+        If the build process for a package requires custom logic, pass the
+        "build" option to SessionBuilder, with the value being function taking
+        one argument (a nox Session) that builds the package. If this option is
+        not passed, the package is just built with `poetry build`.
+        """
+        if self._custom_build is not None:
+            sess.log("Using custom build function")
+            self._custom_build(sess)
+        else:
+            sess.run("poetry", "build", external=True)
+
+    def _install_package(self, f: Callable[[Session], Any]) -> Callable[[Session], Any]:
+        """Install the main package and its dependencies from Poetry lock."""
+
+        @wraps(f)
+        def inner(sess: Session) -> Any:
+            if not sess.virtualenv.is_sandboxed:
+                sess.log(
+                    "Not in a sandboxed environment, skipping package installation"
+                )
+                return f(sess)
+
+            # In the CI we want to use the wheel from the pipeline, but locally
+            # it's easier to let Poetry handle figuring out what to install and
+            # how to build it if needed.
+            if not in_ci():
+                sess.run_install("poetry", "install", "--only", "main", external=True)
+                return f(sess)
+
+            temp_dir = sess.create_tmp()
+            package = f"{self._package}=={self._package_version}"
+            out = sess.run(
+                "pip",
+                "download",
+                package,
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "-d",
+                temp_dir,
+                "--no-deps",
+                silent=True,
+                success_codes=[0, 1],
+                external=True,
+            )
+            assert out is not None, "sess.run should not return None when silent=True"
+            if "No matching distribution" in out:
+                sess.error(
+                    f"No wheel found for {package}; wheel must exist when running in CI"
+                )
+
+            sess.install(
+                f"{self._package}=={self._package_version}",
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "--no-deps",
+            )
+            return install_group("main")(f)(sess)
+
+        return inner
+
+    def _install_package_release(
+        self, f: Callable[[Session], Any]
+    ) -> Callable[[Session], Any]:
+        """Install the main package and its dependencies as resolved by pip.
+
+        Unlike _install_package, this method ignores the Poetry lock and relies
+        on pip to install whatever versions of dependencies it likes. Also
+        unlike _install_package, this method will only install from a built
+        wheel in dist/ -- locally, it will try to build a new wheel if it can't
+        find one.
+        """
+
+        @wraps(f)
+        def inner(sess: Session) -> Any:
+            if not sess.virtualenv.is_sandboxed:
+                sess.error("Release checks cannot be run with --no-venv")
+
+            temp_dir = sess.create_tmp()
+            package = f"{self._package}=={self._package_version}"
+            out = sess.run(
+                "pip",
+                "download",
+                package,
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "-d",
+                temp_dir,
+                "--no-deps",
+                silent=True,
+                success_codes=[0, 1],
+                external=True,
+            )
+            assert out is not None, "sess.run should not return None when silent=True"
+            if "No matching distribution" in out:
+                if in_ci():
+                    sess.error(
+                        f"No wheel found for {package}; "
+                        "wheel must exist when running in CI"
+                    )
+                else:
+                    sess.log("No wheel found, building one")
+                    self._build(sess)
+
+            sess.install(
+                f"{self._package}=={self._package_version}",
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+            )
+            return f(sess)
+
+        return inner
+
+    def build(self) -> None:
+        @session(name="build", python=self._default_python_version)
+        def build(sess: Session) -> None:
+            """Build source distributions and wheels for this package."""
+            self._build(sess)
+
+    def black(self) -> None:
+        @session(name="black", tags=["lint"], python=self._default_python_version)
+        @install_group("black")
+        def black(sess: Session) -> None:
+            """Run black."""
+            check_flags = ["--check", "--diff"] if "--check" in sess.posargs else []
+            sess.run("black", *check_flags, *self._code_dirs)
+
+    def isort(self) -> None:
+        @session(name="isort", tags=["lint"], python=self._default_python_version)
+        @install_group("isort")
+        def isort(sess: Session) -> None:
+            """Run isort."""
+            check_flags = ["--check", "--diff"] if "--check" in sess.posargs else []
+            sess.run("isort", *check_flags, *self._code_dirs)
+
+    def mypy(self) -> None:
+        @session(name="mypy", tags=["lint"], python=self._default_python_version)
+        @self._install_package
+        @install_group("mypy")
+        def mypy(sess: Session) -> None:
+            """Run mypy."""
+            sess.run("mypy", *self._code_dirs)
+
+    def pylint(self) -> None:
+        @session(name="pylint", tags=["lint"], python=self._default_python_version)
+        @self._install_package
+        @install_group("pylint")
+        def pylint(sess: Session) -> None:
+            """Run pylint."""
+            sess.run("pylint", "--score=no", *self._code_dirs)
+
+    def pydocstyle(self) -> None:
+        @session(name="pydocstyle", tags=["lint"], python=self._default_python_version)
+        @install_group("pydocstyle")
+        def pydocstyle(sess: Session) -> None:
+            """Run pydocstyle."""
+            sess.run("pydocstyle", *self._code_dirs)
+
+    def smoketest(self) -> None:
+        @session(name="smoketest", tags=["test"], python=self._default_python_version)
+        @self._install_package
+        def smoketest(sess: Session) -> None:
+            """Run a simple smoke-test of the package."""
+            sess.run("python", "-c", self._smoketest_script)
+
+    def release_smoketest(self) -> None:
+        @session(
+            name="release-smoketest",
+            tags=["release"],
+            python=self._default_python_version,
+        )
+        @self._install_package_release
+        def smoketest(sess: Session) -> None:
+            """Run a simple smoke-test of the package."""
+            sess.run("python", "-c", self._smoketest_script)
path: nox-utils/src/tmlt/nox_utils/__init__.py
user: login: tmager
body: IIRC we generally ran these prior to the main test suite (`smoketest` for normal tests and `release_smoketest` for releases) as a quicker indicator of "this wheel is completely messed up" than running all of the tests. We could just drop this idea entirely or handle that case another way (e.g. telling `pytest` to stop running after x failures), I don't recall the smoketests catching that many bugs.
created_at: 2025-05-13T23:14:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,407 @@
+"""A central, largely package-agnostic collection of nox utilities.
+
+This module defines SessionManager, which takes in a collection of configuration
+options and exposes methods that can be called to generate nox sessions.
+"""
+
+import subprocess
+from functools import wraps
+from pathlib import Path
+from typing import Any, Callable, Optional, Union
+
+import nox
+from nox import Session, session
+
+from .dependencies import install_group, show_installed
+from .environment import in_ci, num_cpus
+
+
+class SessionManager:
+    """Class for creating common Nox sessions based on project-specific configuration.
+
+    To add a session to a particular noxfile, create an instance of this class
+    using your desired options, then call the methods corresponding to the
+    sessions you want.
+
+    This class leverages the fact that *any* function that is defined with the
+    ``@session`` decorator is picked up as a Nox session, no matter where it is
+    defined. By wrapping these definitions inside of class methods, we can
+    access the class attributes to do project-specific things while still
+    maintaining a common collection of sessions that can be reused across
+    projects, and can avoid needing boilerplate in the noxfile itself.
+    """
+
+    def __init__(
+        self,
+        package: str,
+        directory: Path,
+        *,
+        default_python_version: str = "3.9",
+        custom_build: Optional[Callable[[Session], None]] = None,
+        smoketest_script: Optional[str] = None,
+        parallel_tests: bool = True,
+        min_coverage: int = 80,
+        audit_versions: Optional[list[str]] = None,
+        audit_suppressions: Optional[list[str]] = None,
+    ) -> None:
+        """Configure options for generating nox sessions.
+
+        Args:
+            package: The name of the package, e.g. "tmlt.core"
+            directory: The root directory of the package
+            default_python_version: The Python minor version that will be used by
+                default for most generated sessions. In general, this should be the
+                oldest supported Python version.
+            custom_build: A function that overrides the standard build process.
+            smoketest_script: A Python program to execute as a smoke-test for an
+                installation of the package -- this should be the script
+                content, not the path to an external script. It is considered to
+                pass as long as executing the script with `python -c` doesn't
+                produce an error. If unset, defaults to just importing the package.
+            parallel_tests: Controls whether tests should be run in parallel.
+            min_coverage: The minimum coverage needed for the test/test-fast sessions
+                to pass.
+            audit_versions: A list of Python minor versions on which to audit package
+                dependencies for vulnerabilities. If unset, defaults to only
+                ``default_python_version``.
+            audit_suppressions: A list of vulnerability IDs to ignore.
+        """
+        self._package = package
+        self._package_version = (
+            subprocess.run(["poetry", "version", "-s"], capture_output=True, check=True)
+            .stdout.decode("utf-8")
+            .strip()
+        )
+        self._directory = directory
+        self._default_python_version = default_python_version
+        if custom_build is not None and not callable(custom_build):
+            raise ValueError(
+                f"Provided custom_build must be callable, but '{custom_build}' is not"
+            )
+        self._custom_build = custom_build
+        self._smoketest_script = smoketest_script or f"import {self._package}"
+        self._parallel_tests = parallel_tests
+        self._min_coverage = min_coverage
+        self._audit_versions = audit_versions or [default_python_version]
+        self._audit_suppressions = audit_suppressions or []
+
+    @property
+    def _code_dirs(self) -> list[Path]:
+        return [
+            p
+            for p in [
+                self._directory / "src",
+                self._directory / "test",
+            ]
+            if p.exists()
+        ]
+
+    def _build(self, sess: Session) -> None:
+        """Build sdists and wheels for the package.
+
+        If the build process for a package requires custom logic, pass the
+        "build" option to SessionBuilder, with the value being function taking
+        one argument (a nox Session) that builds the package. If this option is
+        not passed, the package is just built with `poetry build`.
+        """
+        if self._custom_build is not None:
+            sess.log("Using custom build function")
+            self._custom_build(sess)
+        else:
+            sess.run("poetry", "build", external=True)
+
+    def _install_package(self, f: Callable[[Session], Any]) -> Callable[[Session], Any]:
+        """Install the main package and its dependencies from Poetry lock."""
+
+        @wraps(f)
+        def inner(sess: Session) -> Any:
+            if not sess.virtualenv.is_sandboxed:
+                sess.log(
+                    "Not in a sandboxed environment, skipping package installation"
+                )
+                return f(sess)
+
+            # In the CI we want to use the wheel from the pipeline, but locally
+            # it's easier to let Poetry handle figuring out what to install and
+            # how to build it if needed.
+            if not in_ci():
+                sess.run_install("poetry", "install", "--only", "main", external=True)
+                return f(sess)
+
+            temp_dir = sess.create_tmp()
+            package = f"{self._package}=={self._package_version}"
+            out = sess.run(
+                "pip",
+                "download",
+                package,
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "-d",
+                temp_dir,
+                "--no-deps",
+                silent=True,
+                success_codes=[0, 1],
+                external=True,
+            )
+            assert out is not None, "sess.run should not return None when silent=True"
+            if "No matching distribution" in out:
+                sess.error(
+                    f"No wheel found for {package}; wheel must exist when running in CI"
+                )
+
+            sess.install(
+                f"{self._package}=={self._package_version}",
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "--no-deps",
+            )
+            return install_group("main")(f)(sess)
+
+        return inner
+
+    def _install_package_release(
+        self, f: Callable[[Session], Any]
+    ) -> Callable[[Session], Any]:
+        """Install the main package and its dependencies as resolved by pip.
+
+        Unlike _install_package, this method ignores the Poetry lock and relies
+        on pip to install whatever versions of dependencies it likes. Also
+        unlike _install_package, this method will only install from a built
+        wheel in dist/ -- locally, it will try to build a new wheel if it can't
+        find one.
+        """
+
+        @wraps(f)
+        def inner(sess: Session) -> Any:
+            if not sess.virtualenv.is_sandboxed:
+                sess.error("Release checks cannot be run with --no-venv")
+
+            temp_dir = sess.create_tmp()
+            package = f"{self._package}=={self._package_version}"
+            out = sess.run(
+                "pip",
+                "download",
+                package,
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "-d",
+                temp_dir,
+                "--no-deps",
+                silent=True,
+                success_codes=[0, 1],
+                external=True,
+            )
+            assert out is not None, "sess.run should not return None when silent=True"
+            if "No matching distribution" in out:
+                if in_ci():
+                    sess.error(
+                        f"No wheel found for {package}; "
+                        "wheel must exist when running in CI"
+                    )
+                else:
+                    sess.log("No wheel found, building one")
+                    self._build(sess)
+
+            sess.install(
+                f"{self._package}=={self._package_version}",
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+            )
+            return f(sess)
+
+        return inner
+
+    def build(self) -> None:
+        @session(name="build", python=self._default_python_version)
+        def build(sess: Session) -> None:
+            """Build source distributions and wheels for this package."""
+            self._build(sess)
+
+    def black(self) -> None:
+        @session(name="black", tags=["lint"], python=self._default_python_version)
+        @install_group("black")
+        def black(sess: Session) -> None:
+            """Run black."""
+            check_flags = ["--check", "--diff"] if "--check" in sess.posargs else []
+            sess.run("black", *check_flags, *self._code_dirs)
+
+    def isort(self) -> None:
+        @session(name="isort", tags=["lint"], python=self._default_python_version)
+        @install_group("isort")
+        def isort(sess: Session) -> None:
+            """Run isort."""
+            check_flags = ["--check", "--diff"] if "--check" in sess.posargs else []
+            sess.run("isort", *check_flags, *self._code_dirs)
+
+    def mypy(self) -> None:
+        @session(name="mypy", tags=["lint"], python=self._default_python_version)
+        @self._install_package
+        @install_group("mypy")
+        def mypy(sess: Session) -> None:
+            """Run mypy."""
+            sess.run("mypy", *self._code_dirs)
+
+    def pylint(self) -> None:
+        @session(name="pylint", tags=["lint"], python=self._default_python_version)
+        @self._install_package
+        @install_group("pylint")
+        def pylint(sess: Session) -> None:
+            """Run pylint."""
+            sess.run("pylint", "--score=no", *self._code_dirs)
+
+    def pydocstyle(self) -> None:
+        @session(name="pydocstyle", tags=["lint"], python=self._default_python_version)
+        @install_group("pydocstyle")
+        def pydocstyle(sess: Session) -> None:
+            """Run pydocstyle."""
+            sess.run("pydocstyle", *self._code_dirs)
+
+    def smoketest(self) -> None:
+        @session(name="smoketest", tags=["test"], python=self._default_python_version)
+        @self._install_package
+        def smoketest(sess: Session) -> None:
+            """Run a simple smoke-test of the package."""
+            sess.run("python", "-c", self._smoketest_script)
+
+    def release_smoketest(self) -> None:
+        @session(
+            name="release-smoketest",
+            tags=["release"],
+            python=self._default_python_version,
+        )
+        @self._install_package_release
+        def smoketest(sess: Session) -> None:
+            """Run a simple smoke-test of the package."""
+            sess.run("python", "-c", self._smoketest_script)
path: nox-utils/src/tmlt/nox_utils/__init__.py
user: login: Maegereg
body: Ah, gotcha. I don't think I have a super strong opinion here - I'm fine leaving it as is.
created_at: 2025-05-14T00:45:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,407 @@
+"""A central, largely package-agnostic collection of nox utilities.
+
+This module defines SessionManager, which takes in a collection of configuration
+options and exposes methods that can be called to generate nox sessions.
+"""
+
+import subprocess
+from functools import wraps
+from pathlib import Path
+from typing import Any, Callable, Optional, Union
+
+import nox
+from nox import Session, session
+
+from .dependencies import install_group, show_installed
+from .environment import in_ci, num_cpus
+
+
+class SessionManager:
+    """Class for creating common Nox sessions based on project-specific configuration.
+
+    To add a session to a particular noxfile, create an instance of this class
+    using your desired options, then call the methods corresponding to the
+    sessions you want.
+
+    This class leverages the fact that *any* function that is defined with the
+    ``@session`` decorator is picked up as a Nox session, no matter where it is
+    defined. By wrapping these definitions inside of class methods, we can
+    access the class attributes to do project-specific things while still
+    maintaining a common collection of sessions that can be reused across
+    projects, and can avoid needing boilerplate in the noxfile itself.
+    """
+
+    def __init__(
+        self,
+        package: str,
+        directory: Path,
+        *,
+        default_python_version: str = "3.9",
+        custom_build: Optional[Callable[[Session], None]] = None,
+        smoketest_script: Optional[str] = None,
+        parallel_tests: bool = True,
+        min_coverage: int = 80,
+        audit_versions: Optional[list[str]] = None,
+        audit_suppressions: Optional[list[str]] = None,
+    ) -> None:
+        """Configure options for generating nox sessions.
+
+        Args:
+            package: The name of the package, e.g. "tmlt.core"
+            directory: The root directory of the package
+            default_python_version: The Python minor version that will be used by
+                default for most generated sessions. In general, this should be the
+                oldest supported Python version.
+            custom_build: A function that overrides the standard build process.
+            smoketest_script: A Python program to execute as a smoke-test for an
+                installation of the package -- this should be the script
+                content, not the path to an external script. It is considered to
+                pass as long as executing the script with `python -c` doesn't
+                produce an error. If unset, defaults to just importing the package.
+            parallel_tests: Controls whether tests should be run in parallel.
+            min_coverage: The minimum coverage needed for the test/test-fast sessions
+                to pass.
+            audit_versions: A list of Python minor versions on which to audit package
+                dependencies for vulnerabilities. If unset, defaults to only
+                ``default_python_version``.
+            audit_suppressions: A list of vulnerability IDs to ignore.
+        """
+        self._package = package
+        self._package_version = (
+            subprocess.run(["poetry", "version", "-s"], capture_output=True, check=True)
+            .stdout.decode("utf-8")
+            .strip()
+        )
+        self._directory = directory
+        self._default_python_version = default_python_version
+        if custom_build is not None and not callable(custom_build):
+            raise ValueError(
+                f"Provided custom_build must be callable, but '{custom_build}' is not"
+            )
+        self._custom_build = custom_build
+        self._smoketest_script = smoketest_script or f"import {self._package}"
+        self._parallel_tests = parallel_tests
+        self._min_coverage = min_coverage
+        self._audit_versions = audit_versions or [default_python_version]
+        self._audit_suppressions = audit_suppressions or []
+
+    @property
+    def _code_dirs(self) -> list[Path]:
+        return [
+            p
+            for p in [
+                self._directory / "src",
+                self._directory / "test",
+            ]
+            if p.exists()
+        ]
+
+    def _build(self, sess: Session) -> None:
+        """Build sdists and wheels for the package.
+
+        If the build process for a package requires custom logic, pass the
+        "build" option to SessionBuilder, with the value being function taking
+        one argument (a nox Session) that builds the package. If this option is
+        not passed, the package is just built with `poetry build`.
+        """
+        if self._custom_build is not None:
+            sess.log("Using custom build function")
+            self._custom_build(sess)
+        else:
+            sess.run("poetry", "build", external=True)
+
+    def _install_package(self, f: Callable[[Session], Any]) -> Callable[[Session], Any]:
+        """Install the main package and its dependencies from Poetry lock."""
+
+        @wraps(f)
+        def inner(sess: Session) -> Any:
+            if not sess.virtualenv.is_sandboxed:
+                sess.log(
+                    "Not in a sandboxed environment, skipping package installation"
+                )
+                return f(sess)
+
+            # In the CI we want to use the wheel from the pipeline, but locally
+            # it's easier to let Poetry handle figuring out what to install and
+            # how to build it if needed.
+            if not in_ci():
+                sess.run_install("poetry", "install", "--only", "main", external=True)
+                return f(sess)
+
+            temp_dir = sess.create_tmp()
+            package = f"{self._package}=={self._package_version}"
+            out = sess.run(
+                "pip",
+                "download",
+                package,
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "-d",
+                temp_dir,
+                "--no-deps",
+                silent=True,
+                success_codes=[0, 1],
+                external=True,
+            )
+            assert out is not None, "sess.run should not return None when silent=True"
+            if "No matching distribution" in out:
+                sess.error(
+                    f"No wheel found for {package}; wheel must exist when running in CI"
+                )
+
+            sess.install(
+                f"{self._package}=={self._package_version}",
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "--no-deps",
+            )
+            return install_group("main")(f)(sess)
+
+        return inner
+
+    def _install_package_release(
path: nox-utils/src/tmlt/nox_utils/__init__.py
user: login: Maegereg
body: That sounds reasonable to me.
created_at: 2025-05-14T00:45:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,407 @@
+"""A central, largely package-agnostic collection of nox utilities.
+
+This module defines SessionManager, which takes in a collection of configuration
+options and exposes methods that can be called to generate nox sessions.
+"""
+
+import subprocess
+from functools import wraps
+from pathlib import Path
+from typing import Any, Callable, Optional, Union
+
+import nox
+from nox import Session, session
+
+from .dependencies import install_group, show_installed
+from .environment import in_ci, num_cpus
+
+
+class SessionManager:
+    """Class for creating common Nox sessions based on project-specific configuration.
+
+    To add a session to a particular noxfile, create an instance of this class
+    using your desired options, then call the methods corresponding to the
+    sessions you want.
+
+    This class leverages the fact that *any* function that is defined with the
+    ``@session`` decorator is picked up as a Nox session, no matter where it is
+    defined. By wrapping these definitions inside of class methods, we can
+    access the class attributes to do project-specific things while still
+    maintaining a common collection of sessions that can be reused across
+    projects, and can avoid needing boilerplate in the noxfile itself.
+    """
+
+    def __init__(
+        self,
+        package: str,
+        directory: Path,
+        *,
+        default_python_version: str = "3.9",
+        custom_build: Optional[Callable[[Session], None]] = None,
+        smoketest_script: Optional[str] = None,
+        parallel_tests: bool = True,
+        min_coverage: int = 80,
+        audit_versions: Optional[list[str]] = None,
+        audit_suppressions: Optional[list[str]] = None,
+    ) -> None:
+        """Configure options for generating nox sessions.
+
+        Args:
+            package: The name of the package, e.g. "tmlt.core"
+            directory: The root directory of the package
+            default_python_version: The Python minor version that will be used by
+                default for most generated sessions. In general, this should be the
+                oldest supported Python version.
+            custom_build: A function that overrides the standard build process.
path: nox-utils/src/tmlt/nox_utils/__init__.py
user: login: Maegereg
body: As, I see it now. I have a slight preference for it being in the arg list (since it'll be a bit easier to find), but I won't insist.
created_at: 2025-05-14T00:48:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,407 @@
+"""A central, largely package-agnostic collection of nox utilities.
+
+This module defines SessionManager, which takes in a collection of configuration
+options and exposes methods that can be called to generate nox sessions.
+"""
+
+import subprocess
+from functools import wraps
+from pathlib import Path
+from typing import Any, Callable, Optional, Union
+
+import nox
+from nox import Session, session
+
+from .dependencies import install_group, show_installed
+from .environment import in_ci, num_cpus
+
+
+class SessionManager:
+    """Class for creating common Nox sessions based on project-specific configuration.
+
+    To add a session to a particular noxfile, create an instance of this class
+    using your desired options, then call the methods corresponding to the
+    sessions you want.
+
+    This class leverages the fact that *any* function that is defined with the
+    ``@session`` decorator is picked up as a Nox session, no matter where it is
+    defined. By wrapping these definitions inside of class methods, we can
+    access the class attributes to do project-specific things while still
+    maintaining a common collection of sessions that can be reused across
+    projects, and can avoid needing boilerplate in the noxfile itself.
+    """
+
+    def __init__(
+        self,
+        package: str,
+        directory: Path,
+        *,
+        default_python_version: str = "3.9",
+        custom_build: Optional[Callable[[Session], None]] = None,
+        smoketest_script: Optional[str] = None,
+        parallel_tests: bool = True,
+        min_coverage: int = 80,
+        audit_versions: Optional[list[str]] = None,
+        audit_suppressions: Optional[list[str]] = None,
+    ) -> None:
+        """Configure options for generating nox sessions.
+
+        Args:
+            package: The name of the package, e.g. "tmlt.core"
+            directory: The root directory of the package
+            default_python_version: The Python minor version that will be used by
+                default for most generated sessions. In general, this should be the
+                oldest supported Python version.
+            custom_build: A function that overrides the standard build process.
+            smoketest_script: A Python program to execute as a smoke-test for an
+                installation of the package -- this should be the script
+                content, not the path to an external script. It is considered to
+                pass as long as executing the script with `python -c` doesn't
+                produce an error. If unset, defaults to just importing the package.
+            parallel_tests: Controls whether tests should be run in parallel.
+            min_coverage: The minimum coverage needed for the test/test-fast sessions
+                to pass.
+            audit_versions: A list of Python minor versions on which to audit package
+                dependencies for vulnerabilities. If unset, defaults to only
+                ``default_python_version``.
+            audit_suppressions: A list of vulnerability IDs to ignore.
+        """
+        self._package = package
+        self._package_version = (
+            subprocess.run(["poetry", "version", "-s"], capture_output=True, check=True)
+            .stdout.decode("utf-8")
+            .strip()
+        )
+        self._directory = directory
+        self._default_python_version = default_python_version
+        if custom_build is not None and not callable(custom_build):
+            raise ValueError(
+                f"Provided custom_build must be callable, but '{custom_build}' is not"
+            )
+        self._custom_build = custom_build
+        self._smoketest_script = smoketest_script or f"import {self._package}"
+        self._parallel_tests = parallel_tests
+        self._min_coverage = min_coverage
+        self._audit_versions = audit_versions or [default_python_version]
+        self._audit_suppressions = audit_suppressions or []
+
+    @property
+    def _code_dirs(self) -> list[Path]:
+        return [
+            p
+            for p in [
+                self._directory / "src",
+                self._directory / "test",
+            ]
+            if p.exists()
+        ]
+
+    def _build(self, sess: Session) -> None:
+        """Build sdists and wheels for the package.
+
+        If the build process for a package requires custom logic, pass the
+        "build" option to SessionBuilder, with the value being function taking
+        one argument (a nox Session) that builds the package. If this option is
+        not passed, the package is just built with `poetry build`.
+        """
+        if self._custom_build is not None:
+            sess.log("Using custom build function")
+            self._custom_build(sess)
+        else:
+            sess.run("poetry", "build", external=True)
+
+    def _install_package(self, f: Callable[[Session], Any]) -> Callable[[Session], Any]:
+        """Install the main package and its dependencies from Poetry lock."""
+
+        @wraps(f)
+        def inner(sess: Session) -> Any:
+            if not sess.virtualenv.is_sandboxed:
+                sess.log(
+                    "Not in a sandboxed environment, skipping package installation"
+                )
+                return f(sess)
+
+            # In the CI we want to use the wheel from the pipeline, but locally
+            # it's easier to let Poetry handle figuring out what to install and
+            # how to build it if needed.
+            if not in_ci():
+                sess.run_install("poetry", "install", "--only", "main", external=True)
+                return f(sess)
+
+            temp_dir = sess.create_tmp()
+            package = f"{self._package}=={self._package_version}"
+            out = sess.run(
+                "pip",
+                "download",
+                package,
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "-d",
+                temp_dir,
+                "--no-deps",
+                silent=True,
+                success_codes=[0, 1],
+                external=True,
+            )
+            assert out is not None, "sess.run should not return None when silent=True"
+            if "No matching distribution" in out:
+                sess.error(
+                    f"No wheel found for {package}; wheel must exist when running in CI"
+                )
+
+            sess.install(
+                f"{self._package}=={self._package_version}",
+                "--find-links",
+                f"{self._directory}/dist/",
+                "--only-binary",
+                self._package,
+                "--no-deps",
+            )
+            return install_group("main")(f)(sess)
+
+        return inner
+
+    def _install_package_release(
path: nox-utils/src/tmlt/nox_utils/__init__.py
user: login: tmager
body: Done.
created_at: 2025-05-14T01:55:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,407 @@
+"""A central, largely package-agnostic collection of nox utilities.
+
+This module defines SessionManager, which takes in a collection of configuration
+options and exposes methods that can be called to generate nox sessions.
+"""
+
+import subprocess
+from functools import wraps
+from pathlib import Path
+from typing import Any, Callable, Optional, Union
+
+import nox
+from nox import Session, session
+
+from .dependencies import install_group, show_installed
+from .environment import in_ci, num_cpus
+
+
+class SessionManager:
+    """Class for creating common Nox sessions based on project-specific configuration.
+
+    To add a session to a particular noxfile, create an instance of this class
+    using your desired options, then call the methods corresponding to the
+    sessions you want.
+
+    This class leverages the fact that *any* function that is defined with the
+    ``@session`` decorator is picked up as a Nox session, no matter where it is
+    defined. By wrapping these definitions inside of class methods, we can
+    access the class attributes to do project-specific things while still
+    maintaining a common collection of sessions that can be reused across
+    projects, and can avoid needing boilerplate in the noxfile itself.
+    """
+
+    def __init__(
+        self,
+        package: str,
+        directory: Path,
+        *,
+        default_python_version: str = "3.9",
+        custom_build: Optional[Callable[[Session], None]] = None,
+        smoketest_script: Optional[str] = None,
+        parallel_tests: bool = True,
+        min_coverage: int = 80,
+        audit_versions: Optional[list[str]] = None,
+        audit_suppressions: Optional[list[str]] = None,
+    ) -> None:
+        """Configure options for generating nox sessions.
+
+        Args:
+            package: The name of the package, e.g. "tmlt.core"
+            directory: The root directory of the package
+            default_python_version: The Python minor version that will be used by
+                default for most generated sessions. In general, this should be the
+                oldest supported Python version.
+            custom_build: A function that overrides the standard build process.
path: nox-utils/src/tmlt/nox_utils/__init__.py
user: login: tmager
body: Updated, I think either is a reasonable place for it.
created_at: 2025-05-14T01:55:56Z
author_association: CONTRIBUTOR

PR comments:
user: login: tmager
created_at: 2025-05-13T00:09:28Z
body: One other thing to note: this assumes that we will use a `src/` layout for Analytics like we do for Core. This should be a straightforward change, the inconsistency between the two seemed like an odd thing to keep supporting.
author_association: CONTRIBUTOR
user: login: tmager
created_at: 2025-05-13T00:27:07Z
body: opendp/tumult-core#2 shows most of the changes needed to make use of this -- I still need to clean up the noxfile a bit and fix some lints that have mysteriously appeared, but it should give a sense of what this will look like.
author_association: CONTRIBUTOR