blob: 5230f0d0151244e2a8d4530d09af87c10210cad5 [file]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import argparse
import os
import re
import tomllib
from pathlib import Path
SCRIPT_PATH = Path(__file__).resolve()
PROJECT_DIR = SCRIPT_PATH.parents[3]
PYTHON_BINDING_DIR = PROJECT_DIR / "bindings" / "python"
DEFAULT_CARGO_MANIFEST = PYTHON_BINDING_DIR / "Cargo.toml"
DEFAULT_PYPROJECT = PYTHON_BINDING_DIR / "pyproject.toml"
RC_TAG_PATTERN = re.compile(
r"^v(?P<release>[0-9]+\.[0-9]+\.[0-9]+)-rc\.(?P<candidate>[1-9][0-9]*)$"
)
STABLE_VERSION_PATTERN = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$")
PROJECT_SECTION_PATTERN = re.compile(
r"(?ms)^\[project\][^\S\r\n]*(?:\r?\n)(?P<body>.*?)(?=^\[|\Z)"
)
DYNAMIC_VERSION_PATTERN = re.compile(
r"(?m)^(?P<indent>[ \t]*)dynamic[ \t]*=[ \t]*"
r"\[[^\r\n]*\][ \t]*(?:#[^\r\n]*)?(?P<newline>\r?\n|\Z)"
)
class PreparationError(ValueError):
pass
def load_stable_version(cargo_manifest: Path) -> str:
with cargo_manifest.open("rb") as fp:
manifest = tomllib.load(fp)
package = manifest.get("package")
version = package.get("version") if isinstance(package, dict) else None
if not isinstance(version, str) or not STABLE_VERSION_PATTERN.fullmatch(version):
raise PreparationError(
f"{cargo_manifest} must define a stable package version in X.Y.Z form"
)
return version
def release_candidate_version(stable_version: str, tag: str) -> str:
match = RC_TAG_PATTERN.fullmatch(tag)
if match is None:
raise PreparationError(
f"tag {tag!r} must use the vX.Y.Z-rc.N release candidate format"
)
return f"{stable_version}rc{match.group('candidate')}"
def render_pyproject(content: str, version: str) -> str:
metadata = tomllib.loads(content)
project = metadata.get("project")
if not isinstance(project, dict):
raise PreparationError("pyproject.toml must define a [project] table")
dynamic = project.get("dynamic")
current_version = project.get("version")
if current_version is not None:
if current_version != version or dynamic is not None:
raise PreparationError(
"pyproject.toml already defines unexpected project version metadata"
)
return content
if dynamic != ["version"]:
raise PreparationError(
"pyproject.toml must declare only version as dynamic project metadata"
)
project_section = PROJECT_SECTION_PATTERN.search(content)
if project_section is None:
raise PreparationError("could not locate the [project] table")
body = project_section.group("body")
assignments = list(DYNAMIC_VERSION_PATTERN.finditer(body))
if len(assignments) != 1:
raise PreparationError(
"the [project] dynamic version declaration must be on one line"
)
assignment = assignments[0]
replacement = (
f'{assignment.group("indent")}version = "{version}"'
f"{assignment.group('newline')}"
)
rendered_body = body[: assignment.start()] + replacement + body[assignment.end() :]
return (
content[: project_section.start("body")]
+ rendered_body
+ content[project_section.end("body") :]
)
def prepare(
tag: str,
cargo_manifest: Path = DEFAULT_CARGO_MANIFEST,
pyproject: Path = DEFAULT_PYPROJECT,
) -> str:
stable_version = load_stable_version(cargo_manifest)
version = release_candidate_version(stable_version, tag)
content = pyproject.read_text(encoding="utf-8")
rendered = render_pyproject(content, version)
if rendered != content:
pyproject.write_text(rendered, encoding="utf-8")
return version
def main() -> int:
parser = argparse.ArgumentParser(
description="Prepare PEP 440 version metadata for a Python release candidate."
)
parser.add_argument(
"--tag",
default=os.environ.get("GITHUB_REF_NAME"),
help="Release candidate tag. Defaults to GITHUB_REF_NAME.",
)
parser.add_argument(
"--cargo-manifest",
type=Path,
default=DEFAULT_CARGO_MANIFEST,
help="Path to the Python binding Cargo.toml.",
)
parser.add_argument(
"--pyproject",
type=Path,
default=DEFAULT_PYPROJECT,
help="Path to the Python binding pyproject.toml.",
)
args = parser.parse_args()
if not args.tag:
parser.error("--tag is required when GITHUB_REF_NAME is not set")
try:
version = prepare(args.tag, args.cargo_manifest, args.pyproject)
except (OSError, PreparationError, tomllib.TOMLDecodeError) as error:
parser.error(str(error))
print(f"Prepared Python release candidate version {version}")
return 0
if __name__ == "__main__":
raise SystemExit(main())