Coverage for src/mafw/devtools/dependencies/freeze.py: 83%

170 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-26 09:13 +0000

1# Copyright 2026 European Union 

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

3# SPDX-License-Identifier: EUPL-1.2 

4""" 

5Dependency freezing and unfreezing utilities for MAFw. 

6 

7This module provides functions for adding and removing computed upper-bound 

8constraints in ``pyproject.toml`` dependency declarations. It is used by 

9the release workflow to pin dependencies during release and unpin them 

10afterwards. 

11""" 

12 

13from __future__ import annotations 

14 

15from collections.abc import Callable 

16from typing import Any, Final 

17 

18import tomlkit 

19 

20from mafw.devtools import ensure_devtools_available 

21 

22ensure_devtools_available() 

23 

24from packaging.requirements import Requirement # noqa: E402 

25from packaging.specifiers import Specifier, SpecifierSet # noqa: E402 

26from packaging.version import Version # noqa: E402 

27 

28from mafw.devtools import DevtoolsError # noqa: E402 

29from mafw.devtools.dependencies.compile import ( # noqa: E402 

30 PYPROJECT_FILE, 

31 collect_compiled_dependency_versions, 

32 load_pyproject_doc, 

33 project_python_versions_from_doc, 

34) 

35from mafw.devtools.documentation.requirements import REQUIREMENTS_GROUPS # noqa: E402 

36from mafw.tools.shell_tools import CONSOLE # noqa: E402 

37from mafw.tools.shell_tools import run as cmd # noqa: E402 

38 

39_FROZEN_OPERATORS: Final[set[str]] = {'<', '<=', '~=', '==', '==='} 

40"""Operators that already constrain the maximum compatible version and should not be auto-frozen.""" 

41 

42 

43def compute_upper_bound(lower_bound: str) -> str: 

44 """ 

45 Compute an upper bound for a dependency based on PEP 440 compatible-release philosophy. 

46 

47 The rule implemented here is purposely conservative and mirrors the intent of compatible 

48 release clauses while remaining explicit: 

49 

50 - For major-versioned releases (``X.*`` with ``X > 0``), freeze to ``<(X + 1)``. 

51 - For ``0.*`` releases, freeze to ``<0.(minor + 1)`` (rolling compatibility during pre-1.0). 

52 

53 :param lower_bound: Version string used as the starting point for the freeze rule. 

54 :type lower_bound: str 

55 :return: Upper-bound version string without operator. 

56 :rtype: str 

57 :raises DevtoolsError: If the version cannot be parsed. 

58 """ 

59 try: 

60 version = Version(lower_bound) 

61 except Exception as exc: # pragma: no cover 

62 raise DevtoolsError(f'Unable to parse version "{lower_bound}" while computing dependency upper bound.') from exc 

63 

64 if version.major > 0: 

65 return str(version.major + 1) 

66 return f'0.{version.minor + 1}' 

67 

68 

69def format_requirement(requirement: Requirement) -> str: 

70 """ 

71 Serialize a packaging requirement object back to a PEP 508 compatible string. 

72 

73 :param requirement: Parsed requirement instance. 

74 :type requirement: Requirement 

75 :return: PEP 508 requirement string. 

76 :rtype: str 

77 """ 

78 name = str(requirement.name) 

79 extras = sorted(str(extra) for extra in getattr(requirement, 'extras', set()) or set()) 

80 if extras: 

81 name = f'{name}[{",".join(extras)}]' 

82 

83 if requirement.url: 83 ↛ 84line 83 didn't jump to line 84 because the condition on line 83 was never true

84 rendered = f'{name} @ {requirement.url}' 

85 else: 

86 spec = str(requirement.specifier).strip() 

87 rendered = f'{name}{spec}' if spec else name 

88 

89 if requirement.marker: 

90 rendered = f'{rendered} ; {requirement.marker}' 

91 return rendered 

92 

93 

94def iter_specifiers(requirement: Requirement) -> list[Specifier]: 

95 """ 

96 Return a concrete list of specifiers for the given requirement. 

97 

98 :param requirement: Parsed requirement instance. 

99 :type requirement: Requirement 

100 :return: List of specifier objects. 

101 :rtype: list[Specifier] 

102 """ 

103 return list(requirement.specifier) if requirement.specifier else [] 

104 

105 

