Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
9e54a3d
Add test for CLI error handling
MJGaughran Jun 29, 2026
c169842
Add behavioural tests for validate command
MJGaughran Jun 26, 2026
a08460b
Have compare command dump values in JSON mode to support special types
MJGaughran Jun 15, 2026
04c3d6c
Add separate tests for the compare command
MJGaughran Jun 15, 2026
bd94f8b
Only search non-deprecated modules for default versions in compare
MJGaughran Jun 15, 2026
06cbd0c
Add test_sync.py to test double-deprecation and removal issues
MJGaughran Jun 16, 2026
8f8ca4b
Remove previous snapshot dead code from Layout
MJGaughran Jun 17, 2026
9642087
Improve exception handling for compare command and snapshots
MJGaughran Jun 17, 2026
5f243d7
Fix typo in validate.py for allow_all, and provide relevant tests
MJGaughran Jun 17, 2026
0fc31b4
Use pathlib symlink_to instead of os function
MJGaughran Jun 17, 2026
6f85eff
Use Layout variables to calculate .gitignore paths
MJGaughran Jun 17, 2026
662c578
Add test for external tool calls
MJGaughran Jun 29, 2026
3662b01
Add consistent type hinting to all CLI tests
MJGaughran Jun 26, 2026
4516b07
Ensure GitPython repos are closed properly
MJGaughran Jun 17, 2026
0a2bf29
Have dedicated test for ensuring apptainer pulls are successful
MJGaughran Jun 17, 2026
6db1cb6
Add test for BinaryApp using a local generated file
MJGaughran Jun 17, 2026
2c4d493
Add error handling when loading deployment configuration with tests
MJGaughran Jun 19, 2026
b7f52a9
Wrap load logic with standard deploy-tools error handling
MJGaughran Jun 19, 2026
c5ce9ad
Add dedicated unit tests for default version logic
MJGaughran Jun 19, 2026
3b3e043
Add tests for Module options not previously covered
MJGaughran Jun 19, 2026
a5b1673
Reorganise CLI test configuration for consistency
MJGaughran Jun 29, 2026
db9aa4b
Improve names of configuration folders
MJGaughran Jun 19, 2026
5f5fcaa
Reduce irrelevant parameters in configuration
MJGaughran Jun 19, 2026
76a29a6
Expand test_module_options to cover template branches
MJGaughran Jun 19, 2026
3352347
Add test for deploying from scratch into dir with snapshot
MJGaughran Jun 22, 2026
5081ea8
Add tests for validation edge cases
MJGaughran Jun 22, 2026
d7c76e8
Add unit test for defensive check in apply_default_versions()
MJGaughran Jun 22, 2026
df673cc
Add explicit default configuration to test_sync.py
MJGaughran Jun 30, 2026
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
22 changes: 17 additions & 5 deletions src/deploy_tools/compare.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,15 @@ def _reconstruct_deployment_config_from_modules(layout: Layout) -> Deployment:
Note that the default versions will be different to those in initial configuration.
"""
releases = _collect_releases(layout)
default_versions = _collect_default_modulefile_versions(layout, list(releases))
settings = DeploymentSettings(default_versions=default_versions)
deployment = Deployment(settings=DeploymentSettings(), releases=releases)

return Deployment(settings=settings, releases=releases)
# Only non-deprecated (live) modules are associated with a .version file
live_names = list(deployment.get_final_deployed_modules())
deployment.settings.default_versions = _collect_default_modulefile_versions(
layout, live_names
)

return deployment


def _collect_releases(layout: Layout) -> ReleasesByNameAndVersion:
Expand Down Expand Up @@ -152,7 +157,12 @@ def _collect_default_modulefile_versions(
default_versions: dict[str, str] = {}

for name in names:
default_version = get_default_modulefile_version(name, layout)
try:
default_version = get_default_modulefile_version(name, layout)
except FileNotFoundError as exc:
raise ComparisonError(
f"Live module '{name}' has no default version file"
) from exc
if default_version is not None:
default_versions[name] = default_version

Expand Down Expand Up @@ -194,4 +204,6 @@ def _get_dict_diff(d1: dict[str, Any], d2: dict[str, Any]) -> str:

def _yaml_dumps(obj: dict[str, Any], indent: int | None = None) -> str:
ta = TypeAdapter(dict[str, Any])
return yaml.safe_dump(ta.dump_python(obj), indent=indent)
# Dump in JSON mode so pydantic special types (e.g. AnyUrl) are coerced to YAML-safe
# primitives
return yaml.safe_dump(ta.dump_python(obj, mode="json"), indent=indent)
7 changes: 5 additions & 2 deletions src/deploy_tools/deploy.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import logging
import os
import shutil
from pathlib import Path

Expand Down Expand Up @@ -69,7 +68,7 @@ def _deploy_new_releases(to_add: list[Release], layout: Layout) -> None:
mf_link = layout.get_modulefile_link(name, version, from_deprecated=deprecated)

mf_link.parent.mkdir(parents=True, exist_ok=True)
os.symlink(modulefile, mf_link)
mf_link.symlink_to(modulefile)


def _deploy_releases(
Expand Down Expand Up @@ -155,6 +154,10 @@ def _delete_modulefiles_name_folder(
) -> None:
modulefiles_name_path = layout.get_modulefiles_root(from_deprecated) / name

# Handle several versions of the same module being processed together
if not modulefiles_name_path.exists():
return

# Ignore the default version file when checking for existing modulefile links
if next(modulefiles_name_path.glob(MODULE_VERSIONS_GLOB), None) is None:
shutil.rmtree(modulefiles_name_path)
Expand Down
1 change: 0 additions & 1 deletion src/deploy_tools/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ class Layout:
DEFAULT_VERSION_FILENAME = ".version"

DEPLOYMENT_SNAPSHOT_FILENAME = "deployment.yaml"
PREVIOUS_DEPLOYMENT_SNAPSHOT_FILENAME = "previous-deployment.yaml"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead code (now uses git refs)


def __init__(self, deployment_root: Path, build_root: Path | None = None) -> None:
self._root = deployment_root
Expand Down
13 changes: 8 additions & 5 deletions src/deploy_tools/models/save_and_load.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import yaml
from pydantic import BaseModel
from pydantic import ValidationError as PydanticValidationError

from ..errors import DeployToolsError
from .deployment import (
Expand Down Expand Up @@ -67,18 +68,20 @@ def _load_release(path: Path) -> Release:
if path.is_dir() or not path.suffix == YAML_FILE_SUFFIX:
raise LoadError(f"Unexpected file in configuration directory:\n{path}")

with open(path) as f:
return load_from_yaml(Release, f)
try:
with open(path) as f:
return load_from_yaml(Release, f)
except (OSError, yaml.YAMLError, PydanticValidationError, TypeError) as exc:
# Include exc: under the top-level handler the traceback (and chained cause) is
# suppressed, so the failing field is only visible if surfaced in the message.
raise LoadError(f"Module configuration is invalid:\n{path}\n{exc}") from exc


def _check_filepath_matches(version_path: Path, module: Module) -> None:
"""Ensure the Module's file path (in config folder) matches the metadata.

