Coverage for src/mafw/devtools/cli/dependencies/registry.py: 100%

42 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-09-01 19:49 +0000

1# Copyright 2026 European Union 

2# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu) 

3# SPDX-License-Identifier: EUPL-1.2 

4"""GitLab registry operations for dependency reference files.""" 

5 

6from __future__ import annotations 

7 

8import re 

9from pathlib import Path 

10from typing import Any, Final 

11 

12import click 

13from rich.prompt import Confirm 

14 

15from mafw.__about__ import __version__ as MAFW_VERSION 

16from mafw.devtools.gitlab import ( 

17 GitlabAPIConfiguration, 

18 build_gitlab_api_configuration, 

19 delete_generic_package_file, 

20 delete_generic_package_version, 

21 download_generic_file, 

22 iter_local_pylock_reference_files, 

23 list_generic_package_files, 

24 normalize_dependency_registry_item, 

25 normalize_mafw_version, 

26 parse_pylock_reference_filename, 

27 resolve_package_ids_by_version, 

28 upload_generic_file, 

29) 

30from mafw.tools.click_extensions import AbbreviateGroup 

31from mafw.tools.shell_tools import CONSOLE 

32 

33DEPS_REGISTRY_PACKAGE_NAME: Final[str] = 'mafw-deps' 

34"""Generic package name used for dependency reference uploads.""" 

35 

36PYLOCK_REFERENCE_PATTERN = re.compile(r'^pylock\.py(?P<python_version>3_\d+)_ref\.toml$') 

37"""Regular expression used to validate dependency reference lock filenames. 

38 

39The Python version uses an underscore instead of a dot (e.g. ``3_14``) so that the 

40filename matches the ``uv`` lockfile naming constraint ``^pylock\\.([^.]+\\.)?toml$``. 

41""" 

42 

43 

44def _registry_api_config(ctx: click.Context) -> GitlabAPIConfiguration: 

45 obj = ctx.obj or {} 

46 try: 

47 return build_gitlab_api_configuration( 

48 obj.get('gitlab_api_url'), 

49 obj.get('gitlab_project_id'), 

50 obj.get('gitlab_token'), 

51 ) 

52 except ValueError as exc: 

53 raise click.ClickException(str(exc)) from exc 

54 

55 

56def _normalize_pylock_input(item: str) -> tuple[str, str]: 

57 """Normalize a dependency registry item into its Python version and file name.""" 

58 try: 

59 return normalize_dependency_registry_item(item) 

60 except ValueError as exc: 

61 raise click.ClickException(str(exc)) from exc 

62 

63 

64def _normalize_mafw_version_option(value: str | None) -> str: 

65 """Return the registry label version, defaulting to the current MAFw version.""" 

66 if value is None: 

67 return normalize_mafw_version(MAFW_VERSION) 

68 try: 

69 return normalize_mafw_version(value) 

70 except ValueError as exc: 

71 raise click.ClickException(str(exc)) from exc 

72 

73 

74@click.group( 

75 context_settings={'help_option_names': ['-h', '--help']}, 

76 help='GitLab registry operations for dependency reference files.', 

77 cls=AbbreviateGroup, 

78) 

79@click.option('--gitlab-api-url', default=None, envvar='CI_API_V4_URL', help='GitLab API v4 base URL.') 

80@click.option( 

81 '--gitlab-project-id', 

82 default=None, 

83 type=int, 

84 envvar='CI_PROJECT_ID', 

85 help='GitLab project numeric id (env: CI_PROJECT_ID).', 

86) 

87@click.option('--gitlab-token', default=None, envvar='CI_JOB_TOKEN', help='GitLab API token.') 

88@click.pass_context 

89def registry( 

90 ctx: click.Context, 

91 gitlab_api_url: str | None, 

92 gitlab_project_id: int | None, 

93 gitlab_token: str | None, 

94) -> None: 

95 """GitLab registry command group for dependency reference files.""" 

96 ctx.obj = ctx.obj or {} 

97 ctx.obj['gitlab_api_url'] = gitlab_api_url 

98 ctx.obj['gitlab_project_id'] = gitlab_project_id 

99 ctx.obj['gitlab_token'] = gitlab_token 

100 

101 

102@registry.command() 

103@click.argument( 

104 'items', 

105 nargs=-1, 

106 required=False, 

107 help='Reference filenames or Python versions to upload (e.g. 3.14 or pylock.py3_14_ref.toml)', 

108) 

109@click.option('-a', '--all', 'all_entries', is_flag=True, default=False, help='Upload all matching reference files.') 

