Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion nitrostack/cli/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,35 @@ def _optional_extra_names(pyproject_text: str) -> List[str]:
return names


def venv_dir(root: str) -> str:
return os.path.join(root, ".venv")


def venv_python(root: str) -> str:
if os.name == "nt":
return os.path.join(venv_dir(root), "Scripts", "python.exe")
return os.path.join(venv_dir(root), "bin", "python")


def ensure_project_venv(root: str) -> str:
"""Create ``<root>/.venv`` if needed and return its Python executable."""
python = venv_python(root)
if os.path.isfile(python):
return python
dest = venv_dir(root)
print(f"Creating virtualenv: {dest}")
result = subprocess.run([sys.executable, "-m", "venv", dest])
if result.returncode != 0 or not os.path.isfile(python):
raise RuntimeError(
f"Failed to create {dest}.\n"
f"Create it manually with `{sys.executable} -m venv .venv`, then retry."
)
return python


def _run_pip(args: List[str], cwd: str) -> None:
cmd = [sys.executable, "-m", "pip", "install", *args]
python = ensure_project_venv(cwd)
cmd = [python, "-m", "pip", "install", *args]
print(f"Running: {' '.join(cmd)}")
result = subprocess.run(cmd, cwd=cwd)
if result.returncode != 0:
Expand Down Expand Up @@ -59,6 +86,7 @@ def install_dependencies(
)

print("NITROSTACK — Install" + (" (production)" if production else ""))
print(f"Target: {venv_dir(root)}")

