Skip to content
Draft
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
6 changes: 6 additions & 0 deletions helm/blueapi/config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,12 @@
"description": "Revision (branch or tag) to check out when cloning - defaults to remote's HEAD. If a tag is used, the repo will be left in a 'detached head' state.",
"title": "Target Revision",
"type": "string"
},
"use_uv_lock": {
"default": false,
"description": "Install this repository with `uv sync` from its committed uv.lock. Designed for when you have tested your code against a set of dependencies and want them not to move between restarts, e.g. a beamline repository such as village_bluesky. At most one repository may set this. It is installed first; every other repository (e.g. dodal) is then installed editable without its dependencies, so the locked versions are kept.",
"title": "Use Uv Lock",
"type": "boolean"
}
},
"title": "ScratchRepository",
Expand Down
6 changes: 6 additions & 0 deletions helm/blueapi/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,12 @@
"title": "Target Revision",
"description": "Revision (branch or tag) to check out when cloning - defaults to remote's HEAD. If a tag is used, the repo will be left in a 'detached head' state.",
"type": "string"
},
"use_uv_lock": {
"title": "Use Uv Lock",
"description": "Install this repository with `uv sync` from its committed uv.lock. Designed for when you have tested your code against a set of dependencies and want them not to move between restarts, e.g. a beamline repository such as village_bluesky. At most one repository may set this. It is installed first; every other repository (e.g. dodal) is then installed editable without its dependencies, so the locked versions are kept.",
"default": false,
"type": "boolean"
}
},
"additionalProperties": false
Expand Down
40 changes: 32 additions & 8 deletions src/blueapi/cli/scratch.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ def setup_scratch(
scratch_install(
*(config.root / repo.name for repo in config.repositories),
timeout=install_timeout,
locked_repo=config.locked_repo,
)


Expand Down Expand Up @@ -116,7 +117,11 @@ def ensure_repo(
)


def scratch_install(*paths: Path, timeout: float = _DEFAULT_INSTALL_TIMEOUT) -> None:
def scratch_install(
*paths: Path,
timeout: float = _DEFAULT_INSTALL_TIMEOUT,
locked_repo: Path | None = None,
) -> None:
"""
Install scratch packages. Make blueapi aware of repositories checked out in
the scratch area. Make it automatically follow code changes to those repositories
Expand All @@ -126,19 +131,38 @@ def scratch_install(*paths: Path, timeout: float = _DEFAULT_INSTALL_TIMEOUT) ->
Args:
paths: List of Paths to the checked out repositories
timeout: Time to wait for installation subprocess
locked_repo: Repository to install first from its uv.lock, if any
"""
if not paths:
return
LOGGER.info("Installing packages")
if locked_repo:
uv_lock_path = locked_repo / "uv.lock"
if not uv_lock_path.exists():
raise FileNotFoundError(
f"Installing from a uv lock file was requested, but no lock file "
f"exists at {uv_lock_path}. Check that uv.lock exists in your "
"repository."
)
# Sync the locked repo into the environment blueapi is running in.
# --inexact stops uv removing packages not in the lock (e.g. blueapi).
_run_install(["uv", "sync", "--locked", "--inexact", str(locked_repo)], timeout)
# With a lock, install the rest without dependencies to keep locked versions.
deps = ["--no-deps"] if locked_repo else []
for path in paths:
if locked_repo and path.resolve() == locked_repo.resolve():
continue
_validate_directory(path)
args = ["uv", "pip", "install", "-e", str(path)]
process = Popen(args)
process.wait(timeout=timeout)
if process.returncode != 0:
raise RuntimeError(
f"Failed to install packages: Exit Code: {process.returncode}"
)
_run_install(["uv", "pip", "install", *deps, "-e", str(path)], timeout)


def _run_install(args: list[str], timeout: float) -> None:
process = Popen(args)
process.wait(timeout=timeout)
if process.returncode != 0:
raise RuntimeError(
f"Failed to install packages: Exit Code: {process.returncode}"
)


def _validate_root_directory(root_path: Path, required_gid: int | None) -> None:
Expand Down
30 changes: 30 additions & 0 deletions src/blueapi/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,19 @@ class ScratchRepository(BlueapiBaseModel):
default_factory=lambda: None,
)

use_uv_lock: bool = Field(
description=(
"Install this repository with `uv sync` from its committed uv.lock. "
"Designed for when you have tested your code against a set of "
"dependencies and want them not to move between restarts, e.g. a "
"beamline repository such as village_bluesky. At most one repository "
"may set this. It is installed first; every other repository (e.g. "
"dodal) is then installed editable without its dependencies, so the "
"locked versions are kept."
),
default=False,
)

@field_validator("remote_url")
@classmethod
def check_remote_url(cls, value: str) -> str:
Expand Down Expand Up @@ -237,6 +250,23 @@ class ScratchConfig(BlueapiBaseModel):
default_factory=list,
)

@property
def locked_repo(self) -> Path | None:
"""Path to the repository with use_uv_lock set, if any."""
for repo in self.repositories:
if repo.use_uv_lock:
return self.root / repo.name
return None

@model_validator(mode="after")
def check_single_uv_lock(self) -> Self:
locked = [repo.name for repo in self.repositories if repo.use_uv_lock]
if len(locked) > 1:
raise ValueError(
f"Only one repository may set use_uv_lock, found: {locked}"
)
return self


