hotdog versioning and some bugfixes for integration project and model testing

This commit is contained in:
K. Hodges 2026-05-20 03:50:51 -07:00
parent 7c54050223
commit d08e629bce
20 changed files with 493 additions and 84 deletions

View File

@ -131,29 +131,24 @@ Create an isolated integration sandbox for a template:
```bash
python -m nightshift.cli integ-run --template tutorial-pastebin
cd integ_runs/<timestamp>/project
```
Activate the generated virtual environment, then install and run the project.
Then run the Python project setup helper. It finds the generated venv, installs this NightShift checkout into it, installs the target project, installs pytest by default, and runs `nightshift validate`:
PowerShell:
```bash
python -m nightshift.cli integ-setup --project integ_runs/<timestamp>/project
```
After setup, run from the generated project with the venv Python:
```powershell
..\.venv\Scripts\Activate.ps1
python -m pip install -e ..\..\..
python -m pip install -e . pytest flask
python -m nightshift.cli validate
python -m nightshift.cli run --task TASK-001
integ_runs\<timestamp>\.venv\Scripts\python.exe -m nightshift.cli run --task TASK-001
```
Bash:
```bash
source ../.venv/bin/activate
python -m pip install -e ../../..
python -m pip install -e . pytest flask
python -m nightshift.cli validate
python -m nightshift.cli run --task TASK-001
integ_runs/<timestamp>/.venv/bin/python -m nightshift.cli run --task TASK-001
```
Open the read-only artifact dashboard:

View File