106def has_frozen_upper_bound(requirement: Requirement) -> bool: 

107 """ 

108 Determine whether a requirement already contains an upper bound constraint. 

109 

110 :param requirement: Parsed requirement instance. 

111 :type requirement: Requirement 

112 :return: ``True`` if the requirement is already frozen. 

113 :rtype: bool 

114 """ 

115 return any(getattr(spec, 'operator', '') in _FROZEN_OPERATORS for spec in iter_specifiers(requirement)) 

116 

117 

118def highest_lower_bound(requirement: Requirement) -> str | None: 

119 """ 

120 Extract the highest lower-bound version from ``>=`` and ``>`` specifiers. 

121 

122 :param requirement: Parsed requirement instance. 

123 :type requirement: Requirement 

124 :return: Highest lower bound version string, or ``None`` if missing. 

125 :rtype: str | None 

126 :raises DevtoolsError: If version parsing fails. 

127 """ 

128 best: tuple[Version, str] | None = None 

129 for spec in iter_specifiers(requirement): 

130 if spec.operator not in {'>=', '>'}: 

131 continue 

132 try: 

133 parsed = Version(spec.version) 

134 except Exception as exc: # pragma: no cover 

135 raise DevtoolsError( 

136 f'Unable to parse lower bound "{spec.version}" in requirement "{requirement}".' 

137 ) from exc 

138 if best is None or parsed > best[0]: 138 ↛ 129line 138 didn't jump to line 129 because the condition on line 138 was always true

139 best = (parsed, spec.version) 

140 return best[1] if best else None 

141 

142 

143def freeze_requirement(requirement_text: str, *, resolved_version: str | None = None) -> tuple[str, list[str]]: 

144 """ 

145 Add a computed upper bound to a requirement string, if eligible. 

146 

147 The function is intentionally conservative: 

148 

149 - URL-based requirements are skipped (cannot be version constrained). 

150 - Requirements already containing ``<``, ``<=``, ``~=``, ``==`` or ``===`` are skipped. 

151 - Requirements without a lower bound emit a warning and are left unchanged. 

152 - When a resolved version is provided, the upper bound is computed from that 

153 version instead of the declared lower bound. 

154 

155 :param requirement_text: Raw PEP 508 requirement string. 

156 :type requirement_text: str 

157 :return: Updated requirement text plus warnings. 

158 :rtype: tuple[str, list[str]] 

159 :raises DevtoolsError: If parsing fails. 

160 """ 

161 warnings: list[str] = [] 

162 stripped = requirement_text.strip() 

163 if not stripped: 163 ↛ 164line 163 didn't jump to line 164 because the condition on line 163 was never true

164 return requirement_text, warnings 

165 

166 try: 

167 requirement = Requirement(stripped) 

168 except Exception as exc: 

169 raise DevtoolsError(f'Unable to parse dependency requirement "{requirement_text}".') from exc 

170 

171 if requirement.url: 

172 warnings.append(f'Skipping URL requirement (cannot freeze): {requirement_text}') 

173 return requirement_text, warnings 

174 

175 if has_frozen_upper_bound(requirement): 

176 return requirement_text, warnings 

177 

178 lower = highest_lower_bound(requirement) 

179 if lower is None: 

180 warnings.append(f'Dependency has no lower bound and will not be frozen: {requirement_text}') 

181 return requirement_text, warnings 

182 

183 upper = compute_upper_bound(resolved_version or lower) 

184 combined = SpecifierSet(f'{requirement.specifier},<{upper}' if str(requirement.specifier).strip() else f'<{upper}') 

185 requirement.specifier = combined 

186 return format_requirement(requirement), warnings 

187 

188 

189def unfreeze_requirement(requirement_text: str) -> tuple[str, list[str]]: 

190 """ 

191 Remove a computed upper bound from a requirement string, if it matches the computed rule. 

192 

193 Only the auto-generated upper bound ``<upper`` is removed; existing manual upper bounds are preserved. 

194 

195 :param requirement_text: Raw PEP 508 requirement string. 

196 :type requirement_text: str 

197 :return: Updated requirement text plus warnings. 

198 :rtype: tuple[str, list[str]] 

199 :raises DevtoolsError: If parsing fails. 

200 """ 

201 warnings: list[str] = [] 

202 stripped = requirement_text.strip() 

