diff --git a/nitrostack/cli/install.py b/nitrostack/cli/install.py index aeb19dc..cfa478c 100644 --- a/nitrostack/cli/install.py +++ b/nitrostack/cli/install.py @@ -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 ``/.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: @@ -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] = [] diff --git a/nitrostack/cli/main.py b/nitrostack/cli/main.py index fdf82bd..08aef34 100644 --- a/nitrostack/cli/main.py +++ b/nitrostack/cli/main.py @@ -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) @@ -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", diff --git a/tests/test_cli.py b/tests/test_cli.py index d734365..40ec53e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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.") @@ -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) diff --git a/tests/test_cli_install.py b/tests/test_cli_install.py index 9d7ef40..c70927e 100644 --- a/tests/test_cli_install.py +++ b/tests/test_cli_install.py @@ -12,7 +12,9 @@ from nitrostack.cli.install import ( _optional_extra_names, _run_pip, + ensure_project_venv, install_dependencies, + venv_python, ) @@ -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"