110@click.option( 

111 '-f', 

112 '--force', 

113 is_flag=True, 

114 default=False, 

115 help='Force upload by removing existing remote files before uploading. ' 

116 'Without this flag, files already present in the registry are skipped.', 

117) 

118@click.option( 

119 '--mafw-version', 

120 default=None, 

121 help='MAFw package version label to use in the registry. Accepts X.Y.Z or vX.Y.Z.', 

122) 

123@click.pass_context 

124def upload( # pragma: no cover — thin CLI delegation to tested gitlab.registry 

125 ctx: click.Context, items: tuple[str, ...], all_entries: bool, force: bool, mafw_version: str | None 

126) -> None: 

127 """Upload dependency reference files to the GitLab generic registry.""" 

128 api_config = _registry_api_config(ctx) 

129 package_version = _normalize_mafw_version_option(mafw_version) 

130 candidates: list[tuple[str, Path]] = [] 

131 

132 if all_entries: 

133 if items: 

134 raise click.ClickException('Do not pass filenames when using --all.') 

135 candidates = iter_local_pylock_reference_files(Path.cwd()) 

136 else: 

137 if not items: 

138 raise click.ClickException('Provide one or more filenames or Python versions, or use --all.') 

139 for item in items: 

140 python_version, normalized_name = _normalize_pylock_input(item) 

141 fp = Path(item) 

142 if not fp.is_absolute(): 

143 cwd_fp = Path.cwd() / fp 

144 if cwd_fp.exists(): 

145 fp = cwd_fp 

146 elif item == python_version: 

147 fp = Path.cwd() / normalized_name 

148 if not fp.exists(): 

149 raise click.ClickException(f'File not found: {fp}') 

150 if not PYLOCK_REFERENCE_PATTERN.fullmatch(fp.name): 

151 raise click.ClickException(f'File does not match pylock.py3_xx_ref.toml: {fp.name}') 

152 candidates.append((python_version, fp)) 

153 

154 if not candidates: 

155 CONSOLE.print('No matching reference files found.') 

156 return 

157 

158 for python_version, fp in candidates: 

159 uploaded = upload_generic_file( 

160 api_config, 

161 DEPS_REGISTRY_PACKAGE_NAME, 

162 package_version, 

163 fp, 

164 replace_existing=force, 

165 ) 

166 if uploaded: 

167 CONSOLE.print( 

168 f'Uploaded {fp.name} for Python {python_version} as {DEPS_REGISTRY_PACKAGE_NAME}/{package_version}' 

169 ) 

170 else: 

171 CONSOLE.print( 

172 f'Skipped {fp.name} (already exists in ' 

173 f'{DEPS_REGISTRY_PACKAGE_NAME}/{package_version}). Use --force to replace.' 

174 ) 

175 

176 

177@registry.command() 

178@click.argument( 

179 'items', 

180 nargs=-1, 

181 required=False, 

182 help='Reference filenames or Python versions to download (e.g. 3.14 or pylock.py3_14_ref.toml)', 

183) 

184@click.option('--output-dir', default='.', type=click.Path(file_okay=False, dir_okay=True, path_type=Path)) 

185@click.option('-a', '--all', 'all_entries', is_flag=True, default=False, help='Download all registry files.') 

186@click.option( 

187 '--mafw-version', 

188 default=None, 

189 help='MAFw package version label to use in the registry. Accepts X.Y.Z or vX.Y.Z.', 

190) 

191@click.pass_context 

192def download( # pragma: no cover — thin CLI delegation to tested gitlab.registry 

193 ctx: click.Context, 

194 items: tuple[str, ...], 

195 output_dir: Path, 

196 all_entries: bool, 

197 mafw_version: str | None, 

198) -> None: 

199 """Download dependency reference files from the GitLab generic registry.""" 

200 api_config = _registry_api_config(ctx) 

201 package_version = _normalize_mafw_version_option(mafw_version) 

202 output_dir = Path(output_dir).resolve() 

203 output_dir.mkdir(parents=True, exist_ok=True) 

204 

205 if all_entries and items: 

206 raise click.ClickException('Do not pass filenames when using --all.') 

207 

208 mapping = resolve_package_ids_by_version(api_config, DEPS_REGISTRY_PACKAGE_NAME) 

209 pkg_id = mapping.get(package_version) 

210 if pkg_id is None: 

211 CONSOLE.print(f'Not found in registry: {DEPS_REGISTRY_PACKAGE_NAME}/{package_version}') 

212 return 

213 

214 targets: list[tuple[str, str]] = [] 

215 if all_entries: 

216 package_files = list_generic_package_files(api_config, pkg_id) 

217 for pf in package_files: 

218 fname = pf.get('file_name') 

