The workflow this serves: make a venv, install what you work with, then `pip install fluksio` into the same one. Building a second environment beside it was exactly wrong — the packages the nodes need are already here, and the Modules screen was asking for them a second time. `NODE_VENV=auto` (the default) adopts that venv. It declines in the three cases where adopting would be wrong: `managed` says otherwise, a managed venv already exists and may hold packages somebody installed on purpose, or the engine is not running from a venv at all. The images set `managed`, since the venv in them holds the app and nothing of anybody else's. An adopted venv is never written to. `uv pip sync` makes a venv hold exactly the manifest, so pointed at somebody's own environment it uninstalls their work and the engine with it — `sync()` refuses outright and `reconcile()` returns before it can be called at startup, which is where that would have happened first. The Modules screen lists what is installed and drops its editor; `pip` is how that environment changes. `fluksio serve` now names the interpreter node code runs on, which is the thing a data scientist most needs to know at that moment. `fluksio-worker` already defaulted `--python` to its own interpreter, so a GPU box works the same way — that was only ever undocumented. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ue1tkFWB1bcGy3aWhCKpU
165 lines
5.4 KiB
Python
165 lines
5.4 KiB
Python
"""The venv node code imports from is built and kept in step with a manifest."""
|
|
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from fluksio.flow import modules
|
|
from fluksio.flow.store import FlowStore
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
shutil.which("uv") is None, reason="module management needs uv"
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def venv(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
directory = tmp_path / "user-venv"
|
|
monkeypatch.setattr(modules, "VENV_DIR", directory)
|
|
return directory
|
|
|
|
|
|
def test_an_empty_manifest_gives_a_venv_of_its_own(venv: Path, tmp_path: Path):
|
|
store = FlowStore(tmp_path / "flows")
|
|
store.write_requirements("")
|
|
modules.sync("")
|
|
|
|
assert Path(modules.venv_python()).exists()
|
|
assert modules.venv_python().startswith(str(venv))
|
|
|
|
information = modules.info(store)
|
|
assert information.applied is True
|
|
assert information.python_version
|
|
|
|
|
|
def test_a_manifest_already_applied_is_not_installed_again(
|
|
venv: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
):
|
|
store = FlowStore(tmp_path / "flows")
|
|
store.write_requirements("")
|
|
modules.sync("")
|
|
marker = (venv / ".applied").read_text()
|
|
|
|
# Recorded rather than refused: reconcile swallows what it fails on, so an
|
|
# exception raised in here would never reach the test.
|
|
calls: list[object] = []
|
|
monkeypatch.setattr(subprocess, "run", lambda *args, **kwargs: calls.append(args))
|
|
modules.reconcile(store)
|
|
|
|
assert calls == []
|
|
assert (venv / ".applied").read_text() == marker
|
|
|
|
|
|
def test_a_missing_uv_is_a_failed_apply_rather_than_a_crash(
|
|
venv: Path, monkeypatch: pytest.MonkeyPatch
|
|
):
|
|
def no_uv(*args: object, **kwargs: object) -> None:
|
|
raise FileNotFoundError(2, "No such file or directory: 'uv'")
|
|
|
|
monkeypatch.setattr(subprocess, "run", no_uv)
|
|
ok, output = modules.sync("")
|
|
|
|
# The route turns this into a 400 with the output; an exception would be a
|
|
# 500 with nothing in it.
|
|
assert ok is False
|
|
assert "uv could not run" in output
|
|
|
|
|
|
def test_a_manifest_that_does_not_resolve_leaves_the_venv_alone(venv: Path):
|
|
modules.sync("")
|
|
ok, output = modules.sync("fluksio-no-such-package-anywhere==9.9.9")
|
|
|
|
assert ok is False
|
|
assert output
|
|
# The marker still describes the manifest that actually installed.
|
|
assert (venv / ".applied").read_text() == modules._digest("")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Adopting the venv Fluksio was installed into
|
|
#
|
|
# The workflow this exists for: make a venv, install what you work with, then
|
|
# `pip install fluksio` into the same one. Building a second environment beside
|
|
# it would leave node code unable to import the packages that are the point.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def their_venv(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
"""Pretend the engine is running from a venv somebody else made."""
|
|
directory = tmp_path / "research"
|
|
(directory / "bin").mkdir(parents=True)
|
|
(directory / "bin" / "python").write_text("")
|
|
monkeypatch.setattr(modules.sys, "prefix", str(directory))
|
|
monkeypatch.setattr(modules.sys, "base_prefix", str(tmp_path / "usr"))
|
|
# Undoes the suite-wide "managed" in tests/__init__.py.
|
|
monkeypatch.setattr(modules.settings, "NODE_VENV", "auto")
|
|
return directory
|
|
|
|
|
|
def test_a_venv_of_your_own_is_adopted(venv: Path, their_venv: Path):
|
|
assert modules.adopted() == their_venv
|
|
assert modules.venv_python() == str(their_venv / "bin" / "python")
|
|
|
|
|
|
def test_an_adopted_venv_is_never_synced(venv: Path, their_venv: Path):
|
|
"""The whole hazard: sync means "hold exactly this", so it would empty it."""
|
|
ok, output = modules.sync("numpy>=2")
|
|
|
|
assert ok is False
|
|
assert "not Fluksio's to install" in output
|
|
# Nothing was built beside it either.
|
|
assert not venv.exists()
|
|
|
|
|
|
def test_reconcile_leaves_an_adopted_venv_alone(
|
|
venv: Path, their_venv: Path, tmp_path: Path
|
|
):
|
|
store = FlowStore(tmp_path / "flows")
|
|
store.write_requirements("numpy>=2\n")
|
|
|
|
modules.reconcile(store)
|
|
|
|
assert not venv.exists()
|
|
assert modules.venv_python() == str(their_venv / "bin" / "python")
|
|
|
|
|
|
def test_a_venv_the_engine_already_owns_is_kept(venv: Path, their_venv: Path):
|
|
"""An upgrade must not take away packages somebody installed on purpose."""
|
|
(venv / "bin").mkdir(parents=True)
|
|
(venv / "bin" / "python").write_text("")
|
|
|
|
assert modules.adopted() is None
|
|
assert modules.venv_python() == str(venv / "bin" / "python")
|
|
|
|
|
|
def test_managed_is_what_a_container_asks_for(
|
|
venv: Path, their_venv: Path, monkeypatch: pytest.MonkeyPatch
|
|
):
|
|
monkeypatch.setattr(modules.settings, "NODE_VENV", "managed")
|
|
|
|
assert modules.adopted() is None
|
|
|
|
|
|
def test_an_interpreter_can_be_named_outright(
|
|
venv: Path, their_venv: Path, monkeypatch: pytest.MonkeyPatch
|
|
):
|
|
monkeypatch.setattr(modules.settings, "NODE_VENV", "/opt/conda/bin/python")
|
|
|
|
assert modules.venv_python() == "/opt/conda/bin/python"
|
|
|
|
|
|
def test_info_describes_an_adopted_venv(venv: Path, their_venv: Path, tmp_path: Path):
|
|
store = FlowStore(tmp_path / "flows")
|
|
store.write_requirements("numpy>=2\n")
|
|
|
|
information = modules.info(store)
|
|
|
|
assert information.adopted is True
|
|
assert information.venv_path == str(their_venv)
|
|
assert information.python_version
|
|
# The manifest does not describe it, so it cannot be out of step with it.
|
|
assert information.applied is True
|