Coverage for src/mafw/devtools/cli/dependencies/latest.py: 96%

169 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"""Rolling compatibility commands for the latest versions of MAFw dependencies.""" 

5 

6from __future__ import annotations 

7 

8import subprocess 

9import tomllib 

10from pathlib import Path 

11from typing import Final 

12 

13import click 

14import tomlkit 

15 

16from mafw.__about__ import __version__ as MAFW_VERSION 

17from mafw.devtools.dependencies.compare import ( 

18 VersionComparisonResult, 

19 compare_packages, 

20 render_comparison_json, 

21 render_comparison_markdown, 

22 render_comparison_rich, 

23 resolve_output_format, 

24) 

25from mafw.devtools.dependencies.compile import ( 

26 DEFAULT_FREEZE_EXTRAS, 

27 compile_dependency_lockfile, 

28 compile_python_selector, 

29 ensure_mafw_project_root, 

30 load_pylock_packages, 

31 python_versions_between, 

32) 

33from mafw.devtools.gitlab import ( 

34 GitlabAPIConfiguration, 

35 build_gitlab_api_configuration, 

36 build_pylock_compiled_filename, 

37 build_pylock_reference_filename, 

38 download_generic_file, 

39 normalize_mafw_version, 

40 upload_generic_file, 

41) 

42from mafw.tools.click_extensions import AbbreviateGroup 

43from mafw.tools.shell_tools import CONSOLE 

44from mafw.tools.shell_tools import run as cmd 

45 

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

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

48 

49DEFAULT_TEST_FILES: Final[list[str]] = [ 

50 'tests/test_full_integration.py', 

51] 

52"""List of test files executed by default during dependency verification.""" 

53 

54 

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

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

57 if value is None: 

58 return normalize_mafw_version(MAFW_VERSION) 

59 try: 

60 return normalize_mafw_version(value) 

61 except ValueError as exc: 

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

63 

64 

65def _upload_pylock_reference( 

66 api_config: GitlabAPIConfiguration, 

67 python_version: str, 

68 file_path: Path, 

69 package_version: str, 

70) -> None: 

71 """Upload a single dependency reference file to the GitLab registry.""" 

72 upload_generic_file( 

73 api_config, 

74 DEPS_REGISTRY_PACKAGE_NAME, 

75 package_version, 

76 file_path, 

77 replace_existing=True, 

78 ) 

79 CONSOLE.print( 

80 f'Uploaded {file_path.name} for Python {python_version} as {DEPS_REGISTRY_PACKAGE_NAME}/{package_version}' 

81 ) 

82 

83 

84def _download_pylock_reference( 

85 api_config: GitlabAPIConfiguration, 

86 python_version: str, 

87 file_name: str, 

88 package_version: str, 

89 output_dir: Path, 

90 *, 

91 quiet_if_missing: bool = False, 

92) -> Path | None: 

93 """Download a single dependency reference file from the GitLab registry.""" 

94 dest = download_generic_file( 

95 api_config, 

96 DEPS_REGISTRY_PACKAGE_NAME, 

97 package_version, 

98 file_name, 

99 output_dir, 

100 ) 

101 if dest is not None: 

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

103 elif not quiet_if_missing: 

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

105 return dest 

106 

107 

108@click.group( 

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

110 help='Rolling compatibility for the latest versions of MAFw dependencies.', 

111 cls=AbbreviateGroup, 

112) 

113def latest() -> None: 

114 """Group for latest-dependency verification commands.""" 

115 

116 

117@latest.command( 

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

119 help='Verify the latest dependency stack across Python versions.\n\n' 

120 'This command will perform a compatibility check with the newest version\n' 

121 'of all MAFw dependencies.', 

122) 

123@click.option( 

124 '--min-python-ver', 

125 default='3.11', 

126 show_default=True, 

127 type=click.STRING, 

128 help='The oldest version of python to be used for the dependencies verification, constrain it to >=3.11', 

129) 

130@click.option( 

131 '--max-python-ver', 

132 default='3.14', 

133 show_default=True, 

134 type=click.STRING, 

135 help='The newest version of python to be used for the dependencies verification', 

136) 

137@click.option( 

138 '--full-unittest', 

139 is_flag=True, 

140 default=False, 

141 help='Perform the test over the whole test suite.', 

142) 