@ -140,35 +140,24 @@ Create a local integration sandbox from the NightShift repository root:
python -m nightshift.cli integ-run --template tutorial-pastebin
```
Then enter the generated project:
Set up the generated Python project:
```bash
cd integ_runs/<timestamp>/project
python -m nightshift.cli integ-setup --project integ_runs/<timestamp>/project
```
Activate the sandbox virtual environment and install target dependencies.
The setup helper:
PowerShell:
- finds or creates the integration virtual environment
- installs this NightShift checkout into that venv
- installs the target project with `pip install -e`
- installs extra packages, defaulting to `pytest`
- runs `nightshift validate` unless `--skip-validate` is set
```powershell
..\.venv\Scripts\Activate.ps1
python -m pip install -e ..\..\..
python -m pip install -e . pytest flask
```
Bash:
Preview commands without running them:
```bash
source ../.venv/bin/activate
python -m pip install -e ../../..
python -m pip install -e . pytest flask
```
Run NightShift inside the generated `project/` directory:
```bash
python -m nightshift.cli validate
python -m nightshift.cli run --task TASK-001
python -m nightshift.cli integ-setup --project integ_runs/<timestamp>/project --dry-run
```
To clean up old sandboxes before creating a new one, keep only the newest three existing runs:

View File

@ -17,23 +17,12 @@ For an isolated local integration run, use the integration sandbox command from
```bash
python -m nightshift.cli integ-run --template tutorial-pastebin
cd integ_runs/<timestamp>/project
```
Activate the generated virtual environment.
PowerShell:
```powershell
..\.venv\Scripts\Activate.ps1
python -m pip install -e ..\..\..
```
Bash:
Then set up the generated Python project:
```bash
source ../.venv/bin/activate
python -m pip install -e ../../..
python -m nightshift.cli integ-setup --project integ_runs/<timestamp>/project
```
The template creates:

View File

@ -1,3 +1,5 @@
"""NightShift package."""
__version__ = "0.1.0"
from .version import __version__, display_version
__all__ = ["__version__", "display_version"]

View File

@ -10,6 +10,7 @@ from .config import validate_config
from .errors import NightShiftError
from .init import available_templates, init_project
from .integ import create_integration_run
from .integ_setup import format_setup_result, setup_python_project
from .pipeline import PipelineRunner
from .runlog import RunLogger
from .status import build_status, format_status
@ -21,12 +22,13 @@ from .tasks import (
select_task_by_id,
validate_task_dependencies,
)
from .version import display_version
from .web import create_app
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="nightshift", description="Auditable AI pipeline runner.")
parser.add_argument("--version", action="version", version="nightshift 0.1.0")
parser.add_argument("--version", action="version", version=f"nightshift {display_version()}")
subparsers = parser.add_subparsers(dest="command", required=True)
@ -66,6 +68,41 @@ def build_parser() -> argparse.ArgumentParser:
)
integ_parser.add_argument("--keep", type=int, help="Keep only the newest N old integration runs before creating a new one.")
setup_parser = subparsers.add_parser(
"integ-setup",
help="Set up a Python integration project venv and dependencies.",
)
setup_parser.add_argument(
"--project",
default=".",
help="Generated project directory. Defaults to the current directory.",
)
setup_parser.add_argument(
"--nightshift-root",
help="NightShift checkout to install into the integration venv. Defaults to this checkout.",
)
setup_parser.add_argument(
"--extra",
action="append",
default=["pytest"],
help="Extra package to install into the venv. May be repeated. Defaults to pytest.",
)
setup_parser.add_argument(
"--no-create-venv",
action="store_true",
help="Fail instead of creating a missing virtual environment.",
)
setup_parser.add_argument(
"--skip-validate",
action="store_true",
help="Skip `nightshift validate` after installation.",
)
setup_parser.add_argument(
"--dry-run",
action="store_true",
help="Print setup commands without running them.",
)
return parser
@ -138,6 +175,19 @@ def main(argv: list[str] | None = None) -> int:
print(f"Integration run: {run.directory}")
print(f"Venv: {run.venv_dir}")
print(f"Log: {run.log_path}")
print(f"Setup: python -m nightshift.cli integ-setup --project {run.directory / 'project'}")
return 0
if args.command == "integ-setup":
result = setup_python_project(
args.project,
nightshift_root=args.nightshift_root,
extras=tuple(args.extra or ()),
create_venv=not args.no_create_venv,
validate=not args.skip_validate,
dry_run=args.dry_run,
)
print(format_setup_result(result))
return 0
except NightShiftError as exc:

View File

@ -7,6 +7,7 @@ import os
from pathlib import Path
import shlex
import subprocess
import sys
import time
from .artifacts import ArtifactStore
@ -137,11 +138,7 @@ class CommandExecutor:
raise CommandError(str(exc)) from exc
timeout = timeout_seconds or self.timeout_seconds
args: str | list[str] = normalized if shell else shlex.split(normalized)
env = None
if self.safety.allowed_env:
env = {name: os.environ[name] for name in self.safety.allowed_env if name in os.environ}
if "PATH" in os.environ:
env.setdefault("PATH", os.environ["PATH"])
env = _command_env(self.safety.allowed_env)
started = time.monotonic()
process = subprocess.Popen(
@ -221,6 +218,18 @@ def _coerce_output(value: str | bytes | None) -> str:
return value
def _command_env(allowed_env: tuple[str, ...]) -> dict[str, str]:
env = dict(os.environ) if not allowed_env else {
name: os.environ[name] for name in allowed_env if name in os.environ
}
python_dir = str(Path(sys.executable).resolve().parent)
current_path = env.get("PATH") or os.environ.get("PATH", "")
path_parts = [part for part in current_path.split(os.pathsep) if part]
env["PATH"] = os.pathsep.join([python_dir, *[part for part in path_parts if part != python_dir]])
env.setdefault("VIRTUAL_ENV", os.environ.get("VIRTUAL_ENV", ""))
return env
def _kill_process_tree(process: subprocess.Popen[str]) -> None:
if os.name == "nt":
subprocess.run(

View File

@ -35,15 +35,6 @@ def classify_failure(output: str, exit_code: int | None = None, modified_files:
lowered = text.lower()
failing_tests = extract_failing_tests(text)
if re.search(r"\b(syntaxerror|indentationerror|importerror)\b", text, re.IGNORECASE):
return FailureClassification(
"syntax/import error",
"Python failed while parsing or importing code.",
0.86,
"Send the failure excerpt and touched files back to the implementer.",
"retry implementation",
failing_tests,
)
missing = re.search(r"No module named ['\"]([^'\"]+)['\"]", text, re.IGNORECASE)
if not missing:
missing = re.search(r"ModuleNotFoundError:\s*['\"]?([A-Za-z0-9_.-]+)", text, re.IGNORECASE)
@ -57,6 +48,15 @@ def classify_failure(output: str, exit_code: int | None = None, modified_files:
"do not retry implementation until dependency is resolved",
failing_tests,
)
if re.search(r"\b(syntaxerror|indentationerror|importerror)\b", text, re.IGNORECASE):
return FailureClassification(
"syntax/import error",
"Python failed while parsing or importing code.",
0.86,
"Send the failure excerpt and touched files back to the implementer.",
"retry implementation",
failing_tests,
)
if any(marker in lowered for marker in ("filenotfounderror", "no such file or directory", "missing fixture", "fixture")):
return FailureClassification(
"missing resource/fixture",

View File

@ -6,6 +6,7 @@ from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
import shutil
import subprocess
import venv
from .init import init_project
@ -37,6 +38,7 @@ def create_integration_run(root: Path, *, template: str = "basic", keep: int | N
project_dir = run_dir / "project"
project_dir.mkdir()
init_project(project_dir, template=template)
_initialize_project_git_repo(project_dir)
log_path = log_dir / "integ-run.log"
log_path.write_text(
"\n".join(
@ -55,6 +57,21 @@ def create_integration_run(root: Path, *, template: str = "basic", keep: int | N
return IntegrationRun(run_dir, venv_dir, log_path)
def _initialize_project_git_repo(project_dir: Path) -> None:
try:
subprocess.run(
["git", "init"],
cwd=project_dir,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=10,
)
except (OSError, subprocess.TimeoutExpired):
return
def cleanup_integration_runs(base: Path, *, keep: int) -> tuple[Path, ...]:
if keep < 0:
raise ValueError("keep must be zero or greater")

134
nightshift/integ_setup.py Normal file
View File

@ -0,0 +1,134 @@
"""Python project setup helper for integration sandboxes."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import os
import subprocess
import sys
import venv
from .errors import NightShiftError
@dataclass(frozen=True)
class SetupCommand:
args: tuple[str, ...]
cwd: Path
@dataclass(frozen=True)
class IntegrationSetupResult:
project_dir: Path
venv_dir: Path
python: Path
created_venv: bool
commands: tuple[SetupCommand, ...]
dry_run: bool = False
def setup_python_project(
project_dir: str | Path = ".",
*,
nightshift_root: str | Path | None = None,
extras: tuple[str, ...] = ("pytest",),
create_venv: bool = True,
validate: bool = True,
dry_run: bool = False,
) -> IntegrationSetupResult:
"""Install NightShift and a Python target project into an integration venv."""
project = Path(project_dir).resolve()
if not project.exists() or not project.is_dir():
raise NightShiftError(f"Integration setup error: project directory does not exist: {project}")
venv_dir, created = _ensure_venv(project, create=create_venv, dry_run=dry_run)
python = _venv_python(venv_dir)
root = Path(nightshift_root).resolve() if nightshift_root else _default_nightshift_root()
if not root.exists():
raise NightShiftError(f"Integration setup error: NightShift root does not exist: {root}")
commands = [
SetupCommand((str(python), "-m", "pip", "install", "-e", str(root)), project),
SetupCommand((str(python), "-m", "pip", "install", "-e", str(project)), project),
]
if extras:
commands.append(SetupCommand((str(python), "-m", "pip", "install", *extras), project))
if validate and (project / "nightshift.yaml").exists():
commands.append(SetupCommand((str(python), "-m", "nightshift.cli", "validate"), project))
if not dry_run:
for command in commands:
completed = subprocess.run(
command.args,
cwd=command.cwd,
text=True,
encoding="utf-8",
errors="replace",
)
if completed.returncode != 0:
rendered = " ".join(command.args)
raise NightShiftError(
f"Integration setup error: command failed with code {completed.returncode}: {rendered}"
)
return IntegrationSetupResult(
project_dir=project,
venv_dir=venv_dir,
python=python,
created_venv=created,
commands=tuple(commands),
dry_run=dry_run,
)
def format_setup_result(result: IntegrationSetupResult) -> str:
lines = [
f"Project: {result.project_dir}",
f"Venv: {result.venv_dir}",
f"Python: {result.python}",
f"Created venv: {str(result.created_venv).lower()}",
]
if result.dry_run:
lines.append("Dry run: true")
lines.append("Commands:")
for command in result.commands:
lines.append(f"- ({command.cwd}) {' '.join(command.args)}")
else:
lines.append("Setup complete.")
lines.append("Run from the project directory:")
lines.append(f" {result.python} -m nightshift.cli run --task TASK-001")
return "\n".join(lines)
def _ensure_venv(project: Path, *, create: bool, dry_run: bool) -> tuple[Path, bool]:
candidates = _venv_candidates(project)
for candidate in candidates:
if _venv_python(candidate).exists():
return candidate, False
if not create:
raise NightShiftError(
"Integration setup error: no virtual environment found. "
f"Checked: {', '.join(str(path) for path in candidates)}"
)
target = candidates[0]
if not dry_run:
venv.EnvBuilder(with_pip=True).create(target)
return target, True
def _venv_candidates(project: Path) -> tuple[Path, ...]:
if project.name == "project" and project.parent.name:
return (project.parent / ".venv", project / ".venv")
return (project / ".venv", project.parent / ".venv")
def _venv_python(venv_dir: Path) -> Path:
if os.name == "nt":
return venv_dir / "Scripts" / "python.exe"
return venv_dir / "bin" / "python"
def _default_nightshift_root() -> Path:
return Path(__file__).resolve().parents[1]

View File

@ -119,12 +119,14 @@ def generate_patch_from_file_updates(
root = resolve_project_root(project_root)
scoped_roots = validate_scoped_paths(root, safety.scoped_paths or (".",))
patch_parts: list[str] = []
seen: set[str] = set()
seen: dict[str, str] = {}
for update in updates:
normalized_path = _normalize_update_path(update.path)
if normalized_path in seen:
if seen[normalized_path] == update.content:
continue
raise PipelineError(f"File writer error: duplicate file block `{normalized_path}`.")
seen.add(normalized_path)
seen[normalized_path] = update.content
_validate_patch_path(normalized_path, root, scoped_roots, forbidden_paths)
file_path = resolve_inside_root(root, normalized_path, f"file update '{normalized_path}'")
old_text = file_path.read_text(encoding="utf-8", errors="replace") if file_path.exists() else ""

View File

@ -1181,16 +1181,23 @@ class PipelineRunner:
def _build_context_pack(self, task: Task) -> str:
terms = _task_search_terms(task)
files = self.repo_tools.list_files(".", pattern="*.py", max_files=80)
lookup_paths = self.config.safety.scoped_paths or (".",)
files = self._list_context_files(lookup_paths)
grep_sections: list[str] = []
for term in terms[:5]:
scoped_results = []
for path in lookup_paths:
scoped_results.append(
f"#### Path: {path}\n\n"
"```text\n"
f"{self.repo_tools.grep(re.escape(term), path, max_matches=20).rstrip()}\n"
"```"
)
grep_sections.extend(
[
f"### Search: {term}",
"",
"```text",
self.repo_tools.grep(re.escape(term), ".", max_matches=20),
"```",
*scoped_results,
"",
]
)
@ -1223,6 +1230,18 @@ class PipelineRunner:
]
)
def _list_context_files(self, paths: tuple[str, ...]) -> str:
sections: list[str] = []
for path in paths:
sections.extend(
[
f"## Path: {path}",
self.repo_tools.list_files(path, pattern="*", max_files=80).rstrip(),
"",
]
)
return "\n".join(sections).strip() or "No files found."
def _read_output(self, output_path: str | None) -> str:
if output_path is None:
return ""