219 if isinstance(fname, str): 

220 parsed = parse_pylock_reference_filename(fname) 

221 if parsed is not None: 

222 python_version, normalized_name = parsed 

223 targets.append((python_version, normalized_name)) 

224 targets.sort(key=lambda item: tuple(int(part) for part in item[0].split('.'))) 

225 else: 

226 if not items: 

227 raise click.ClickException('Provide one or more filenames or Python versions, or use --all.') 

228 for item in items: 

229 python_version, file_name = _normalize_pylock_input(item) 

230 targets.append((python_version, file_name)) 

231 

232 if not targets: 

233 CONSOLE.print(f'No reference files found in {DEPS_REGISTRY_PACKAGE_NAME}/{package_version}.') 

234 return 

235 

236 for python_version, file_name in targets: 

237 dest = download_generic_file( 

238 api_config, 

239 DEPS_REGISTRY_PACKAGE_NAME, 

240 package_version, 

241 file_name, 

242 output_dir, 

243 ) 

244 if dest is not None: 

245 CONSOLE.print(f'Downloaded {python_version}: {dest}') 

246 else: 

247 CONSOLE.print(f'Not found in registry: {DEPS_REGISTRY_PACKAGE_NAME}/{package_version}/{file_name}') 

248 

249 

250@registry.command() 

251@click.argument( 

252 'items', 

253 nargs=-1, 

254 required=False, 

255 help='Reference filenames or Python versions to delete (e.g. 3.14 or pylock.py3_14_ref.toml)', 

256) 

257@click.option('-a', '--all', 'all_entries', is_flag=True, default=False, help='Delete all registry files.') 

258@click.option( 

259 '-y', 

260 '--yes', 

261 is_flag=True, 

262 default=False, 

263 help='Skip confirmation prompt and proceed with deletion. Useful for CI/CD automation.', 

264) 

265@click.option( 

266 '--mafw-version', 

267 default=None, 

268 help='MAFw package version label to use in the registry. Accepts X.Y.Z or vX.Y.Z.', 

269) 

270@click.pass_context 

271def delete( 

272 ctx: click.Context, items: tuple[str, ...], all_entries: bool, yes: bool, mafw_version: str | None 

273) -> None: # pragma: no cover — thin CLI delegation to tested gitlab.registry 

274 """Delete dependency reference files from the GitLab generic registry.""" 

275 api_config = _registry_api_config(ctx) 

276 package_version = _normalize_mafw_version_option(mafw_version) 

277 

278 mapping = resolve_package_ids_by_version(api_config, DEPS_REGISTRY_PACKAGE_NAME) 

279 pkg_id = mapping.get(package_version) 

280 if pkg_id is None: 

281 CONSOLE.print(f'Not found in registry: {DEPS_REGISTRY_PACKAGE_NAME}/{package_version}') 

282 return 

283 

284 if all_entries and items: 

285 raise click.ClickException('Do not pass filenames when using --all.') 

286 

287 if all_entries: 

288 if not yes: 

289 confirmed = Confirm.ask( 

290 f'Delete all reference files from {DEPS_REGISTRY_PACKAGE_NAME}/{package_version}?', 

291 default=False, 

292 console=CONSOLE, 

293 ) 

294 if not confirmed: 

295 CONSOLE.print('Aborted.') 

296 return 

297 

298 deleted = delete_generic_package_version(api_config, pkg_id, DEPS_REGISTRY_PACKAGE_NAME, package_version) 

299 if deleted: 

300 CONSOLE.print(f'Deleted all reference files from {DEPS_REGISTRY_PACKAGE_NAME}/{package_version}') 

301 else: 

302 CONSOLE.print(f'Not found in registry: {DEPS_REGISTRY_PACKAGE_NAME}/{package_version}') 

303 return 

304 

305 if not items: 

306 raise click.ClickException('Provide one or more filenames or Python versions, or use --all.') 

307 

308 targets: list[tuple[str, str]] = [] 

309 for item in items: 

310 python_version, file_name = _normalize_pylock_input(item) 

311 targets.append((python_version, file_name)) 

312 

313 if not yes: 

314 file_list = ', '.join(file_name for _, file_name in targets) 

315 confirmed = Confirm.ask( 

316 f'Delete {file_list} from {DEPS_REGISTRY_PACKAGE_NAME}/{package_version}?', 

317 default=False, 

318 console=CONSOLE, 

319 ) 

320 if not confirmed: 

321 CONSOLE.print('Aborted.') 

322 return 

323 

324 package_files = list_generic_package_files(api_config, pkg_id) 

325 file_name_to_id: dict[str, int] = {} 

326 for pf in package_files: 