143@click.option( 

144 '--with-dep-file/--without-dep-file', 

145 default=True, 

146 show_default=True, 

147 help='Generate dependency lock files using uv pip compile and uninstall managed python before check.', 

148) 

149@click.option( 

150 '--compare-with-ref/--no-compare-with-ref', 

151 default=True, 

152 show_default=True, 

153 help='Compare the generated dependency file with a reference one and skip tests if identical.', 

154) 

155@click.option( 

156 '--gitlab-ref/--no-gitlab-ref', 

157 default=False, 

158 show_default=True, 

159 help='Download/upload dependency reference files from/to the GitLab generic registry.', 

160) 

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

162@click.option('--gitlab-project-id', default=None, type=click.INT, envvar='CI_PROJECT_ID', help='GitLab project ID.') 

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

164@click.option( 

165 '--preserve-envs', 

166 is_flag=True, 

167 default=False, 

168 show_default=True, 

169 help='Preserve created hatch environments after the check is completed instead of removing them.', 

170) 

171def check( 

172 min_python_ver: str, 

173 max_python_ver: str, 

174 full_unittest: bool, 

175 with_dep_file: bool, 

176 compare_with_ref: bool, 

177 gitlab_ref: bool, 

178 gitlab_api_url: str | None, 

179 gitlab_project_id: int | None, 

180 gitlab_token: str | None, 

181 preserve_envs: bool, 

182) -> None: 

183 """Verify the latest dependency stack for a Python version range.""" 

184 supported_python_versions = ensure_mafw_project_root() 

185 api_config: GitlabAPIConfiguration | None = None 

186 package_version = _normalize_mafw_version_option(None) 

187 if gitlab_ref: 

188 try: 

189 api_config = build_gitlab_api_configuration(gitlab_api_url, gitlab_project_id, gitlab_token) 

190 except ValueError as exc: 

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

192 

193 for python_version in python_versions_between(min_python_ver, max_python_ver, supported_python_versions): 

194 pylock_file = Path(build_pylock_compiled_filename(python_version)) 

195 pylock_ref_file = Path(build_pylock_reference_filename(python_version)) 

196 

197 if with_dep_file or compare_with_ref or gitlab_ref: 

198 pylock_file.unlink(missing_ok=True) 

199 compile_dependency_lockfile(python_version, pylock_file, list(DEFAULT_FREEZE_EXTRAS)) 

200 cmd(['uv', 'python', 'uninstall', '--managed-python', python_version]) 

201 

202 if gitlab_ref and api_config: 

203 dest = _download_pylock_reference( 

204 api_config, 

205 compile_python_selector(python_version), 

206 pylock_ref_file.name, 

207 package_version, 

208 Path.cwd(), 

209 quiet_if_missing=True, 

210 ) 

211 if dest is None: 

212 CONSOLE.print(f'WARNING: reference file for Python {python_version} not found in GitLab registry.') 

213 if pylock_ref_file.exists(): 213 ↛ 216line 213 didn't jump to line 216 because the condition on line 213 was always true

214 CONSOLE.print(f'A local reference file {pylock_ref_file} exists and will be used instead.') 

215 

216 if (compare_with_ref or gitlab_ref) and pylock_ref_file.exists(): 

217 current_data = tomlkit.loads(pylock_file.read_text(encoding='utf-8')) 

218 reference_data = tomlkit.loads(pylock_ref_file.read_text(encoding='utf-8')) 

219 

220 current_pkgs = {pkg['name']: pkg for pkg in current_data.get('packages', [])} 

221 reference_pkgs = {pkg['name']: pkg for pkg in reference_data.get('packages', [])} 

222 

223 differences: list[str] = [] 

224 for name, pkg in current_pkgs.items(): 

225 if name not in reference_pkgs: 

226 differences.append(f'ADDED {name} {pkg.get("version", "unknown")}') 

227 else: 

228 ref_pkg = reference_pkgs[name] 

229 if pkg.get('version') != ref_pkg.get('version') or pkg.get('marker') != ref_pkg.get('marker'): 

230 differences.append(f'UPDATED {name}: {ref_pkg.get("version")} -> {pkg.get("version")}') 

231 

232 for name in reference_pkgs: 

233 if name not in current_pkgs: 

234 differences.append(f'REMOVED {name}') 

235 

236 if not differences: 

237 CONSOLE.print(f'Dependencies for Python {python_version} are identical to reference. Skipping tests.') 