View File

@ -12,26 +12,15 @@ Or create an isolated integration sandbox from the NightShift repository root:
```bash
python -m nightshift.cli integ-run --template tutorial-pastebin
cd integ_runs/<timestamp>/project
```
Activate the generated virtual environment.
PowerShell:
```powershell
..\.venv\Scripts\Activate.ps1
python -m pip install -e ..\..\..
```
Bash:
Then set up the generated Python project:
```bash
source ../.venv/bin/activate
python -m pip install -e ../../..
python -m nightshift.cli integ-setup --project integ_runs/<timestamp>/project
```
Install target dependencies:
For a normal non-integration checkout, install target dependencies:
```bash
python -m pip install -e . pytest flask

View File

@ -7,6 +7,7 @@ import sys
from typing import TextIO
import random
from .version import display_version
RESET = "\x1b[0m"
@ -77,7 +78,7 @@ def format_banner(stream: TextIO | None = None) -> str:
f" [ {quote} ]",
" [ planner | implementer | verifier | audit ]",
"",
" VERSION: 0.1.0-alpha-glizzy",
f" VERSION: {display_version()}",
"-" * 50,
"",
]

46
nightshift/version.py Normal file
View File

@ -0,0 +1,46 @@
"""NightShift version metadata."""
from __future__ import annotations
PACKAGE_VERSION = "0.2.2"
RELEASE_CHANNEL = "alpha"
hotdog_version = "footlong"
topping_version = "mustard"
HOTDOG_VERSIONS = (
"bratwurst",
"italian-sausage",
"footlong",
"new-york",
"chicago",
"coney",
"corn-dog",
"kielbasa",
"vienna",
"andouille",
"chorizo",
"frankfurter",
)
TOPPING_VERSIONS = (
"relish",
"mustard",
"mayo",
"onions",
"sauerkraut",
"jalapenos",
"pickles",
"chili",
"cheese",
"sport-peppers",
"ketchup",
"slaw",
)
def display_version() -> str:
return f"{PACKAGE_VERSION}-{RELEASE_CHANNEL}-{hotdog_version}-{topping_version}"
__version__ = PACKAGE_VERSION

View File

@ -5,8 +5,10 @@ import unittest
from nightshift.artifacts import ArtifactStore
from nightshift.commands import CommandExecutor
from nightshift.commands import CommandRun, format_command_runs
from nightshift.commands import _command_env
from nightshift.config import SafetyConfig, StageConfig
from nightshift.errors import CommandError
import sys
PASSING_COMMAND = 'python -c "print(\'ok\')"'
@ -168,6 +170,12 @@ class CommandExecutorTests(unittest.TestCase):
self.assertIn("Command: `cmd`", output)
self.assertIn("### stderr", output)
def test_command_env_prefers_current_python_directory(self) -> None:
env = _command_env(())
first_path = env["PATH"].split(";")[0] if ";" in env["PATH"] else env["PATH"].split(":")[0]
self.assertEqual(Path(first_path), Path(sys.executable).resolve().parent)
if __name__ == "__main__":
unittest.main()

49
tests/test_integ_setup.py Normal file
View File

@ -0,0 +1,49 @@
from pathlib import Path
import tempfile
import unittest
from nightshift.integ import create_integration_run
from nightshift.integ_setup import format_setup_result, setup_python_project
class IntegrationSetupTests(unittest.TestCase):
def test_setup_python_project_dry_run_uses_integration_venv(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
run = create_integration_run(root, template="tutorial-pastebin")
result = setup_python_project(
run.directory / "project",
nightshift_root=Path(__file__).resolve().parents[1],
extras=("pytest", "flask"),
dry_run=True,
)
self.assertEqual(result.venv_dir, run.venv_dir)
self.assertFalse(result.created_venv)
rendered = format_setup_result(result)
self.assertIn("pip install -e", rendered)
self.assertIn("pytest", rendered)
self.assertIn("flask", rendered)
self.assertTrue(any("nightshift.cli validate" in " ".join(command.args) for command in result.commands))
self.assertTrue((run.directory / "project" / ".git").exists())
def test_setup_python_project_dry_run_creates_project_local_venv_when_missing(self) -> None:
with tempfile.TemporaryDirectory() as directory:
project = Path(directory) / "project"
project.mkdir()
(project / "pyproject.toml").write_text("[project]\nname = 'demo'\n", encoding="utf-8")
result = setup_python_project(
project,
nightshift_root=Path(__file__).resolve().parents[1],
extras=(),
dry_run=True,
)
self.assertEqual(result.venv_dir, project.parent / ".venv")
self.assertTrue(result.created_venv)
if __name__ == "__main__":
unittest.main()

View File

@ -254,6 +254,30 @@ two
with self.assertRaisesRegex(PipelineError, "duplicate file block"):
generate_patch_from_file_updates(updates, root, safety)
def test_file_updates_allow_identical_duplicate_blocks(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "app.py").write_text("old\n", encoding="utf-8")
safety = SafetyConfig(
require_clean_worktree=False,
scoped_paths=(".",),
allowed_commands=(),
forbidden_commands=(),
)
updates = parse_file_updates(
"""```file:app.py
new
```
```file:app.py
new
```
"""
)
patch = generate_patch_from_file_updates(updates, root, safety)
self.assertEqual(patch.count("diff --git a/app.py b/app.py"), 1)
if __name__ == "__main__":
unittest.main()

View File

@ -308,6 +308,38 @@ Acceptance Criteria:
self.assertIn("Context Pack", pack.read_text(encoding="utf-8"))
self.assertIn("app.py", pack.read_text(encoding="utf-8"))
def test_repo_context_stage_respects_scoped_paths_without_project_root(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
_write_common_files(root)
(root / "src").mkdir()
(root / "tests").mkdir()
(root / "src" / "app.py").write_text("def create_snippet():\n return True\n", encoding="utf-8")
(root / "tests" / "test_app.py").write_text("def test_create_snippet():\n assert True\n", encoding="utf-8")
stages = (StageConfig(id="context", type="repo_context", output="context-pack.md"),)
config = make_config(root, stages)
config = replace(
config,
safety=SafetyConfig(
require_clean_worktree=False,
scoped_paths=("src", "tests", "pyproject.toml", "README.md"),
allowed_commands=config.safety.allowed_commands,
forbidden_commands=config.safety.forbidden_commands,
),
)
(root / "pyproject.toml").write_text("[project]\nname = 'demo'\n", encoding="utf-8")
(root / "README.md").write_text("# Demo\n", encoding="utf-8")
runner = PipelineRunner(config, ArtifactStore(root, ".nightshift", run_id="test-run"))
task = parse_tasks(TASK_MD)[0]
result = runner.run_task(task)
pack = root / ".nightshift" / "runs" / "test-run" / "tasks" / task.id / "context-pack.md"
self.assertEqual(result.status, "complete")
content = pack.read_text(encoding="utf-8")
self.assertIn("src/app.py", content)
self.assertIn("tests/test_app.py", content)
def test_project_context_chart_is_written_during_run(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)

View File

@ -22,6 +22,20 @@ class ReliabilityFeatureTests(unittest.TestCase):
self.assertIn("flask", result.probable_root_cause)
self.assertIn("do not retry", result.retry_recommendation)
def test_failure_classifier_prioritizes_module_not_found_in_pytest_import_error(self) -> None:
result = classify_failure(
"\n".join(
[
"ImportError while importing test module 'tests/test_app.py'.",
"ModuleNotFoundError: No module named 'pastebin_app'",
]
),
exit_code=2,
)
self.assertEqual(result.category, "missing dependency")
self.assertIn("pastebin_app", result.probable_root_cause)
def test_command_failure_writes_diagnostics_and_retry_memory(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)

40
tests/test_version.py Normal file
View File

@ -0,0 +1,40 @@
import subprocess
import sys
import unittest
from nightshift.terminal import format_banner
from nightshift.version import (
HOTDOG_VERSIONS,
PACKAGE_VERSION,
TOPPING_VERSIONS,
display_version,
hotdog_version,
topping_version,
)
class VersionTests(unittest.TestCase):
def test_display_version_includes_channel_hotdog_and_topping(self) -> None:
self.assertEqual(display_version(), "0.2.2-alpha-footlong-mustard")
self.assertEqual(PACKAGE_VERSION, "0.2.2")
self.assertIn(hotdog_version, HOTDOG_VERSIONS)
self.assertIn(topping_version, TOPPING_VERSIONS)
def test_banner_uses_central_display_version(self) -> None:
self.assertIn(f"VERSION: {display_version()}", format_banner())
def test_cli_version_uses_central_display_version(self) -> None:
completed = subprocess.run(
[sys.executable, "-m", "nightshift.cli", "--version"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=True,
)
self.assertEqual(completed.stdout.strip(), f"nightshift {display_version()}")
if __name__ == "__main__":
unittest.main()