Skip to content

Commit bb76dc1

Browse files
authored
refactor(programs): multiple programs api improvements (#276)
Refine #263 * move exe attribute to program section * rename binary to distribution * don't auto-sync on registry file generation, only on api consumer commands * update registry file generation to work with local assets
1 parent f6cc9bb commit bb76dc1

5 files changed

Lines changed: 230 additions & 90 deletions

File tree

autotest/test_programs.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ def test_save_and_load_registry(self):
3232
"test-program": {
3333
"version": "1.0.0",
3434
"repo": "test/repo",
35+
"exe": "bin/test-program",
3536
"binaries": {},
3637
}
3738
},
@@ -272,6 +273,13 @@ def test_program_manager_list_installed_empty(self):
272273
# Use fresh cache
273274
cache = ProgramCache()
274275
cache.clear()
276+
277+
# Also clear metadata directory to ensure no leftover installation data
278+
if cache.metadata_dir.exists():
279+
import shutil
280+
281+
shutil.rmtree(cache.metadata_dir)
282+
275283
manager = ProgramManager(cache=cache)
276284

277285
installed = manager.list_installed()

modflow_devtools/programs/__init__.py

Lines changed: 43 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,14 @@ class ProgramRegistryDiscoveryError(Exception):
7575
pass
7676

7777

78-
class ProgramBinary(BaseModel):
79-
"""Platform-specific binary information."""
78+
class ProgramDistribution(BaseModel):
79+
"""Distribution-specific information."""
8080

81+
name: str = Field(
82+
..., description="Distribution name (e.g., linux, mac, macarm, win64, win64ext)"
83+
)
8184
asset: str = Field(..., description="Release asset filename")
8285
hash: str | None = Field(None, description="SHA256 hash")
83-
exe: str = Field(..., description="Executable path within archive")
8486

8587
model_config = {"arbitrary_types_allowed": True}
8688

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

