Back to home page

sPhenix code displayed by LXR

 
 

    


File indexing completed on 2025-08-03 08:09:26

0001 #!/usr/bin/env python3
0002 import os
0003 import asyncio
0004 from typing import List, Optional, Tuple
0005 from pathlib import Path
0006 import sys
0007 import http
0008 import json
0009 import yaml
0010 import datetime
0011 import typer
0012 import base64
0013 
0014 import aiohttp
0015 from gidgethub.aiohttp import GitHubAPI
0016 from gidgethub import InvalidField
0017 from semantic_release.enums import LevelBump
0018 from semantic_release.version import Version
0019 from semantic_release.commit_parser.angular import (
0020     AngularCommitParser,
0021     AngularParserOptions,
0022 )
0023 from semantic_release.commit_parser.token import ParseError, ParseResult
0024 import gidgethub
0025 import sh
0026 from dotenv import load_dotenv
0027 import functools
0028 
0029 load_dotenv()
0030 
0031 git = sh.git
0032 
0033 RETRY_COUNT = 10
0034 RETRY_INTERVAL = 0.5  # seconds
0035 
0036 
0037 def get_repo():
0038     repo = os.environ.get("GITHUB_REPOSITORY", None)
0039     if repo is not None:
0040         return repo
0041 
0042     origin = git.remote("get-url", "origin")
0043     _, loc = origin.split(":", 1)
0044     repo, _ = loc.split(".", 1)
0045     return repo
0046 
0047 
0048 class Commit:
0049     sha: str
0050     message: str
0051     author: str
0052 
0053     def __init__(self, sha: str, message: str, author: str):
0054         self.sha = sha
0055         self.message = self._normalize(message)
0056         self.author = author
0057 
0058     @staticmethod
0059     def _normalize(message):
0060         message = message.replace("\r", "\n")
0061         return message
0062 
0063     def __str__(self):
0064         message = self.message.split("\n")[0]
0065         return f"Commit(sha='{self.sha[:8]}', message='{message}')"
0066 
0067     # needed for semantic_release duck typing
0068     @property
0069     def hexsha(self):
0070         return self.sha
0071 
0072 
0073 _default_parser = AngularCommitParser(AngularParserOptions())
0074 
0075 
0076 def evaluate_version_bump(
0077     commits: List[Commit], commit_parser=_default_parser
0078 ) -> Optional[str]:
0079     """
0080     Adapted from: https://github.com/relekang/python-semantic-release/blob/master/semantic_release/history/logs.py#L22
0081     """
0082     bump = None
0083 
0084     changes = []
0085     commit_count = 0
0086 
0087     for commit in commits:
0088         commit_count += 1
0089 
0090         message: ParseResult = commit_parser.parse(commit)
0091         if isinstance(message, ParseError):
0092             print("Unknown commit message style!")
0093         else:
0094             changes.append(message.bump)
0095 
0096     if changes:
0097         level = max(changes)
0098         if level in LevelBump:
0099             bump = level
0100         else:
0101             print(f"Unknown bump level {level}")
0102 
0103     return bump
0104 
0105 
0106 def generate_changelog(commits, commit_parser=_default_parser) -> dict:
0107     """
0108     Modified from: https://github.com/relekang/python-semantic-release/blob/48972fb761ed9b0fb376fa3ad7028d65ff407ee6/semantic_release/history/logs.py#L78
0109     """
0110     changes: dict = {"breaking": []}
0111 
0112     for commit in commits:
0113         message: ParseResult = commit_parser.parse(commit)
0114 
0115         if isinstance(message, ParseError):
0116             print("Unknown commit message style!")
0117             continue
0118 
0119         if message.type not in changes:
0120             changes[message.type] = list()
0121 
0122         capital_message = (
0123             message.descriptions[0][0].upper() + message.descriptions[0][1:]
0124         )
0125         changes[message.type].append((commit.sha, capital_message, commit.author))
0126 
0127     return changes
0128 
0129 
0130 def markdown_changelog(version: str, changelog: dict, header: bool = False) -> str:
0131     output = f"## v{version}\n" if header else ""
0132 
0133     for section, items in changelog.items():
0134         if len(items) == 0:
0135             continue
0136         output += "\n### {0}\n".format(section.capitalize())
0137 
0138         for sha, msg, author in items:
0139             output += "* {} ({}) (@{})\n".format(msg, sha, author)
0140 
0141     return output
0142 
0143 
0144 def update_zenodo(zenodo_file: Path, repo: str, next_version):
0145     data = json.loads(zenodo_file.read_text())
0146     data["title"] = f"{repo}: v{next_version}"
0147     data["version"] = f"v{next_version}"
0148     zenodo_file.write_text(json.dumps(data, indent=2))
0149 
0150 
0151 def update_citation(citation_file: Path, next_version):
0152     with citation_file.open() as fh:
0153         data = yaml.safe_load(fh)
0154     data["version"] = f"v{next_version}"
0155     data["date-released"] = datetime.date.today().strftime("%Y-%m-%d")
0156     with citation_file.open("w") as fh:
0157         yaml.dump(data, fh, indent=2)
0158 
0159 
0160 def make_sync(fn):
0161     @functools.wraps(fn)
0162     def wrapped(*args, **kwargs):
0163         loop = asyncio.get_event_loop()
0164         loop.run_until_complete(fn(*args, **kwargs))
0165 
0166     return wrapped
0167 
0168 
0169 app = typer.Typer()
0170 
0171 
0172 async def get_parsed_commit_range(
0173     start: str, end: str, repo: str, gh: GitHubAPI, edit: bool = False
0174 ) -> Tuple[List[Commit], List[Commit]]:
0175     commits_iter = gh.getiter(f"/repos/{repo}/commits?sha={start}")
0176 
0177     commits = []
0178     unparsed_commits = []
0179 
0180     try:
0181         async for item in commits_iter:
0182             commit_hash = item["sha"]
0183             commit_message = item["commit"]["message"]
0184             if commit_hash == end:
0185                 break
0186 
0187             commit = Commit(commit_hash, commit_message, item["author"]["login"])
0188 
0189             invalid_message = False
0190             message: ParseResult = _default_parser.parse(commit)
0191 
0192             if isinstance(message, ParseError):
0193                 print("Unknown commit message style!")
0194                 if not commit_message.startswith("Merge"):
0195                     invalid_message = True
0196 
0197             if (
0198                 (invalid_message or edit)
0199                 and sys.stdout.isatty()
0200                 and False
0201                 and typer.confirm(f"Edit effective message '{commit_message}'?")
0202             ):
0203                 commit_message = typer.edit(commit_message)
0204                 _default_parser(commit_message)
0205 
0206             commits.append(commit)
0207 
0208             if invalid_message:
0209                 unparsed_commits.append(commit)
0210 
0211             print("-", commit)
0212             if len(commits) > 200:
0213                 raise RuntimeError(f"{len(commits)} are a lot. Aborting!")
0214         return commits, unparsed_commits
0215     except gidgethub.BadRequest:
0216         print(
0217             "BadRequest for commit retrieval. That is most likely because you forgot to push the merge commit."
0218         )
0219         return
0220 
0221 
0222 @app.command()
0223 @make_sync
0224 async def make_release(
0225     token: str = typer.Argument(..., envvar="GH_TOKEN"),
0226     force_next_version: Optional[str] = typer.Option(None, "--next-version"),
0227     draft: bool = True,
0228     dry_run: bool = False,
0229     edit: bool = False,
0230 ):
0231     async with aiohttp.ClientSession(loop=asyncio.get_event_loop()) as session:
0232         gh = GitHubAPI(session, __name__, oauth_token=token)
0233 
0234         version_file = Path("version_number")
0235         current_version = version_file.read_text()
0236 
0237         tag_hash = str(git("rev-list", "-n", "1", f"v{current_version}").strip())
0238         print("current_version:", current_version, "[" + tag_hash[:8] + "]")
0239 
0240         sha = git("rev-parse", "HEAD").strip()
0241         print("sha:", sha)
0242 
0243         repo = get_repo()
0244         print("repo:", repo)
0245 
0246         commits, _ = await get_parsed_commit_range(
0247             start=sha, end=tag_hash, repo=repo, gh=gh, edit=edit
0248         )
0249 
0250         bump = evaluate_version_bump(commits)
0251         print("bump:", bump)
0252         if bump is None:
0253             print("-> nothing to do")
0254             return
0255 
0256         current_version_obj = Version(*(map(int, current_version.split("."))))
0257         next_version_obj = current_version_obj.bump(bump)
0258         next_version = f"{next_version_obj.major}.{next_version_obj.minor}.{next_version_obj.patch}"
0259         if force_next_version is not None:
0260             next_version = force_next_version
0261         print("next version:", next_version)
0262         next_tag = f"v{next_version}"
0263 
0264         changes = generate_changelog(commits)
0265         md = markdown_changelog(next_version, changes, header=False)
0266 
0267         print(md)
0268 
0269         if not dry_run:
0270             execute_bump(next_version)
0271 
0272             git.commit(m=f"Bump to version {next_tag}", no_verify=True)
0273 
0274             target_hash = str(git("rev-parse", "HEAD")).strip()
0275             print("target_hash:", target_hash)
0276 
0277             git.push()
0278 
0279             commit_ok = False
0280             print("Waiting for commit", target_hash[:8], "to be received")
0281             for _ in range(RETRY_COUNT):
0282                 try:
0283                     url = f"/repos/{repo}/commits/{target_hash}"
0284                     await gh.getitem(url)
0285                     commit_ok = True
0286                     break
0287                 except InvalidField:
0288                     print("Commit", target_hash[:8], "not received yet")
0289                     pass  # this is what we want
0290                 await asyncio.sleep(RETRY_INTERVAL)
0291 
0292             if not commit_ok:
0293                 print("Commit", target_hash[:8], "was not created on remote")
0294                 sys.exit(1)
0295 
0296             print("Commit", target_hash[:8], "received")
0297 
0298             await gh.post(
0299                 f"/repos/{repo}/releases",
0300                 data={
0301                     "body": md,
0302                     "tag_name": next_tag,
0303                     "name": next_tag,
0304                     "draft": draft,
0305                     "target_commitish": target_hash,
0306                 },
0307             )
0308 
0309 
0310 def execute_bump(next_version: str):
0311     version_file = Path("version_number")
0312 
0313     version_file.write_text(next_version)
0314     git.add(version_file)
0315 
0316     repo = get_repo()
0317     zenodo_file = Path(".zenodo.json")
0318     update_zenodo(zenodo_file, repo, next_version)
0319     git.add(zenodo_file)
0320 
0321     citation_file = Path("CITATION.cff")
0322     update_citation(citation_file, next_version)
0323     git.add(citation_file)
0324 
0325 
0326 @app.command()
0327 def bump(
0328     next_version: str = typer.Argument(..., help="Format: X.Y.Z"), commit: bool = False
0329 ):
0330     execute_bump(next_version)
0331     next_tag = f"v{next_version}"
0332 
0333     if commit:
0334         git.commit(m=f"Bump to version {next_tag}", no_verify=True)
0335 
0336 
0337 async def get_release_branch_version(
0338     repo: str, target_branch: str, gh: GitHubAPI
0339 ) -> str:
0340     content = await gh.getitem(
0341         f"repos/{repo}/contents/version_number?ref={target_branch}"
0342     )
0343     assert content["type"] == "file"
0344     return base64.b64decode(content["content"]).decode("utf-8")
0345 
0346 
0347 async def get_tag_hash(tag: str, repo: str, gh: GitHubAPI) -> str:
0348     async for item in gh.getiter(f"repos/{repo}/tags"):
0349         if item["name"] == tag:
0350             return item["commit"]["sha"]
0351     raise ValueError(f"Tag {tag} not found")
0352 
0353 
0354 async def get_merge_commit_sha(pr: int, repo: str, gh: GitHubAPI) -> str:
0355     for _ in range(RETRY_COUNT):
0356         pull = await gh.getitem(f"repos/{repo}/pulls/{pr}")
0357         if pull["mergeable"] is None:
0358             # no merge commit yet, wait a bit
0359             await asyncio.sleep(RETRY_INTERVAL)
0360             continue
0361         if not pull["mergeable"]:
0362             raise RuntimeError("Pull request is not mergeable, can't continue")
0363         return pull["merge_commit_sha"]
0364     raise RuntimeError("Timeout waiting for pull request merge status")
0365 
0366 
0367 async def get_tag(tag: str, repo: str, gh: GitHubAPI):
0368     async for item in gh.getiter(f"repos/{repo}/tags"):
0369         if item["name"] == tag:
0370             return item
0371     return None
0372 
0373 
0374 async def get_release(tag: str, repo: str, gh: GitHubAPI):
0375     existing_release = None
0376     try:
0377         existing_release = await gh.getitem(f"repos/{repo}/releases/tags/v{tag}")
0378     except gidgethub.BadRequest as e:
0379         if e.status_code == http.HTTPStatus.NOT_FOUND:
0380             pass  # this is what we want
0381         else:
0382             raise e
0383     return existing_release
0384 
0385 
0386 if __name__ == "__main__":
0387     app()