From 74eca5f0590c5219fcb4332e98cfa8664f81fa4a Mon Sep 17 00:00:00 2001 From: Zoheb Shaikh <26975142+ZohebShaikh@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:23:58 +0000 Subject: [PATCH 1/2] add uv lock file --- helm/blueapi/config_schema.json | 6 ++ src/blueapi/cli/scratch.py | 40 ++++++++++--- src/blueapi/config.py | 30 ++++++++++ tests/unit_tests/cli/test_scratch.py | 90 ++++++++++++++++++++++++++++ tests/unit_tests/test_config.py | 40 +++++++++++++ 5 files changed, 198 insertions(+), 8 deletions(-) diff --git a/helm/blueapi/config_schema.json b/helm/blueapi/config_schema.json index 0755a2c6e..2f0d0ecda 100644 --- a/helm/blueapi/config_schema.json +++ b/helm/blueapi/config_schema.json @@ -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", diff --git a/src/blueapi/cli/scratch.py b/src/blueapi/cli/scratch.py index 6fa01d83e..d194b53c1 100644 --- a/src/blueapi/cli/scratch.py +++ b/src/blueapi/cli/scratch.py @@ -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, ) @@ -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 @@ -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: diff --git a/src/blueapi/config.py b/src/blueapi/config.py index f65dd65f0..7c498b77e 100644 --- a/src/blueapi/config.py +++ b/src/blueapi/config.py @@ -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: @@ -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( diff --git a/tests/unit_tests/cli/test_scratch.py b/tests/unit_tests/cli/test_scratch.py index 22f3ad3d8..0d5907cde 100644 --- a/tests/unit_tests/cli/test_scratch.py +++ b/tests/unit_tests/cli/test_scratch.py @@ -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, @@ -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, ), ] ) diff --git a/tests/unit_tests/test_config.py b/tests/unit_tests/test_config.py index a335434df..229eac9dd 100644 --- a/tests/unit_tests/test_config.py +++ b/tests/unit_tests/test_config.py @@ -20,6 +20,8 @@ ConfigLoader, OIDCConfig, RestConfig, + ScratchConfig, + ScratchRepository, generate_config_schema, ) from blueapi.utils import InvalidConfigError @@ -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, } ], }, @@ -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, } ], }, @@ -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 + ), + ], + ) From 11155456528277fdfd98835b30987dc242881dfa Mon Sep 17 00:00:00 2001 From: Zoheb Shaikh <26975142+ZohebShaikh@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:04:26 +0000 Subject: [PATCH 2/2] Add helm config changes --- helm/blueapi/values.schema.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/helm/blueapi/values.schema.json b/helm/blueapi/values.schema.json index 3020e5502..dc2b7b170 100644 --- a/helm/blueapi/values.schema.json +++ b/helm/blueapi/values.schema.json @@ -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