Skip to content
Merged
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
8 changes: 8 additions & 0 deletions autotest/test_programs.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def test_save_and_load_registry(self):
"test-program": {
"version": "1.0.0",
"repo": "test/repo",
"exe": "bin/test-program",
"binaries": {},
}
},
Expand Down Expand Up @@ -272,6 +273,13 @@ def test_program_manager_list_installed_empty(self):
# Use fresh cache
cache = ProgramCache()
cache.clear()

# Also clear metadata directory to ensure no leftover installation data
if cache.metadata_dir.exists():
import shutil

shutil.rmtree(cache.metadata_dir)

manager = ProgramManager(cache=cache)

installed = manager.list_installed()
Expand Down
68 changes: 43 additions & 25 deletions modflow_devtools/programs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,14 @@ class ProgramRegistryDiscoveryError(Exception):
pass


class ProgramBinary(BaseModel):
"""Platform-specific binary information."""
class ProgramDistribution(BaseModel):
"""Distribution-specific information."""

name: str = Field(
..., description="Distribution name (e.g., linux, mac, macarm, win64, win64ext)"
)
asset: str = Field(..., description="Release asset filename")
hash: str | None = Field(None, description="SHA256 hash")
exe: str = Field(..., description="Executable path within archive")

model_config = {"arbitrary_types_allowed": True}