327 fname = pf.get('file_name') 

328 fid = pf.get('id') 

329 if isinstance(fname, str) and isinstance(fid, int): 

330 file_name_to_id[fname] = fid 

331 

332 for _python_version, file_name in targets: 

333 file_id = file_name_to_id.get(file_name) 

334 if file_id is None: 

335 CONSOLE.print(f'Not found in registry: {DEPS_REGISTRY_PACKAGE_NAME}/{package_version}/{file_name}') 

336 continue 

337 deleted = delete_generic_package_file(api_config, pkg_id, file_id) 

338 if deleted: 

339 CONSOLE.print(f'Deleted {DEPS_REGISTRY_PACKAGE_NAME}/{package_version}/{file_name}') 

340 else: 

341 CONSOLE.print(f'Not found in registry: {DEPS_REGISTRY_PACKAGE_NAME}/{package_version}/{file_name}') 

342 

343 

344@registry.command() 

345@click.option( 

346 '--keep-newest/--keep-oldest', 

347 default=True, 

348 show_default=True, 

349 help='When duplicates are found, keep the newest (default) or the oldest file.', 

350) 

351@click.option( 

352 '-f', 

353 '--force', 

354 is_flag=True, 

355 default=False, 

356 help='Skip confirmation prompt and proceed with pruning. Useful for CI/CD automation.', 

357) 

358@click.option( 

359 '--mafw-version', 

360 default=None, 

361 help='MAFw package version label to use in the registry. Accepts X.Y.Z or vX.Y.Z.', 

362) 

363@click.pass_context 

364def prune( 

365 ctx: click.Context, keep_newest: bool, force: bool, mafw_version: str | None 

366) -> None: # pragma: no cover — thin CLI delegation to tested gitlab.registry 

367 """Remove duplicated reference files from a registry package version.""" 

368 api_config = _registry_api_config(ctx) 

369 package_version = _normalize_mafw_version_option(mafw_version) 

370 

371 mapping = resolve_package_ids_by_version(api_config, DEPS_REGISTRY_PACKAGE_NAME) 

372 pkg_id = mapping.get(package_version) 

373 if pkg_id is None: 

374 CONSOLE.print(f'Not found in registry: {DEPS_REGISTRY_PACKAGE_NAME}/{package_version}') 

375 return 

376 

377 package_files = list_generic_package_files(api_config, pkg_id) 

378 

379 files_by_name: dict[str, list[dict[str, Any]]] = {} 

380 for pf in package_files: 

381 fname = pf.get('file_name') 

382 if isinstance(fname, str): 

383 files_by_name.setdefault(fname, []).append(pf) 

384 

385 to_delete: list[tuple[str, int, str]] = [] 

386 for fname, entries in files_by_name.items(): 

387 if len(entries) <= 1: 

388 continue 

389 sorted_entries = sorted(entries, key=lambda e: e.get('created_at', '')) 

390 if keep_newest: 

391 duplicates = sorted_entries[:-1] 

392 else: 

393 duplicates = sorted_entries[1:] 

394 for entry in duplicates: 

395 fid = entry.get('id') 

396 created_at = entry.get('created_at', 'unknown') 

397 if isinstance(fid, int): 

398 to_delete.append((fname, fid, str(created_at))) 

399 

400 if not to_delete: 

401 CONSOLE.print(f'No duplicates found in {DEPS_REGISTRY_PACKAGE_NAME}/{package_version}.') 

402 return 

403 

404 keep_label = 'newest' if keep_newest else 'oldest' 

405 CONSOLE.print( 

406 f'Found {len(to_delete)} duplicate(s) to remove ' 

407 f'(keeping {keep_label}) in {DEPS_REGISTRY_PACKAGE_NAME}/{package_version}:' 

408 ) 

409 for fname, _fid, created_at in to_delete: 

410 CONSOLE.print(f' - {fname} (created: {created_at})') 

411 

412 if not force: 

413 confirmed = Confirm.ask( 

414 'Proceed with deletion?', 

415 default=False, 

416 console=CONSOLE, 

417 ) 

418 if not confirmed: 

419 CONSOLE.print('Aborted.') 

420 return 

421 

422 deleted_count = 0 

423 for fname, fid, created_at in to_delete: 

424 deleted = delete_generic_package_file(api_config, pkg_id, fid) 

425 if deleted: 

426 CONSOLE.print(f'Deleted {fname} (created: {created_at})') 

427 deleted_count += 1 

428 else: 

429 CONSOLE.print(f'Failed to delete {fname} (id={fid})') 

430 

431 CONSOLE.print(f'Pruning complete: {deleted_count} duplicate(s) removed.')