203 if not stripped: 203 ↛ 204line 203 didn't jump to line 204 because the condition on line 203 was never true

204 return requirement_text, warnings 

205 

206 try: 

207 requirement = Requirement(stripped) 

208 except Exception as exc: 

209 raise DevtoolsError(f'Unable to parse dependency requirement "{requirement_text}".') from exc 

210 

211 if requirement.url: 211 ↛ 212line 211 didn't jump to line 212 because the condition on line 211 was never true

212 return requirement_text, warnings 

213 

214 # Do not try to unfreeze already pinned/compatible requirements, or requirements that already 

215 # had an upper bound before freezing (we cannot safely distinguish manual bounds). 

216 specifiers = iter_specifiers(requirement) 

217 if any(spec.operator in {'~=', '==', '==='} for spec in specifiers): 

218 return requirement_text, warnings 

219 lower = highest_lower_bound(requirement) 

220 if lower is None: 220 ↛ 221line 220 didn't jump to line 221 because the condition on line 220 was never true

221 return requirement_text, warnings 

222 

223 if any(spec.operator == '<=' for spec in specifiers): 223 ↛ 224line 223 didn't jump to line 224 because the condition on line 223 was never true

224 return requirement_text, warnings 

225 

226 remaining: list[Specifier] = [] 

227 removed = False 

228 for spec in specifiers: 

229 if spec.operator == '<': 

230 removed = True 

231 continue 

232 remaining.append(spec) 

233 

234 if not removed: 234 ↛ 235line 234 didn't jump to line 235 because the condition on line 234 was never true

235 return requirement_text, warnings 

236 

237 requirement.specifier = SpecifierSet(','.join(str(spec) for spec in remaining)) 

238 return format_requirement(requirement), warnings 

239 

240 

241def update_dependency_list( 

242 dependencies: Any, 

243 *, 

244 transformer: Callable[[str], tuple[str, list[str]]], 

245 warnings: list[str], 

246 context: str, 

247) -> None: 

248 """ 

249 Update a TOML list of dependency strings in-place. 

250 

251 :param dependencies: TOML array that contains dependency strings. 

252 :type dependencies: Any 

253 :param transformer: Callable applied to each dependency string. 

254 :type transformer: Any 

255 :param warnings: List of warnings to append to. 

256 :type warnings: list[str] 

257 :param context: Human-readable location for warnings. 

258 :type context: str 

259 """ 

260 for idx, item in enumerate(list(dependencies)): 

261 if not isinstance(item, str): 261 ↛ 262line 261 didn't jump to line 262 because the condition on line 261 was never true

262 warnings.append(f'Skipping non-string dependency entry in {context}: {item!r}') 

263 continue 

264 updated, item_warnings = transformer(item) 

265 warnings.extend(item_warnings) 

266 dependencies[idx] = updated 

267 

268 

269def freeze_pyproject_toml( 

270 toml_text: str, 

271 *, 

272 resolved_versions: dict[str, Version] | None = None, 

273 doc: tomlkit.TOMLDocument | None = None, 

274) -> tuple[str, list[str]]: 

275 """ 

276 Freeze dependencies in a ``pyproject.toml`` payload by adding upper bounds. 

277 

278 The TOML structure is preserved via tomlkit; only dependency strings may be normalized. 

279 

280 When ``resolved_versions`` is provided, the function uses those compiled 

281 versions as the basis for upper-bound computation. Otherwise the declared 

282 lower bounds are used as a fallback. 

283 

284 :param toml_text: Raw TOML file content. 

285 :type toml_text: str 

286 :param resolved_versions: Optional mapping of dependency names to resolved versions. 

287 :type resolved_versions: dict[str, Version] | None 

288 :param doc: Optional pre-parsed TOML document to reuse when available. 

289 :type doc: tomlkit.TOMLDocument | None 

290 :return: Updated TOML plus warnings. 

291 :rtype: tuple[str, list[str]] 

292 :raises DevtoolsError: If TOML parsing fails. 

293 """ 

294 warnings: list[str] = [] 

295 if doc is None: 

296 doc = load_pyproject_doc(toml_text) 

297 

298 project = doc.get('project') 

299 if project is None: 299 ↛ 300line 299 didn't jump to line 300 because the condition on line 299 was never true

300 raise DevtoolsError(f'Missing [project] table in {PYPROJECT_FILE}.') 