Expand All @@ -92,8 +94,9 @@ class ProgramMetadata(BaseModel):
description: str | None = Field(None, description="Program description")
repo: str = Field(..., description="Source repository (owner/name)")
license: str | None = Field(None, description="License identifier")
binaries: dict[str, ProgramBinary] = Field(
default_factory=dict, description="Platform-specific binaries (linux/mac/win64)"
exe: str = Field(..., description="Executable path within archive (e.g., bin/mf6)")
dists: list[ProgramDistribution] = Field(
default_factory=list, description="Available distributions"
)

model_config = {"arbitrary_types_allowed": True}
Expand Down Expand Up @@ -1239,15 +1242,19 @@ def install(
if verbose:
print(f"Detected platform: {platform}")

# 4. Get binary metadata
if platform not in program_meta.binaries:
available = ", ".join(program_meta.binaries.keys())
# 4. Get distribution metadata
dist_meta = None
for dist in program_meta.dists:
if dist.name == platform:
dist_meta = dist
break

if dist_meta is None:
available = ", ".join(d.name for d in program_meta.dists)
raise ProgramInstallationError(
f"Binary not available for platform '{platform}'. Available platforms: {available}"
f"Distribution not available for platform '{platform}'. Available: {available}"
)

binary_meta = program_meta.binaries[platform]

# 5. Determine bindir
if bindir is None:
bindir_options = get_bindir_options(program)
Expand All @@ -1270,28 +1277,35 @@ def install(
if verbose:
print(f"{program} {version} is already installed in {bindir}")
# Return paths to existing executables
exe_name = Path(binary_meta.exe).name
exe_name = Path(program_meta.exe).name
# Add .exe extension on Windows
if platform.startswith("win") and not exe_name.endswith(".exe"):
exe_name += ".exe"
return [bindir / exe_name]

# 7. Download archive (if not cached)
asset_url = f"https://github.com/{program_meta.repo}/releases/download/{found_ref}/{binary_meta.asset}"
asset_url = f"https://github.com/{program_meta.repo}/releases/download/{found_ref}/{dist_meta.asset}"
archive_dir = self.cache.get_archive_dir(program, version, platform)
archive_path = archive_dir / binary_meta.asset
archive_path = archive_dir / dist_meta.asset

if verbose:
print(f"Downloading archive from {asset_url}...")

download_archive(
url=asset_url,
dest=archive_path,
expected_hash=binary_meta.hash,
expected_hash=dist_meta.hash,
force=force,
verbose=verbose,
)

# 8. Extract to binaries cache (if not already extracted)
binary_dir = self.cache.get_binary_dir(program, version, platform)
exe_in_cache = binary_dir / binary_meta.exe
exe_path = program_meta.exe
# Add .exe extension on Windows for extraction path
if platform.startswith("win") and not exe_path.endswith(".exe"):
exe_path += ".exe"
exe_in_cache = binary_dir / exe_path

if not exe_in_cache.exists() or force:
if verbose:
Expand All @@ -1300,12 +1314,15 @@ def install(
extract_executables(
archive=archive_path,
dest_dir=binary_dir,
exe_path=binary_meta.exe,
exe_path=exe_path,
verbose=verbose,
)

# 9. Copy executables to bindir
exe_name = Path(binary_meta.exe).name
exe_name = Path(program_meta.exe).name
# Add .exe extension on Windows
if platform.startswith("win") and not exe_name.endswith(".exe"):
exe_name += ".exe"
dest_exe = bindir / exe_name

if verbose:
Expand All @@ -1326,7 +1343,7 @@ def install(
"repo": program_meta.repo,
"tag": found_ref,
"asset_url": asset_url,
"hash": binary_meta.hash or "",
"hash": dist_meta.hash or "",
}
installation = ProgramInstallation(
version=version,
Expand Down Expand Up @@ -1842,7 +1859,11 @@ def list_installed(program: str | None = None) -> dict[str, list[ProgramInstalla


def _try_best_effort_sync():
"""Attempt to sync registries on first import (best-effort, fails silently)."""
"""
Attempt to sync registries (best-effort, fails silently).

Called by consumer commands before accessing program registries.
"""
global _SYNC_ATTEMPTED

if _SYNC_ATTEMPTED:
Expand All @@ -1861,10 +1882,6 @@ def _try_best_effort_sync():
_SYNC_ATTEMPTED = False
"""Track whether auto-sync has been attempted"""

# Attempt best-effort sync on import (unless disabled)
if not os.environ.get("MODFLOW_DEVTOOLS_NO_AUTO_SYNC"):
_try_best_effort_sync()


# ============================================================================
# Public API
Expand All @@ -1875,8 +1892,8 @@ def _try_best_effort_sync():
"_DEFAULT_MANAGER",
"DiscoveredProgramRegistry",
"InstallationMetadata",
"ProgramBinary",
"ProgramCache",
"ProgramDistribution",
"ProgramInstallation",
"ProgramInstallationError",
"ProgramManager",
Expand All @@ -1885,6 +1902,7 @@ def _try_best_effort_sync():
"ProgramRegistryDiscoveryError",
"ProgramSourceConfig",
"ProgramSourceRepo",
"_try_best_effort_sync",
"download_archive",
"extract_executables",
"get_bindir_options",
Expand Down
16 changes: 13 additions & 3 deletions modflow_devtools/programs/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@
"""

import argparse
import os
import sys

from . import (
_DEFAULT_CACHE,
ProgramSourceConfig,
_try_best_effort_sync,
get_executable,
install_program,
list_installed,
Expand Down Expand Up @@ -68,6 +70,10 @@ def cmd_info(args):

def cmd_list(args):
"""List command handler."""
# Attempt auto-sync before listing (unless disabled)
if not os.environ.get("MODFLOW_DEVTOOLS_NO_AUTO_SYNC"):
_try_best_effort_sync()

cached = _DEFAULT_CACHE.list()

if not cached:
Expand Down Expand Up @@ -106,17 +112,21 @@ def cmd_list(args):
# Show all programs in verbose mode
for program_name, metadata in sorted(programs.items()):
version = metadata.version
platforms = (
", ".join(metadata.binaries.keys()) if metadata.binaries else "none"
dist_names = (
", ".join(d.name for d in metadata.dists) if metadata.dists else "none"
)
print(f" - {program_name} ({version}) [{platforms}]")
print(f" - {program_name} ({version}) [{dist_names}]")
else:
print(" No programs")
print()


def cmd_install(args):
"""Install command handler."""
# Attempt auto-sync before installation (unless disabled)
if not os.environ.get("MODFLOW_DEVTOOLS_NO_AUTO_SYNC"):
_try_best_effort_sync()

try:
paths = install_program(
program=args.program,
Expand Down
Loading