238 continue 

239 

240 CONSOLE.print(f'Dependencies for Python {python_version} have changed:') 

241 for diff in differences: 

242 CONSOLE.print(f' - {diff}') 

243 

244 ### UNIT TESTS 

245 

246 # Step 1. Remove existing hatch-test env 

247 cmd(['hatch', 'env', 'remove', f'hatch-test.py{python_version}']) 

248 

249 # Step 2. Create it again from scratch 

250 cmd(['hatch', 'env', 'create', f'hatch-test.py{python_version}']) 

251 

252 # Step 3. Run the test with either a selection of files or the whole test suite. 

253 hatch_test_cmd = ['hatch', 'test', '-py', python_version] 

254 files_to_be_tested = [] 

255 if not full_unittest: 

256 files_to_be_tested = DEFAULT_TEST_FILES 

257 cmd(hatch_test_cmd + files_to_be_tested) 

258 

259 # Step 4. Clean up to save space 

260 if not preserve_envs: 

261 cmd(['hatch', 'env', 'remove', f'hatch-test.py{python_version}']) 

262 

263 ### STATIC TYPING 

264 

265 # Step 1. Remove env 

266 cmd(['hatch', 'env', 'remove', f'types.py{python_version}']) 

267 

268 # Step 2. Create env 

269 cmd(['hatch', 'env', 'create', f'types.py{python_version}']) 

270 

271 # Step 3. Run the test 

272 cmd(['hatch', 'run', f'types.py{python_version}:check', '--python-version', python_version]) 

273 

274 # Step 4. Clean up 

275 if not preserve_envs: 

276 cmd(['hatch', 'env', 'remove', f'types.py{python_version}']) 

277 

278 if compare_with_ref or gitlab_ref: 

279 pylock_file.replace(pylock_ref_file) 

280 CONSOLE.print(f'Updated reference file: {pylock_ref_file}') 

281 if gitlab_ref and api_config: 

282 _upload_pylock_reference(api_config, python_version, pylock_ref_file, package_version) 

283 

284 

285@latest.command( 

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

287 help='Compare latest dependency versions against reference pylock files.\n\n' 

288 'This command compiles fresh dependency lockfiles and compares them\n' 

289 'against reference pylock files for each Python version in the\n' 

290 'specified range, classifying differences as ADDED, REMOVED, or UPDATED.', 

291) 

292@click.option( 

293 '--min-python-ver', 

294 default='3.11', 

295 show_default=True, 

296 type=click.STRING, 

297 help='The minimum Python version (major.minor) for the comparison range.', 

298) 

299@click.option( 

300 '--max-python-ver', 

301 default='3.14', 

302 show_default=True, 

303 type=click.STRING, 

304 help='The maximum Python version (major.minor) for the comparison range.', 

305) 

306@click.option( 

307 '--gitlab-ref/--no-gitlab-ref', 

308 default=False, 

309 show_default=True, 

310 help='Download reference files from the GitLab generic registry.', 

311) 

312@click.option( 

313 '--gitlab-api-url', 

314 default=None, 

315 envvar='CI_API_V4_URL', 

316 help='GitLab API v4 base URL.', 

317) 

318@click.option( 

319 '--gitlab-project-id', 

320 default=None, 

321 type=click.INT, 

322 envvar='CI_PROJECT_ID', 

323 help='GitLab project ID.', 

324) 

325@click.option( 

326 '--gitlab-token', 

327 default=None, 

328 envvar='CI_JOB_TOKEN', 

329 help='GitLab API token.', 

330) 

331@click.option( 

332 '-o', 

333 '--output-file', 

334 default=None, 

335 type=click.Path(path_type=Path), 

336 help='Output file path for the comparison results.', 

337) 

338@click.option( 

339 '-f', 

340 '--format', 

341 'output_format', 

342 default=None, 

343 type=click.Choice(['json', 'markdown'], case_sensitive=False), 

344 help='Output file format. Inferred from --output-file extension if omitted.', 

345) 

346@click.option( 

347 '-q', 

348 '--quiet', 

349 is_flag=True, 

350 default=False, 

351 help='Suppress console output (requires --output-file).', 

352) 

353def compare( 

354 min_python_ver: str, 

355 max_python_ver: str, 

356 gitlab_ref: bool, 

357 gitlab_api_url: str | None, 

358 gitlab_project_id: int | None, 

359 gitlab_token: str | None, 

360 output_file: Path | None, 

361 output_format: str | None, 

362 quiet: bool, 

363) -> None: 