301 

302 def freeze_for_requirement(requirement_text: str) -> tuple[str, list[str]]: 

303 try: 

304 requirement = Requirement(requirement_text.strip()) 

305 except Exception as exc: 

306 raise DevtoolsError(f'Unable to parse dependency requirement "{requirement_text}".') from exc 

307 

308 resolved_version = None 

309 if resolved_versions is not None: 309 ↛ 313line 309 didn't jump to line 313 because the condition on line 309 was always true

310 resolved = resolved_versions.get(requirement.name.lower()) 

311 if resolved is not None: 

312 resolved_version = str(resolved) 

313 return freeze_requirement(requirement_text, resolved_version=resolved_version) 

314 

315 dependencies = project.get('dependencies') 

316 if dependencies is not None: 316 ↛ 324line 316 didn't jump to line 324 because the condition on line 316 was always true

317 update_dependency_list( 

318 dependencies, 

319 transformer=freeze_for_requirement, 

320 warnings=warnings, 

321 context='project.dependencies', 

322 ) 

323 

324 optional = project.get('optional-dependencies') 

325 if optional is not None: 

326 for group, group_deps in optional.items(): 

327 update_dependency_list( 

328 group_deps, 

329 transformer=freeze_for_requirement, 

330 warnings=warnings, 

331 context=f'project.optional-dependencies.{group}', 

332 ) 

333 

334 return tomlkit.dumps(doc), warnings 

335 

336 

337def unfreeze_pyproject_toml( 

338 toml_text: str, 

339 *, 

340 baseline_toml_text: str | None = None, 

341 doc: tomlkit.TOMLDocument | None = None, 

342 baseline_doc: tomlkit.TOMLDocument | None = None, 

343) -> tuple[str, list[str]]: 

344 """ 

345 Unfreeze dependencies in a ``pyproject.toml`` payload by removing computed upper bounds. 

346 

347 Only upper bounds matching the computed rule are removed; existing manual constraints remain. 

348 

349 :param toml_text: Raw TOML file content. 

350 :type toml_text: str 

351 :param baseline_toml_text: Optional original TOML text captured before freezing. 

352 When provided, unfreezing is computed against the baseline to avoid 

353 altering dependencies that were already frozen before release. 

354 :type baseline_toml_text: str | None 

355 :param doc: Optional pre-parsed TOML document to reuse when available. 

356 :type doc: tomlkit.TOMLDocument | None 

357 :param baseline_doc: Optional pre-parsed baseline TOML document to reuse when available. 

358 :type baseline_doc: tomlkit.TOMLDocument | None 

359 :return: Updated TOML plus warnings. 

360 :rtype: tuple[str, list[str]] 

361 :raises DevtoolsError: If TOML parsing fails. 

362 """ 

363 warnings: list[str] = [] 

364 if doc is None: 

365 doc = load_pyproject_doc(toml_text) 

366 if baseline_toml_text is not None: 

367 if baseline_doc is None: 

368 baseline_doc = load_pyproject_doc(baseline_toml_text) 

369 

370 current_project = doc.get('project') 

371 baseline_project = baseline_doc.get('project') 

372 if current_project is None or baseline_project is None: 372 ↛ 373line 372 didn't jump to line 373 because the condition on line 372 was never true

373 raise DevtoolsError(f'Missing [project] table in {PYPROJECT_FILE}.') 

374 

375 current_project['dependencies'] = baseline_project.get('dependencies', current_project.get('dependencies')) 

376 current_optional = current_project.get('optional-dependencies') 

377 baseline_optional = baseline_project.get('optional-dependencies') 

378 if current_optional is not None and baseline_optional is not None: 

379 for group in current_optional: 

380 if group in baseline_optional: 380 ↛ 379line 380 didn't jump to line 379 because the condition on line 380 was always true

381 current_optional[group] = baseline_optional[group] 

382 return tomlkit.dumps(doc), warnings 

383 

384 project = doc.get('project') 

385 if project is None: 385 ↛ 386line 385 didn't jump to line 386 because the condition on line 385 was never true

386 raise DevtoolsError(f'Missing [project] table in {PYPROJECT_FILE}.') 

387 

388 dependencies = project.get('dependencies') 

389 if dependencies is not None: 389 ↛ 397line 389 didn't jump to line 397 because the condition on line 389 was always true

