Skip to content
Open
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
253 changes: 186 additions & 67 deletions framework/dev/update_version.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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, 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`, `<version>\.dev<YYYYMMDD>` e\.g\. `)"
rf"{VERSION_PATTERN}\.dev\d{{8}}(`)",
rf"\g<1>{current_version}.dev{today}\g<2>",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the bumped version for nightly Docker examples

When the post-release script runs, it rewrites pyproject.toml to next_version before the next nightly job; the nightly workflow then derives VERSION=$(uv version --short) after publish-nightly.sh appends .devYYYYMMDD to that rewritten version (framework/dev/publish-nightly.sh:36-37, .github/workflows/framework-release-nightly.yml:62-64). This README update still formats the nightly example with current_version, so after a 1.32.0 release the PR would bump the package to 1.33.0 but document 1.32.0.dev<date>, leaving the Docker README nightly tag examples one release cycle behind as soon as the nightly build publishes.

Useful? React with 👍 / 👎.

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.",
)
Comment thread
panh99 marked this conversation as resolved.

group = parser.add_mutually_exclusive_group()
Expand All @@ -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"
Expand All @@ -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, args.check)

if wrong and args.check:
sys.exit("Some version haven't been updated.")
if changed and args.check:
sys.exit(1)
Comment thread
panh99 marked this conversation as resolved.
Loading