364 """Compare latest dependency versions against reference pylock files.""" 

365 supported_python_versions = ensure_mafw_project_root() 

366 

367 if quiet and output_file is None: 

368 click.echo( 

369 'Error: --quiet requires --output-file to be specified.', 

370 err=True, 

371 ) 

372 raise SystemExit(1) 

373 

374 api_config: GitlabAPIConfiguration | None = None 

375 package_version = _normalize_mafw_version_option(None) 

376 if gitlab_ref: 

377 try: 

378 api_config = build_gitlab_api_configuration(gitlab_api_url, gitlab_project_id, gitlab_token) 

379 except ValueError as exc: 

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

381 

382 python_versions = python_versions_between(min_python_ver, max_python_ver, supported_python_versions) 

383 

384 results: list[VersionComparisonResult] = [] 

385 for python_version in python_versions: 

386 pylock_file = Path(build_pylock_compiled_filename(python_version)) 

387 pylock_ref_file = Path(build_pylock_reference_filename(python_version)) 

388 

389 try: 

390 pylock_file.unlink(missing_ok=True) 

391 compile_dependency_lockfile(python_version, pylock_file, list(DEFAULT_FREEZE_EXTRAS)) 

392 except (subprocess.CalledProcessError, click.ClickException) as exc: 

393 click.echo( 

394 f'Error: compilation failed for Python {python_version}: {exc}', 

395 err=True, 

396 ) 

397 continue 

398 

399 if gitlab_ref and api_config: 399 ↛ 400line 399 didn't jump to line 400 because the condition on line 399 was never true

400 dest = _download_pylock_reference( 

401 api_config, 

402 compile_python_selector(python_version), 

403 pylock_ref_file.name, 

404 package_version, 

405 Path.cwd(), 

406 quiet_if_missing=True, 

407 ) 

408 if dest is None: 

409 click.echo( 

410 f'Warning: reference file for Python {python_version} not found in GitLab registry, skipping.', 

411 err=True, 

412 ) 

413 continue 

414 else: 

415 if not pylock_ref_file.exists(): 

416 click.echo( 

417 f'Warning: no local reference file {pylock_ref_file} for Python {python_version}, skipping.', 

418 err=True, 

419 ) 

420 continue 

421 

422 try: 

423 latest_packages = load_pylock_packages(pylock_file) 

424 except ( 

425 FileNotFoundError, 

426 tomllib.TOMLDecodeError, 

427 ) as exc: # pragma: no cover — defensive: filesystem/parse error in CI-only flow 

428 click.echo( 

429 f'Error: unable to parse latest lockfile for Python {python_version}: {exc}', 

430 err=True, 

431 ) 

432 continue 

433 

434 try: 

435 reference_packages = load_pylock_packages(pylock_ref_file) 

436 except ( 

437 FileNotFoundError, 

438 tomllib.TOMLDecodeError, 

439 ) as exc: # pragma: no cover — defensive: filesystem/parse error in CI-only flow 

440 click.echo( 

441 f'Error: unable to parse reference lockfile for Python {python_version}: {exc}', 

442 err=True, 

443 ) 

444 continue 

445 

446 changes = compare_packages(latest_packages, reference_packages) 

447 results.append(VersionComparisonResult(python_version=python_version, changes=changes)) 

448 

449 if output_file is not None: 

450 output_file.parent.mkdir(parents=True, exist_ok=True) 

451 

452 effective_format = resolve_output_format(output_format, output_file) 

453 

454 if effective_format == 'json': 

455 content = render_comparison_json(results) 

456 else: 

457 content = render_comparison_markdown(results) 

458 

459 try: 

460 output_file.write_text(content, encoding='utf-8') 

461 except OSError as exc: # pragma: no cover — defensive: OS-level write failure 

462 raise click.ClickException(f'Unable to write output file {output_file}: {exc}') from exc 

463 

464 if not quiet: 464 ↛ 467line 464 didn't jump to line 467 because the condition on line 464 was always true

465 CONSOLE.print(f'Comparison results written to {output_file}') 

466 

467 if not quiet: 467 ↛ exitline 467 didn't return from function 'compare' because the condition on line 467 was always true

468 render_comparison_rich(results, CONSOLE)