class OIDCConfig(BlueapiBaseModel):
well_known_url: str | None = Field(
Expand Down
90 changes: 90 additions & 0 deletions tests/unit_tests/cli/test_scratch.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,95 @@ def test_scratch_install_fails_on_non_zero_exit_code(
scratch_install(directory_path_with_sgid, timeout=1.0)


@pytest.fixture
def locked_and_other_repos(tmp_path: Path) -> tuple[Path, Path]:
locked = tmp_path / "village_bluesky"
other = tmp_path / "dodal"
locked.mkdir()
other.mkdir()
(locked / "uv.lock").write_text("")
return locked, other


def _successful_process() -> Mock:
process = Mock()
process.returncode = 0
return process


@patch("blueapi.cli.scratch.Popen")
def test_scratch_install_with_locked_repo_syncs_then_installs_without_deps(
mock_popen: Mock,
locked_and_other_repos: tuple[Path, Path],
):
locked, other = locked_and_other_repos
mock_popen.return_value = _successful_process()

scratch_install(locked, other, timeout=1.0, locked_repo=locked)

assert mock_popen.call_count == 2
sync_args = mock_popen.call_args_list[0].args[0]
assert sync_args[:4] == ["uv", "sync", "--locked", "--inexact"]
assert str(locked) in sync_args
assert mock_popen.call_args_list[1] == call(
["uv", "pip", "install", "--no-deps", "-e", str(other)]
)


@patch("blueapi.cli.scratch.Popen")
def test_scratch_install_fails_if_lock_file_missing(
mock_popen: Mock,
locked_and_other_repos: tuple[Path, Path],
):
locked, other = locked_and_other_repos
(locked / "uv.lock").unlink()

with pytest.raises(FileNotFoundError, match="uv.lock"):
scratch_install(locked, other, timeout=1.0, locked_repo=locked)

mock_popen.assert_not_called()


@patch("blueapi.cli.scratch.Popen")
def test_scratch_install_stops_if_uv_sync_fails(
mock_popen: Mock,
locked_and_other_repos: tuple[Path, Path],
):
locked, other = locked_and_other_repos
mock_popen.return_value.returncode = 1

with pytest.raises(RuntimeError, match="Exit Code: 1"):
scratch_install(locked, other, timeout=1.0, locked_repo=locked)

mock_popen.assert_called_once()


@patch("blueapi.cli.scratch.ensure_repo")
@patch("blueapi.cli.scratch.scratch_install")
def test_setup_scratch_passes_locked_repo(
mock_scratch_install: Mock,
mock_ensure_repo: Mock,
directory_path_with_sgid: Path,
):
config = ScratchConfig(
root=directory_path_with_sgid,
repositories=[
ScratchRepository(name="foo", remote_url="http://example.com/foo.git"),
ScratchRepository(
name="bar", remote_url="http://example.com/bar.git", use_uv_lock=True
),
],
)
setup_scratch(config, install_timeout=120.0)

mock_scratch_install.assert_called_once_with(
directory_path_with_sgid / "foo",
directory_path_with_sgid / "bar",
timeout=120.0,
locked_repo=directory_path_with_sgid / "bar",
)


@patch("blueapi.cli.scratch.Repo")
def test_repo_not_cloned_and_validated_if_found_locally(
mock_repo: Mock,
Expand Down Expand Up @@ -349,6 +438,7 @@ def test_setup_scratch_iterates_repos(
directory_path_with_sgid / "foo",
directory_path_with_sgid / "bar",
timeout=120.0,
locked_repo=None,
),
]
)
Expand Down
40 changes: 40 additions & 0 deletions tests/unit_tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
ConfigLoader,
OIDCConfig,
RestConfig,
ScratchConfig,
ScratchRepository,
generate_config_schema,
)
from blueapi.utils import InvalidConfigError
Expand Down Expand Up @@ -333,6 +335,7 @@ def test_config_yaml_parsed(temp_yaml_config_file):
{
"name": "dodal",
"remote_url": "https://github.com/DiamondLightSource/dodal.git",
"use_uv_lock": False,
}
],
},
Expand Down Expand Up @@ -396,6 +399,7 @@ def test_config_yaml_parsed(temp_yaml_config_file):
{
"name": "dodal",
"remote_url": "https://github.com/DiamondLightSource/dodal.git",
"use_uv_lock": False,
}
],
},
Expand Down Expand Up @@ -579,3 +583,39 @@ def test_issuer_url_preferred_over_well_known_url(caplog):
def test_ws_address(url: str, ws_url: str):
conf = RestConfig.model_validate({"url": url})
assert conf.ws_address == WebsocketUrl(ws_url)


def test_scratch_config_locked_repo_none_by_default():
config = ScratchConfig(
root=Path("/tmp/scratch"),
repositories=[ScratchRepository(name="dodal", remote_url="https://a.com/d")],
)
assert config.locked_repo is None


def test_scratch_config_locked_repo():
config = ScratchConfig(
root=Path("/tmp/scratch"),
repositories=[
ScratchRepository(name="dodal", remote_url="https://a.com/d"),
ScratchRepository(
name="village_bluesky", remote_url="https://a.com/v", use_uv_lock=True
),
],
)
assert config.locked_repo == Path("/tmp/scratch/village_bluesky")


def test_scratch_config_rejects_multiple_locked_repos():
with pytest.raises(ValueError, match="Only one repository may set use_uv_lock"):
ScratchConfig(
root=Path("/tmp/scratch"),
repositories=[
ScratchRepository(
name="a", remote_url="https://a.com/a", use_uv_lock=True
),
ScratchRepository(
name="b", remote_url="https://a.com/b", use_uv_lock=True
),
],
)
Loading