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
|
```bash
|
||||||
python -m nightshift.cli integ-run --template tutorial-pastebin
|
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
|
```powershell
|
||||||
..\.venv\Scripts\Activate.ps1
|
integ_runs\<timestamp>\.venv\Scripts\python.exe -m nightshift.cli run --task TASK-001
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Bash:
|
Bash:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
source ../.venv/bin/activate
|
integ_runs/<timestamp>/.venv/bin/python -m nightshift.cli run --task TASK-001
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open the read-only artifact dashboard:
|
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
|
python -m nightshift.cli integ-run --template tutorial-pastebin
|
||||||
```
|
```
|
||||||
|
|
||||||
Then enter the generated project:
|
Set up the generated Python project:
|
||||||
|
|
||||||
```bash
|
```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
|
Preview commands without running them:
|
||||||
..\.venv\Scripts\Activate.ps1
|
|
||||||
python -m pip install -e ..\..\..
|
|
||||||
python -m pip install -e . pytest flask
|
|
||||||
```
|
|
||||||
|
|
||||||
Bash:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
source ../.venv/bin/activate
|
python -m nightshift.cli integ-setup --project integ_runs/<timestamp>/project --dry-run
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
To clean up old sandboxes before creating a new one, keep only the newest three existing runs:
|
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
|
```bash
|
||||||
python -m nightshift.cli integ-run --template tutorial-pastebin
|
python -m nightshift.cli integ-run --template tutorial-pastebin
|
||||||
cd integ_runs/<timestamp>/project
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Activate the generated virtual environment.
|
Then set up the generated Python project:
|
||||||
|
|
||||||
PowerShell:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
..\.venv\Scripts\Activate.ps1
|
|
||||||
python -m pip install -e ..\..\..
|
|
||||||
```
|
|
||||||
|
|
||||||
Bash:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
source ../.venv/bin/activate
|
python -m nightshift.cli integ-setup --project integ_runs/<timestamp>/project
|
||||||
python -m pip install -e ../../..
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The template creates:
|
The template creates:
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
"""NightShift package."""
|
"""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 .errors import NightShiftError
|
||||||
from .init import available_templates, init_project
|
from .init import available_templates, init_project
|
||||||
from .integ import create_integration_run
|
from .integ import create_integration_run
|
||||||
|
from .integ_setup import format_setup_result, setup_python_project
|
||||||
from .pipeline import PipelineRunner
|
from .pipeline import PipelineRunner
|
||||||
from .runlog import RunLogger
|
from .runlog import RunLogger
|
||||||
from .status import build_status, format_status
|
from .status import build_status, format_status
|
||||||
|
|
@ -21,12 +22,13 @@ from .tasks import (
|
||||||
select_task_by_id,
|
select_task_by_id,
|
||||||
validate_task_dependencies,
|
validate_task_dependencies,
|
||||||
)
|
)
|
||||||
|
from .version import display_version
|
||||||
from .web import create_app
|
from .web import create_app
|
||||||
|
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(prog="nightshift", description="Auditable AI pipeline runner.")
|
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)
|
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.")
|
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
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -138,6 +175,19 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
print(f"Integration run: {run.directory}")
|
print(f"Integration run: {run.directory}")
|
||||||
print(f"Venv: {run.venv_dir}")
|
print(f"Venv: {run.venv_dir}")
|
||||||
print(f"Log: {run.log_path}")
|
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
|
return 0
|
||||||
|
|
||||||
except NightShiftError as exc:
|
except NightShiftError as exc:
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from .artifacts import ArtifactStore
|
from .artifacts import ArtifactStore
|
||||||
|
|
@ -137,11 +138,7 @@ class CommandExecutor:
|
||||||
raise CommandError(str(exc)) from exc
|
raise CommandError(str(exc)) from exc
|
||||||
timeout = timeout_seconds or self.timeout_seconds
|
timeout = timeout_seconds or self.timeout_seconds
|
||||||
args: str | list[str] = normalized if shell else shlex.split(normalized)
|
args: str | list[str] = normalized if shell else shlex.split(normalized)
|
||||||
env = None
|
env = _command_env(self.safety.allowed_env)
|
||||||
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"])
|
|
||||||
|
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
|
|
@ -221,6 +218,18 @@ def _coerce_output(value: str | bytes | None) -> str:
|
||||||
return value
|
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:
|
def _kill_process_tree(process: subprocess.Popen[str]) -> None:
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
|
|
|
||||||
|
|
@ -35,15 +35,6 @@ def classify_failure(output: str, exit_code: int | None = None, modified_files:
|
||||||
lowered = text.lower()
|
lowered = text.lower()
|
||||||
failing_tests = extract_failing_tests(text)
|
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)
|
missing = re.search(r"No module named ['\"]([^'\"]+)['\"]", text, re.IGNORECASE)
|
||||||
if not missing:
|
if not missing:
|
||||||
missing = re.search(r"ModuleNotFoundError:\s*['\"]?([A-Za-z0-9_.-]+)", text, re.IGNORECASE)
|
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",
|
"do not retry implementation until dependency is resolved",
|
||||||
failing_tests,
|
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")):
|
if any(marker in lowered for marker in ("filenotfounderror", "no such file or directory", "missing fixture", "fixture")):
|
||||||
return FailureClassification(
|
return FailureClassification(
|
||||||
"missing resource/fixture",
|
"missing resource/fixture",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import shutil
|
import shutil
|
||||||
|
import subprocess
|
||||||
import venv
|
import venv
|
||||||
|
|
||||||
from .init import init_project
|
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 = run_dir / "project"
|
||||||
project_dir.mkdir()
|
project_dir.mkdir()
|
||||||
init_project(project_dir, template=template)
|
init_project(project_dir, template=template)
|
||||||
|
_initialize_project_git_repo(project_dir)
|
||||||
log_path = log_dir / "integ-run.log"
|
log_path = log_dir / "integ-run.log"
|
||||||
log_path.write_text(
|
log_path.write_text(
|
||||||
"\n".join(
|
"\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)
|
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, ...]:
|
def cleanup_integration_runs(base: Path, *, keep: int) -> tuple[Path, ...]:
|
||||||
if keep < 0:
|
if keep < 0:
|
||||||
raise ValueError("keep must be zero or greater")
|
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)
|
root = resolve_project_root(project_root)
|
||||||
scoped_roots = validate_scoped_paths(root, safety.scoped_paths or (".",))
|
scoped_roots = validate_scoped_paths(root, safety.scoped_paths or (".",))
|
||||||
patch_parts: list[str] = []
|
patch_parts: list[str] = []
|
||||||
seen: set[str] = set()
|
seen: dict[str, str] = {}
|
||||||
for update in updates:
|
for update in updates:
|
||||||
normalized_path = _normalize_update_path(update.path)
|
normalized_path = _normalize_update_path(update.path)
|
||||||
if normalized_path in seen:
|
if normalized_path in seen:
|
||||||
|
if seen[normalized_path] == update.content:
|
||||||
|
continue
|
||||||
raise PipelineError(f"File writer error: duplicate file block `{normalized_path}`.")
|
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)
|
_validate_patch_path(normalized_path, root, scoped_roots, forbidden_paths)
|
||||||
file_path = resolve_inside_root(root, normalized_path, f"file update '{normalized_path}'")
|
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 ""
|
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:
|
def _build_context_pack(self, task: Task) -> str:
|
||||||
terms = _task_search_terms(task)
|
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] = []
|
grep_sections: list[str] = []
|
||||||
for term in terms[:5]:
|
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(
|
grep_sections.extend(
|
||||||
[
|
[
|
||||||
f"### Search: {term}",
|
f"### Search: {term}",
|
||||||
"",
|
"",
|
||||||
"```text",
|
*scoped_results,
|
||||||
self.repo_tools.grep(re.escape(term), ".", max_matches=20),
|
|
||||||
"```",
|
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
@ -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:
|
def _read_output(self, output_path: str | None) -> str:
|
||||||
if output_path is None:
|
if output_path is None:
|
||||||
return ""
|
return ""
|
||||||
|
|
|
||||||
|
|
@ -12,26 +12,15 @@ Or create an isolated integration sandbox from the NightShift repository root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m nightshift.cli integ-run --template tutorial-pastebin
|
python -m nightshift.cli integ-run --template tutorial-pastebin
|
||||||
cd integ_runs/<timestamp>/project
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Activate the generated virtual environment.
|
Then set up the generated Python project:
|
||||||
|
|
||||||
PowerShell:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
..\.venv\Scripts\Activate.ps1
|
|
||||||
python -m pip install -e ..\..\..
|
|
||||||
```
|
|
||||||
|
|
||||||
Bash:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
source ../.venv/bin/activate
|
python -m nightshift.cli integ-setup --project integ_runs/<timestamp>/project
|
||||||
python -m pip install -e ../../..
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Install target dependencies:
|
For a normal non-integration checkout, install target dependencies:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m pip install -e . pytest flask
|
python -m pip install -e . pytest flask
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import sys
|
||||||
from typing import TextIO
|
from typing import TextIO
|
||||||
import random
|
import random
|
||||||
|
|
||||||
|
from .version import display_version
|
||||||
|
|
||||||
|
|
||||||
RESET = "\x1b[0m"
|
RESET = "\x1b[0m"
|
||||||
|
|
@ -77,7 +78,7 @@ def format_banner(stream: TextIO | None = None) -> str:
|
||||||
f" [ {quote} ]",
|
f" [ {quote} ]",
|
||||||
" [ planner | implementer | verifier | audit ]",
|
" [ planner | implementer | verifier | audit ]",
|
||||||
"",
|
"",
|
||||||
" VERSION: 0.1.0-alpha-glizzy",
|
f" VERSION: {display_version()}",
|
||||||
"-" * 50,
|
"-" * 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.artifacts import ArtifactStore
|
||||||
from nightshift.commands import CommandExecutor
|
from nightshift.commands import CommandExecutor
|
||||||
from nightshift.commands import CommandRun, format_command_runs
|
from nightshift.commands import CommandRun, format_command_runs
|
||||||
|
from nightshift.commands import _command_env
|
||||||
from nightshift.config import SafetyConfig, StageConfig
|
from nightshift.config import SafetyConfig, StageConfig
|
||||||
from nightshift.errors import CommandError
|
from nightshift.errors import CommandError
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
PASSING_COMMAND = 'python -c "print(\'ok\')"'
|
PASSING_COMMAND = 'python -c "print(\'ok\')"'
|
||||||
|
|
@ -168,6 +170,12 @@ class CommandExecutorTests(unittest.TestCase):
|
||||||
self.assertIn("Command: `cmd`", output)
|
self.assertIn("Command: `cmd`", output)
|
||||||
self.assertIn("### stderr", 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__":
|
if __name__ == "__main__":
|
||||||
unittest.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"):
|
with self.assertRaisesRegex(PipelineError, "duplicate file block"):
|
||||||
generate_patch_from_file_updates(updates, root, safety)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -308,6 +308,38 @@ Acceptance Criteria:
|
||||||
self.assertIn("Context Pack", pack.read_text(encoding="utf-8"))
|
self.assertIn("Context Pack", pack.read_text(encoding="utf-8"))
|
||||||
self.assertIn("app.py", 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:
|
def test_project_context_chart_is_written_during_run(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = Path(directory)
|
root = Path(directory)
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,20 @@ class ReliabilityFeatureTests(unittest.TestCase):
|
||||||
self.assertIn("flask", result.probable_root_cause)
|
self.assertIn("flask", result.probable_root_cause)
|
||||||
self.assertIn("do not retry", result.retry_recommendation)
|
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:
|
def test_command_failure_writes_diagnostics_and_retry_memory(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = Path(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