390 update_dependency_list( 

391 dependencies, 

392 transformer=unfreeze_requirement, 

393 warnings=warnings, 

394 context='project.dependencies', 

395 ) 

396 

397 optional = project.get('optional-dependencies') 

398 if optional is not None: 398 ↛ 399line 398 didn't jump to line 399 because the condition on line 398 was never true

399 for group, group_deps in optional.items(): 

400 update_dependency_list( 

401 group_deps, 

402 transformer=unfreeze_requirement, 

403 warnings=warnings, 

404 context=f'project.optional-dependencies.{group}', 

405 ) 

406 

407 return tomlkit.dumps(doc), warnings 

408 

409 

410def summarize_freeze_changes(before: str, after: str) -> str: 

411 """ 

412 Build a short summary for dry-run output. 

413 

414 :param before: Original TOML content. 

415 :type before: str 

416 :param after: Updated TOML content. 

417 :type after: str 

418 :return: Human-readable summary line. 

419 :rtype: str 

420 """ 

421 if before == after: 421 ↛ 423line 421 didn't jump to line 423 because the condition on line 421 was always true

422 return 'No dependency constraints would be updated.' 

423 before_lines = before.splitlines() 

424 after_lines = after.splitlines() 

425 return f'pyproject.toml would be updated ({len(before_lines)} -> {len(after_lines)} lines).' 

426 

427 

428def freeze_dependencies(*, dry_run: bool) -> str: # pragma: no cover 

429 """ 

430 Freeze dependencies by adding computed upper bounds in ``pyproject.toml``. 

431 

432 :param dry_run: Whether command execution is disabled. 

433 :type dry_run: bool 

434 :return: Original ``pyproject.toml`` content captured before freeze. 

435 :rtype: str 

436 """ 

437 CONSOLE.print('Freezing dependencies (adding upper bounds) ...') 

438 before = PYPROJECT_FILE.read_text(encoding='utf-8') 

439 doc = load_pyproject_doc(before) 

440 supported_python_versions = project_python_versions_from_doc(doc) 

441 resolved_versions = collect_compiled_dependency_versions(supported_python_versions) 

442 after, warnings = freeze_pyproject_toml(before, resolved_versions=resolved_versions, doc=doc) 

443 for warning in warnings: 

444 CONSOLE.print(f'WARNING: {warning}') 

445 

446 if dry_run: 

447 CONSOLE.print(summarize_freeze_changes(before, after)) 

448 return before 

449 if before != after: 

450 PYPROJECT_FILE.write_text(after, encoding='utf-8') 

451 return before 

452 

453 

454def unfreeze_dependencies(original_pyproject_toml: str, *, dry_run: bool) -> None: # pragma: no cover 

455 """ 

456 Unfreeze dependencies by removing computed upper bounds in ``pyproject.toml``. 

457 

458 :param dry_run: Whether command execution is disabled. 

459 :type dry_run: bool 

460 """ 

461 CONSOLE.print('Unfreezing dependencies (removing computed upper bounds) ...') 

462 before = PYPROJECT_FILE.read_text(encoding='utf-8') 

463 doc = load_pyproject_doc(before) 

464 baseline_doc = load_pyproject_doc(original_pyproject_toml) 

465 after, warnings = unfreeze_pyproject_toml( 

466 before, 

467 baseline_toml_text=original_pyproject_toml, 

468 doc=doc, 

469 baseline_doc=baseline_doc, 

470 ) 

471 for warning in warnings: 

472 CONSOLE.print(f'WARNING: {warning}') 

473 

474 if dry_run: 

475 CONSOLE.print(summarize_freeze_changes(before, after)) 

476 return 

477 if before != after: 

478 PYPROJECT_FILE.write_text(after, encoding='utf-8') 

479 

480 

481def update_requirements_and_readme(*, dry_run: bool) -> None: # pragma: no cover 

482 """ 

483 Update the requirements RST files and README.rst. 

484 

485 :param dry_run: Whether command execution is disabled. 

486 :type dry_run: bool 

487 """ 

488 CONSOLE.print('Updating requirements and README.rst...') 

489 cmd(['hatch', 'run', 'dev:multidoc', 'requirements', '--update-readme', *REQUIREMENTS_GROUPS], dry_run=dry_run)