if os.path.isfile(pyproject):
extras: List[str] = []
Expand Down
40 changes: 24 additions & 16 deletions nitrostack/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1343,22 +1343,30 @@ def init_project(name: str = None, template: str = None, skip_install: bool = Fa
except Exception:
pass

# 9. Run npm install inside widgets directory
widgets_dir = os.path.join(name, "src", "widgets")
if os.path.exists(widgets_dir) and install_deps:
print("Installing widget dependencies...")
# 9. Install Python deps (requirements.txt / pyproject.toml), then widget npm if present.
if install_deps:
print("Installing dependencies...")
try:
_run_npm(["--version"], capture_output=True, check=True, text=True)
_run_npm(["install"], cwd=widgets_dir, check=True)
print("\033[32m✓\033[0m Widget dependencies installed\n")
except FileNotFoundError as e:
print(f"Warning: {e}")
print("Please run 'npm install' inside 'src/widgets' manually.\n")
except subprocess.CalledProcessError as e:
detail = (getattr(e, "stderr", None) or getattr(e, "stdout", None) or str(e)).strip()
print(f"Warning: Failed to install widget dependencies: {detail}")
print("Please run 'npm install' inside 'src/widgets' manually.\n")
elif not install_deps:
install_dependencies(cwd=name)
print("\033[32m✓\033[0m Dependencies installed")
except Exception as e:
print(f"Warning: Failed to install dependencies: {e}")
print("Please run 'nitrostack-py install' from the project directory.\n")
widgets_dir = os.path.join(name, "src", "widgets")
if os.path.exists(widgets_dir):
print("Installing widget dependencies...")
try:
_run_npm(["--version"], capture_output=True, check=True, text=True)
_run_npm(["install"], cwd=widgets_dir, check=True)
print("\033[32m✓\033[0m Widget dependencies installed\n")
except FileNotFoundError as e:
print(f"Warning: {e}")
print("Please run 'npm install' inside 'src/widgets' manually.\n")
except subprocess.CalledProcessError as e:
detail = (getattr(e, "stderr", None) or getattr(e, "stdout", None) or str(e)).strip()
print(f"Warning: Failed to install widget dependencies: {detail}")
print("Please run 'npm install' inside 'src/widgets' manually.\n")
else:
print("\033[32m✓\033[0m Skipped dependency install")

run_skills_flow(os.path.abspath(name), force=force)
Expand Down Expand Up @@ -1727,7 +1735,7 @@ def main():
default=None,
help="Template to use: python-starter, python-pizzaz, python-oauth (default: interactive prompt)",
)
init_parser.add_argument("--skip-install", action="store_true", help="Skip installing widget npm dependencies")
init_parser.add_argument("--skip-install", action="store_true", help="Skip installing Python (and widget npm) dependencies")
init_parser.add_argument(
"--force",
action="store_true",
Expand Down
23 changes: 18 additions & 5 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,13 +299,17 @@ def test_init_port_and_widget_flags_override_defaults():


def test_init_install_dependencies_prompt_no():
from unittest.mock import patch

tmp = tempfile.mkdtemp(prefix="nitro-cli-install-")
original_cwd = os.getcwd()
original_stdin = sys.stdin
try:
os.chdir(tmp)
sys.stdin = io.StringIO("desc\nauthor\nn\n")
init_project("no-npm", template="python-starter")
with patch("nitrostack.cli.main.install_dependencies") as pip_install:
init_project("no-npm", template="python-starter")
pip_install.assert_not_called()
widgets = os.path.join(tmp, "no-npm", "src", "widgets", "node_modules")
assert not os.path.isdir(widgets)
print("Success! Install dependencies (Y/n) respects n.")
Expand Down Expand Up @@ -569,11 +573,20 @@ def test_init_project_overwrite_and_install_yes_calls_npm():
assert not os.path.exists(os.path.join("taken", "old.txt"))

sys.stdin = io.StringIO("d\na\nY\n")
with patch("nitrostack.cli.main._run_npm") as npm:
init_project("installed", template="python-starter")
npm.assert_not_called()
with patch("nitrostack.cli.main.install_dependencies") as pip_install:
with patch("nitrostack.cli.main._run_npm") as npm:
init_project("installed", template="python-starter")
pip_install.assert_called_once()
assert pip_install.call_args.kwargs["cwd"] == "installed"
npm.assert_not_called()
assert os.path.isfile(os.path.join("installed", "widgets", "out", "calculator-result.html"))
print("Success! init_project overwrite and install-yes npm path work.")

sys.stdin = io.StringIO("d\na\n\n")
with patch("nitrostack.cli.main.install_dependencies") as pip_install:
init_project("installed-enter", template="python-starter")
pip_install.assert_called_once()
assert pip_install.call_args.kwargs["cwd"] == "installed-enter"
print("Success! init_project overwrite and install-yes pip path work.")
finally:
sys.stdin = original_stdin
os.chdir(original_cwd)
Expand Down
41 changes: 37 additions & 4 deletions tests/test_cli_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
from nitrostack.cli.install import (
_optional_extra_names,
_run_pip,
ensure_project_venv,
install_dependencies,
venv_python,
)


Expand Down Expand Up @@ -68,7 +70,38 @@ def test_install_requirements_txt_and_dev_file(tmp_path):


def test_run_pip_raises_on_nonzero_exit(tmp_path):
with patch("nitrostack.cli.install.subprocess.run") as run:
run.return_value.returncode = 3
with pytest.raises(RuntimeError, match="exit code 3"):
_run_pip(["-e", "."], cwd=str(tmp_path))
with patch("nitrostack.cli.install.ensure_project_venv", return_value=sys.executable):
with patch("nitrostack.cli.install.subprocess.run") as run:
run.return_value.returncode = 3
with pytest.raises(RuntimeError, match="exit code 3"):
_run_pip(["-e", "."], cwd=str(tmp_path))


def test_ensure_project_venv_creates_and_reuses(tmp_path):
python = venv_python(str(tmp_path))

def fake_venv(cmd, **kwargs):
os.makedirs(os.path.dirname(python), exist_ok=True)
open(python, "w", encoding="utf-8").write("")
return type("R", (), {"returncode": 0})()

with patch("nitrostack.cli.install.subprocess.run", side_effect=fake_venv) as run:
first = ensure_project_venv(str(tmp_path))
second = ensure_project_venv(str(tmp_path))
assert first == python == second
run.assert_called_once()
assert run.call_args.args[0][:3] == [sys.executable, "-m", "venv"]


def test_install_requirements_uses_venv_python(tmp_path):
(tmp_path / "requirements.txt").write_text("nitrostack\n", encoding="utf-8")
venv_py = venv_python(str(tmp_path))
with patch("nitrostack.cli.install.ensure_project_venv", return_value=venv_py) as ensure:
with patch("nitrostack.cli.install.subprocess.run") as run:
run.return_value.returncode = 0
install_dependencies(cwd=str(tmp_path), production=False)
ensure.assert_called()
cmd = run.call_args.args[0]
assert cmd[0] == venv_py
assert cmd[1:4] == ["-m", "pip", "install"]
assert cmd[4] == "-r"
Loading