It should be the Module's name and version as /<config_folder>/<name>/<version>.yaml
"""
if version_path.is_dir() and version_path.suffix == YAML_FILE_SUFFIX:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead code as _load_release() already checks if path.is_dir()

raise LoadError(f"Module directory has incorrect suffix:\n{version_path}")

if not module.name == version_path.parent.name:
raise LoadError(
f"Module name {module.name} does not match path:\n{version_path}"
Expand Down
2 changes: 2 additions & 0 deletions src/deploy_tools/modulefile.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ def apply_default_versions(
overwrite=True,
)
else:
# Defensive: remove a stale default file for a deployed module that has no
# entry in the default map. This ought to be unreachable via the CLI
default_version_file.unlink(missing_ok=True)


Expand Down
27 changes: 20 additions & 7 deletions src/deploy_tools/snapshot.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import io
import logging

from git import Repo
import yaml
from git import BadName, Repo
from pydantic import ValidationError as PydanticValidationError

from .errors import DeployToolsError
from .layout import Layout
Expand Down Expand Up @@ -54,14 +56,25 @@ def load_snapshot(layout: Layout, from_scratch: bool = False) -> Deployment:
)

logger.debug("Loading snapshot: %s", layout.deployment_snapshot_path)
with open(layout.deployment_snapshot_path) as f:
return load_from_yaml(Deployment, f)
try:
with open(layout.deployment_snapshot_path) as f:
return load_from_yaml(Deployment, f)
except (OSError, yaml.YAMLError, PydanticValidationError, TypeError) as exc:
raise SnapshotError(
f"Deployment snapshot could not be read:\n{layout.deployment_snapshot_path}"
) from exc


def load_snapshot_from_ref(layout: Layout, ref: str) -> Deployment:
"""Load the deployment snapshot from the given git ref of the deployment area."""
logger.debug("Loading snapshot from ref: %s", ref)
repo = Repo(layout.deployment_root)
ref_snapshot = repo.commit(ref).tree[layout.DEPLOYMENT_SNAPSHOT_FILENAME]
with io.BytesIO(ref_snapshot.data_stream.read()) as snapshot_f: # type: ignore
return load_from_yaml(Deployment, snapshot_f)
with Repo(layout.deployment_root) as repo:
try:
ref_snapshot = repo.commit(ref).tree[layout.DEPLOYMENT_SNAPSHOT_FILENAME]
except (BadName, KeyError) as exc:
raise SnapshotError(
f"Deployment snapshot not found at git ref:\n{ref}"
) from exc

with io.BytesIO(ref_snapshot.data_stream.read()) as snapshot_f: # type: ignore

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does this need # type: ignore ?

@MJGaughran MJGaughran Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although GitPython has type hints, it appears gitdb (a dependency) does not. I'll add a minor edit to remove the type ignore statement.

This is also just an indentation change.

return load_from_yaml(Deployment, snapshot_f)
48 changes: 33 additions & 15 deletions src/deploy_tools/sync.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import logging
from pathlib import Path

from git import Repo
from git import InvalidGitRepositoryError, Repo

from .build import build, clean_build_area
from .deploy import deploy_changes
from .layout import Layout
from .errors import DeployToolsError
from .layout import Layout, ModuleBuildLayout
from .models.save_and_load import load_deployment
from .snapshot import create_snapshot, load_snapshot
from .templater import Templater, TemplateType
Expand All @@ -15,7 +16,17 @@

logger = logging.getLogger(__name__)

IGNORE_DIRS = ["/build", "/modules/*/*/sif_files"]

class SyncError(DeployToolsError):
"""Raised when the deployment area is not in a state the sync process can use."""


# Files not tracked in the deployment area's git repo (kept only as a change reference):
# the transient build area and the large Apptainer .sif images.
IGNORE_DIRS = [
f"/{Layout.DEFAULT_BUILD_ROOT_NAME}",
f"/{Layout.MODULES_ROOT_NAME}/*/*/{ModuleBuildLayout.SIF_FILES_FOLDER}",
]


def synchronise(
Expand Down Expand Up @@ -45,18 +56,25 @@ def synchronise(
logger.info("Creating git repository in deployment area")
repo = _initialise_git_repo(layout.deployment_root, IGNORE_DIRS)
else:
repo = Repo(layout.deployment_root)

logger.info("Creating snapshot")
create_snapshot(deployment, layout)

logger.info("Deploying changes")
deploy_changes(deployment_changes, layout)

logger.info("Committing changes to git (for reference)")
repo.git.add("--all")
commit = repo.index.commit("Performed sync process")
logger.info("Commit SHA: %s", commit.hexsha)
try:
repo = Repo(layout.deployment_root)
except InvalidGitRepositoryError as exc:
raise SyncError(
f"Deployment area has a snapshot but is not a git repository "
f"(git metadata missing or corrupt):\n{layout.deployment_root}"
) from exc

with repo:
logger.info("Creating snapshot")
create_snapshot(deployment, layout)
Comment thread
ptsOSL marked this conversation as resolved.

logger.info("Deploying changes")
deploy_changes(deployment_changes, layout)

logger.info("Committing changes to git (for reference)")
repo.git.add("--all")
commit = repo.index.commit("Performed sync process")
logger.info("Commit SHA: %s", commit.hexsha)

logger.info("Sync process finished")

Expand Down
4 changes: 2 additions & 2 deletions src/deploy_tools/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,11 +157,11 @@ def _get_release_changes(
return release_changes


def _validate_added_modules(releases: list[Release], from_scratch: bool) -> None:
def _validate_added_modules(releases: list[Release], allow_all: bool) -> None:
for release in releases:
module = release.module
if release.deprecated:
if not from_scratch:
if not allow_all:
raise ValidationError(
f"Module {module.name}/{module.version} cannot have deprecated "
f"status on initial creation."
Expand Down
16 changes: 16 additions & 0 deletions tests/configs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Test configurations

Sample `deploy-tools` configuration folders used by the test suite, grouped by purpose:

- **`golden-master/`** — the `01-initial` … `06-removed` lifecycle sequence. These are
**cumulative and ordered**: each stage is synced on top of the previous one to model a
real deployment lifecycle (only `01-initial` uses `--from-scratch`). Driven by
`test_golden_master.py` and regenerated by `generate_samples.sh`. Do not reorder or make
independent.
- **`valid/`** — standalone, self-contained configs that should deploy/validate cleanly,
each serving one focused test (e.g. `minimal`, `empty`, `multi-version-active`).
- **`invalid/`** — configs that a command must reject; each maps to a test asserting the
specific error message.

When adding a fixture, prefer reusing an existing one; otherwise add a single-purpose
folder under `valid/` or `invalid/`. Leave `golden-master/` to the lifecycle sequence.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# yaml-language-server: $schema=/workspaces/deploy-tools/src/deploy_tools/models/schemas/release.json

module:
name: example-module-shell
version: "1.0"
description: Minimal shell-only module used by the validate tests

applications:
- app_type: shell

name: test-shell-echo
script:
- echo hello
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# yaml-language-server: $schema=/workspaces/deploy-tools/src/deploy_tools/models/schemas/deployment-settings.json

default_versions:
example-module-shell: "2.0"
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# yaml-language-server: $schema=/workspaces/deploy-tools/src/deploy_tools/models/schemas/release.json

module:
name: example-module-shell
version: "1.0"
description: Minimal shell-only module used by the validate tests

applications:
- app_type: shell

name: test-shell-echo
script:
- echo hello
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# yaml-language-server: $schema=/workspaces/deploy-tools/src/deploy_tools/models/schemas/release.json

module:
name: example-module-shell
version: "2.0"
description: Minimal shell-only module used by the validate tests

applications:
- app_type: shell

name: test-shell-echo
script:
- echo hello

deprecated: true
3 changes: 3 additions & 0 deletions tests/configs/invalid/deprecated-on-creation/settings.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# yaml-language-server: $schema=/workspaces/deploy-tools/src/deploy_tools/models/schemas/deployment-settings.json

default_versions: {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# yaml-language-server: $schema=/workspaces/deploy-tools/src/deploy_tools/models/schemas/release.json

# The module name contains a dot, which MODULE_NAME_REGEX forbids; loading must fail
# with a clear LoadError that names the offending field.
module:
name: example.module.shell
version: "1.0"
description: Module with a name that violates the name constraint

applications:
- app_type: shell

name: test-shell-echo
script:
- echo hello
3 changes: 3 additions & 0 deletions tests/configs/invalid/invalid-module-name/settings.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# yaml-language-server: $schema=/workspaces/deploy-tools/src/deploy_tools/models/schemas/deployment-settings.json

default_versions: {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# yaml-language-server: $schema=/workspaces/deploy-tools/src/deploy_tools/models/schemas/release.json

module:
name: example-module-shell
version: "1.0"
description: Module whose shell script is not valid bash

applications:
- app_type: shell

name: test-shell-echo
# 'fi' with no matching 'if' is invalid bash, so `validate --test-build` must
# reject the generated entrypoint when it runs `bash -n` over it.
script:
- fi
3 changes: 3 additions & 0 deletions tests/configs/invalid/invalid-shell-script/settings.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# yaml-language-server: $schema=/workspaces/deploy-tools/src/deploy_tools/models/schemas/deployment-settings.json

default_versions: {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# yaml-language-server: $schema=/workspaces/deploy-tools/src/deploy_tools/models/schemas/release.json

module:
name: example-module-shell
version: "1.0"
description: Minimal shell-only module used by the validate tests

applications:
- app_type: shell

name: test-shell-echo
# Differs from the valid/minimal baseline (echo hello) so that deploying this over
# it is an in-place modification, rejected without allow_updates / a version bump.
script:
- echo modified
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# yaml-language-server: $schema=/workspaces/deploy-tools/src/deploy_tools/models/schemas/deployment-settings.json

default_versions: {}
3 changes: 3 additions & 0 deletions tests/configs/invalid/name-mismatch/settings.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# yaml-language-server: $schema=/workspaces/deploy-tools/src/deploy_tools/models/schemas/deployment-settings.json

default_versions: {}
15 changes: 15 additions & 0 deletions tests/configs/invalid/name-mismatch/wrong-folder-name/1.0.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# yaml-language-server: $schema=/workspaces/deploy-tools/src/deploy_tools/models/schemas/release.json

# The module name does not match its parent folder (wrong-folder-name), which the
# loader requires to match.
module:
name: example-module-shell
version: "1.0"
description: Module whose name does not match its containing folder

applications:
- app_type: shell

name: test-shell-echo
script:
- echo hello
Loading
Loading