99102
model_config = {"arbitrary_types_allowed": True}
@@ -1239,15 +1242,19 @@ def install(
12391242
if verbose:
12401243
print(f"Detected platform: {platform}")
12411244

1242-
# 4. Get binary metadata
1243-
if platform not in program_meta.binaries:
1244-
available = ", ".join(program_meta.binaries.keys())
1245+
# 4. Get distribution metadata
1246+
dist_meta = None
1247+
for dist in program_meta.dists:
1248+
if dist.name == platform:
1249+
dist_meta = dist
1250+
break
1251+
1252+
if dist_meta is None:
1253+
available = ", ".join(d.name for d in program_meta.dists)
12451254
raise ProgramInstallationError(
1246-
f"Binary not available for platform '{platform}'. Available platforms: {available}"
1255+
f"Distribution not available for platform '{platform}'. Available: {available}"
12471256
)
12481257

1249-
binary_meta = program_meta.binaries[platform]
1250-
12511258
# 5. Determine bindir
12521259
if bindir is None:
12531260
bindir_options = get_bindir_options(program)
@@ -1270,28 +1277,35 @@ def install(
12701277
if verbose:
12711278
print(f"{program} {version} is already installed in {bindir}")
12721279
# Return paths to existing executables
1273-
exe_name = Path(binary_meta.exe).name
1280+
exe_name = Path(program_meta.exe).name
1281+
# Add .exe extension on Windows
1282+
if platform.startswith("win") and not exe_name.endswith(".exe"):
1283+
exe_name += ".exe"
12741284
return [bindir / exe_name]
12751285

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

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

12841294
download_archive(
12851295
url=asset_url,
12861296
dest=archive_path,
1287-
expected_hash=binary_meta.hash,
1297+
expected_hash=dist_meta.hash,
12881298
force=force,
12891299
verbose=verbose,
12901300
)
12911301

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

12961310
if not exe_in_cache.exists() or force:
12971311
if verbose:
@@ -1300,12 +1314,15 @@ def install(
13001314
extract_executables(
13011315
archive=archive_path,
13021316
dest_dir=binary_dir,
1303-
exe_path=binary_meta.exe,
1317+
exe_path=exe_path,
13041318
verbose=verbose,
13051319
)
13061320

13071321
# 9. Copy executables to bindir
1308-
exe_name = Path(binary_meta.exe).name
1322+
exe_name = Path(program_meta.exe).name
1323+
# Add .exe extension on Windows
1324+
if platform.startswith("win") and not exe_name.endswith(".exe"):
1325+
exe_name += ".exe"
13091326
dest_exe = bindir / exe_name
13101327

13111328
if verbose:
@@ -1326,7 +1343,7 @@ def install(
13261343
"repo": program_meta.repo,
13271344
"tag": found_ref,
13281345
"asset_url": asset_url,
1329-
"hash": binary_meta.hash or "",
1346+
"hash": dist_meta.hash or "",
13301347
}
13311348
installation = ProgramInstallation(
13321349
version=version,
@@ -1842,7 +1859,11 @@ def list_installed(program: str | None = None) -> dict[str, list[ProgramInstalla
18421859

18431860

18441861
def _try_best_effort_sync():
1845-
"""Attempt to sync registries on first import (best-effort, fails silently)."""
1862+
"""
1863+
Attempt to sync registries (best-effort, fails silently).
1864+
1865+
Called by consumer commands before accessing program registries.
1866+
"""
18461867
global _SYNC_ATTEMPTED
18471868

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

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

18691886
# ============================================================================
18701887
# Public API
@@ -1875,8 +1892,8 @@ def _try_best_effort_sync():
18751892
"_DEFAULT_MANAGER",
18761893
"DiscoveredProgramRegistry",
18771894
"InstallationMetadata",
1878-
"ProgramBinary",
18791895
"ProgramCache",
1896+
"ProgramDistribution",
18801897
"ProgramInstallation",
18811898
"ProgramInstallationError",
18821899
"ProgramManager",
@@ -1885,6 +1902,7 @@ def _try_best_effort_sync():
18851902
"ProgramRegistryDiscoveryError",
18861903
"ProgramSourceConfig",
18871904
"ProgramSourceRepo",
1905+
"_try_best_effort_sync",
18881906
"download_archive",
18891907
"extract_executables",
18901908
"get_bindir_options",

modflow_devtools/programs/__main__.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@
1212
"""
1313

1414
import argparse
15+
import os
1516
import sys
1617

1718
from . import (
1819
_DEFAULT_CACHE,
1920
ProgramSourceConfig,
21+
_try_best_effort_sync,
2022
get_executable,
2123
install_program,
2224
list_installed,
@@ -68,6 +70,10 @@ def cmd_info(args):
6870

6971
def cmd_list(args):
7072
"""List command handler."""
73+
# Attempt auto-sync before listing (unless disabled)
74+
if not os.environ.get("MODFLOW_DEVTOOLS_NO_AUTO_SYNC"):
75+
_try_best_effort_sync()
76+
7177
cached = _DEFAULT_CACHE.list()
7278

7379
if not cached:
@@ -106,17 +112,21 @@ def cmd_list(args):
106112
# Show all programs in verbose mode
107113
for program_name, metadata in sorted(programs.items()):
108114
version = metadata.version
109-
platforms = (
110-
", ".join(metadata.binaries.keys()) if metadata.binaries else "none"
115+
dist_names = (
116+
", ".join(d.name for d in metadata.dists) if metadata.dists else "none"
111117
)
112-
print(f" - {program_name} ({version}) [{platforms}]")
118+
print(f" - {program_name} ({version}) [{dist_names}]")
113119
else:
114120
print(" No programs")
115121
print()
116122

117123

118124
def cmd_install(args):
119125
"""Install command handler."""
126+
# Attempt auto-sync before installation (unless disabled)
127+
if not os.environ.get("MODFLOW_DEVTOOLS_NO_AUTO_SYNC"):
128+
_try_best_effort_sync()
129+
120130
try:
121131
paths = install_program(
122132
program=args.program,

0 commit comments

Comments
 (0)