mirror of
https://github.com/khodges42/nightShift.git
synced 2026-06-14 10:08:37 +00:00
hotdog versioning and some bugfixes for integration project and model testing
This commit is contained in:
parent
7c54050223
commit
d08e629bce
21
README.md
21
README.md
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
"""NightShift package."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
from .version import __version__, display_version
|
||||
|
||||
__all__ = ["__version__", "display_version"]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
134
nightshift/integ_setup.py
Normal 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]
|
||||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
46
nightshift/version.py
Normal 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
|
||||
|
|
@ -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
49
tests/test_integ_setup.py
Normal 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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
40
tests/test_version.py
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user