diff --git a/framework/dev/update_version.py b/framework/dev/update_version.py index 6efb4f3c8e5d..c3a8734921c8 100644 --- a/framework/dev/update_version.py +++ b/framework/dev/update_version.py @@ -1,38 +1,43 @@ """Utility used to bump the version of the package.""" import argparse +import datetime import re import sys from pathlib import Path -REPLACE_CURR_VERSION = {} - REPLACE_NEXT_VERSION = { "framework/pyproject.toml": ['version = "{version}"'], + "framework/uv.lock": ['name = "flwr"\nversion = "{version}"'], "framework/docs/source/conf.py": [ 'release = "{version}"', ".. |stable_flwr_version| replace:: {version}", ], - "examples/docs/source/conf.py": ['release = "{version}"'], - "baselines/docs/source/conf.py": ['release = "{version}"'], "framework/docker/complete/compose.yml": ["FLWR_VERSION:-{version}"], "framework/docker/distributed/client/compose.yml": ["FLWR_VERSION:-{version}"], "framework/docker/distributed/server/compose.yml": ["FLWR_VERSION:-{version}"], - "framework/py/flwr/cli/new/templates/app/pyproject.*.toml.tpl": [ - "flwr[simulation]>={version}", - ], } -EXAMPLES = { - "examples/**/pyproject.toml": [ - "flwr[simulation]>={version}", - "flwr[simulation]=={version}", - "flwr>={version}", - "flwr=={version}", - ], +REPLACE_CURRENT_VERSION = { + "examples/docs/source/conf.py": ['release = "{version}"'], + "baselines/docs/source/conf.py": ['release = "{version}"'], } ROOT_DIR = Path(__file__).parents[2] +VERSION_PATTERN = r"\d+\.\d+\.\d+" + + +def _read_current_version(): + """Read the current Flower version from framework/pyproject.toml.""" + pyproject = ROOT_DIR / "framework/pyproject.toml" + match = re.search( + rf'^version = "({VERSION_PATTERN})"$', + pyproject.read_text(), + flags=re.MULTILINE, + ) + if not match: + raise ValueError("Version not found in framework/pyproject.toml") + return match.group(1) def _get_next_version(curr_version, increment): @@ -54,55 +59,175 @@ def _get_next_version(curr_version, increment): return f"{major}.{minor}.{patch_version}" -def _update_versions(file_patterns, replace_strings, new_version, check): +def _bump_patch_version(version): + """Increment the patch part of a version string.""" + major, minor, patch_version = map(int, version.split(".")) + return f"{major}.{minor}.{patch_version + 1}" + + +def _write_if_changed(file_path, original_content, content, check): + """Write a file if changed, or report it in check mode.""" + if content == original_content: + return False + + if check: + print(f"{file_path} would be updated") + else: + file_path.write_text(content) + print(f"Updated {file_path}") + return True + + +def _update_versions(file_pattern, replace_strings, new_version, check): """Update the version strings in the specified files.""" - wrong = False - for pattern in file_patterns: - files = list(ROOT_DIR.glob(pattern)) - for file_path in files: - if not file_path.is_file(): - continue - content = file_path.read_text() - original_content = content - for s in replace_strings: - # Construct regex pattern to match any version number in the string - escaped_s = re.escape(s).replace(r"\{version\}", r"(\d+\.\d+\.\d+)") - regex_pattern = re.compile(escaped_s) - content = regex_pattern.sub(s.format(version=new_version), content) - if content != original_content: - wrong = True - if check: - print(f"{file_path} would be updated") - else: - file_path.write_text(content) - print(f"Updated {file_path}") - - return wrong + changed = False + for file_path in sorted(ROOT_DIR.glob(file_pattern)): + if not file_path.is_file(): + continue + content = file_path.read_text() + original_content = content + for s in replace_strings: + pattern = re.escape(s).replace(r"\{version\}", f"({VERSION_PATTERN})") + content = re.sub(pattern, s.format(version=new_version), content) -if __name__ == "__main__": - # Search for the latest stable release version in the CHANGELOG - changelog_path = ROOT_DIR / "framework/docs/source/ref-changelog.md" - with changelog_path.open("r") as f: - for line in f: - if match := re.match(r"^## v(\d+\.\d+\.\d+).+", line): - break + changed |= _write_if_changed(file_path, original_content, content, check) + + return changed + + +def _update_example_versions(current_version, check): + """Update app target versions and bump app patch versions.""" + changed = False + target_pattern = re.compile( + rf'^(flwr-version-target\s*=\s*")({VERSION_PATTERN})(")$', + flags=re.MULTILINE, + ) + project_version_pattern = re.compile( + rf'(?m)^(version\s*=\s*")({VERSION_PATTERN})(")$' + ) + + for file_path in sorted((ROOT_DIR / "examples").glob("**/pyproject.toml")): + content = file_path.read_text() + match = target_pattern.search(content) + if not match or match.group(2) == current_version: + continue + + original_content = content + content = target_pattern.sub( + rf"\g<1>{current_version}\g<3>", + content, + count=1, + ) + content = project_version_pattern.sub( + lambda m: f"{m.group(1)}{_bump_patch_version(m.group(2))}{m.group(3)}", + content, + count=1, + ) + changed |= _write_if_changed(file_path, original_content, content, check) + + return changed + + +def _docker_tag_lines(image_dir, version): + """Return the stable Docker tag lines for the README.""" + if image_dir.name == "base": + return [ + f"- `{version}-py3.13-alpine3.22`", + f"- `{version}-py3.13-ubuntu24.04`", + f"- `{version}-py3.12-ubuntu24.04`", + f"- `{version}-py3.11-ubuntu24.04`", + ] + + if image_dir.name == "superlink": + return [ + f"- `{version}`, `{version}-py3.13-alpine3.22`", + f"- `{version}-py3.13-ubuntu24.04`, `latest`", + ] + + if image_dir.name == "supernode": + return [ + f"- `{version}`, `{version}-py3.13-alpine3.22`", + f"- `{version}-py3.13-ubuntu24.04`, `latest`", + f"- `{version}-py3.12-ubuntu24.04`", + f"- `{version}-py3.11-ubuntu24.04`", + ] + + return [ + f"- `{version}`, `{version}-py3.13-ubuntu24.04`, `latest`", + f"- `{version}-py3.12-ubuntu24.04`", + f"- `{version}-py3.11-ubuntu24.04`", + ] + + +def _update_docker_readmes(current_version, next_version, check): + """Update Docker README supported tags.""" + changed = False + today = datetime.date.today().strftime("%Y%m%d") + + for image_name in ("base", "superexec", "superlink", "supernode"): + file_path = ROOT_DIR / "framework/docker" / image_name / "README.md" + content = file_path.read_text() + original_content = content + image_dir = file_path.parent + + content = re.sub( + rf"(`nightly`, `\.dev` e\.g\. `)" + rf"{VERSION_PATTERN}\.dev\d{{8}}(`)", + rf"\g<1>{next_version}.dev{today}\g<2>", + content, + ) + content = re.sub( + rf"(`({VERSION_PATTERN})-py3\.13-ubuntu24\.04`), `latest`", + lambda m: m.group(0) if m.group(2) == current_version else m.group(1), + content, + ) + + if image_dir.name == "superexec": + content = re.sub( + rf"^ - points to `{VERSION_PATTERN}` and " + rf"`{VERSION_PATTERN}-py3\.13-ubuntu24\.04`$", + f" - points to `{current_version}` and " + f"`{current_version}-py3.13-ubuntu24.04`", + content, + count=1, + flags=re.MULTILINE, + ) + elif image_dir.name != "base": + content = re.sub( + rf"^ - points to `{VERSION_PATTERN}-py3\.13-ubuntu24\.04`$", + f" - points to `{current_version}-py3.13-ubuntu24.04`", + content, + count=1, + flags=re.MULTILINE, + ) + + tag_lines = _docker_tag_lines(image_dir, current_version) + if not all(line in content for line in tag_lines): + content = re.sub( + rf"^- `{VERSION_PATTERN}.*$", + lambda m: "\n".join(tag_lines) + "\n" + m.group(0), + content, + count=1, + flags=re.MULTILINE, + ) + + changed |= _write_if_changed(file_path, original_content, content, check) + + return changed + +if __name__ == "__main__": parser = argparse.ArgumentParser( description="Utility used to bump the version of the package." ) - parser.add_argument( - "--old_version", - help="Current (non-updated) version of the package, soon to be the old version.", - default=match.group(1) if match else None, - ) parser.add_argument( "--check", action="store_true", help="Fails if any file would be modified." ) parser.add_argument( "--no_examples", action="store_true", - help="Also modify flwr version in examples.", + help="Skip example app version and target updates.", ) group = parser.add_mutually_exclusive_group() @@ -114,9 +239,6 @@ def _update_versions(file_patterns, replace_strings, new_version, check): ) args = parser.parse_args() - if not args.old_version: - raise ValueError("Version not found in conf.py, please provide current version") - # Determine the type of version increment if args.major: increment = "major" @@ -125,25 +247,22 @@ def _update_versions(file_patterns, replace_strings, new_version, check): else: increment = "minor" - curr_version = _get_next_version(args.old_version, increment) - next_version = _get_next_version(curr_version, "minor") + curr_version = _read_current_version() + next_version = _get_next_version(curr_version, increment) - wrong = False + changed = False # Update files with next version for file_pattern, strings in REPLACE_NEXT_VERSION.items(): - if not _update_versions([file_pattern], strings, next_version, args.check): - wrong = True + changed |= _update_versions(file_pattern, strings, next_version, args.check) - # Update files with current version - for file_pattern, strings in REPLACE_CURR_VERSION.items(): - if not _update_versions([file_pattern], strings, curr_version, args.check): - wrong = True + for file_pattern, strings in REPLACE_CURRENT_VERSION.items(): + changed |= _update_versions(file_pattern, strings, curr_version, args.check) if not args.no_examples: - for file_pattern, strings in EXAMPLES.items(): - if not _update_versions([file_pattern], strings, curr_version, args.check): - wrong = True + changed |= _update_example_versions(curr_version, args.check) + + changed |= _update_docker_readmes(curr_version, next_version, args.check) - if wrong and args.check: - sys.exit("Some version haven't been updated.") + if changed and args.check: + sys.exit(1)