From f8cc8186c334d9e3a8ec33f17a88bb17e1537898 Mon Sep 17 00:00:00 2001 From: Glenn Jocher Date: Wed, 16 Oct 2024 16:01:25 +0200 Subject: [PATCH] New `check_pypi_version()` function (#247) Co-authored-by: UltralyticsAssistant --- .github/workflows/publish.yml | 42 ++----------------------- action.yml | 30 +++++++++--------- actions/__init__.py | 8 +---- actions/first_interaction.py | 24 +++++++------- actions/summarize_pr.py | 6 ++-- actions/summarize_release.py | 12 +++---- actions/update_markdown_code_blocks.py | 16 +++++----- actions/utils/__init__.py | 2 ++ actions/utils/github_utils.py | 43 ++++++++++++++++++++++++++ 9 files changed, 92 insertions(+), 91 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5d2204b..c7c9679 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -41,49 +41,11 @@ jobs: shell: python run: | import os - import requests - import toml - - # Load version and package name from pyproject.toml - pyproject = toml.load('pyproject.toml') - package_name = pyproject['project']['name'] - local_version = pyproject['project'].get('version', 'dynamic') - - # If version is dynamic, extract it from the specified file - if local_version == 'dynamic': - version_attr = pyproject['tool']['setuptools']['dynamic']['version']['attr'] - module_path, attr_name = version_attr.rsplit('.', 1) - with open(f"{module_path.replace('.', '/')}/__init__.py") as f: - local_version = next(line.split('=')[1].strip().strip("'\"") for line in f if line.startswith(attr_name)) - - print(f"Local Version: {local_version}") - - # Get online version from PyPI - response = requests.get(f"https://pypi.org/pypi/{package_name}/json") - online_version = response.json()['info']['version'] if response.status_code == 200 else None - print(f"Online Version: {online_version or 'Not Found'}") - - # Determine if a new version should be published - publish = False - if online_version: - local_ver = tuple(map(int, local_version.split('.'))) - online_ver = tuple(map(int, online_version.split('.'))) - major_diff = local_ver[0] - online_ver[0] - minor_diff = local_ver[1] - online_ver[1] - patch_diff = local_ver[2] - online_ver[2] - - publish = ( - (major_diff == 0 and minor_diff == 0 and 0 < patch_diff <= 2) or - (major_diff == 0 and minor_diff == 1 and local_ver[2] == 0) or - (major_diff == 1 and local_ver[1] == 0 and local_ver[2] == 0) - ) - else: - publish = True # First release - + from actions.utils import check_pypi_version + local_version, online_version, publish = check_pypi_version() os.system(f'echo "increment={publish}" >> $GITHUB_OUTPUT') os.system(f'echo "current_tag=v{local_version}" >> $GITHUB_OUTPUT') os.system(f'echo "previous_tag=v{online_version}" >> $GITHUB_OUTPUT') - if publish: print('Ready to publish new version to PyPI ✅.') id: check_pypi diff --git a/action.yml b/action.yml index 99cd895..a0b1043 100644 --- a/action.yml +++ b/action.yml @@ -191,6 +191,21 @@ runs: shell: bash continue-on-error: true + # Autolabel Issues and PRs (run before commit changes in case commit fails) ---------------------------------------- + - name: Autolabel Issues and PRs + if: inputs.labels == 'true' && (github.event.action == 'opened' || github.event.action == 'created') + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + GITHUB_TOKEN: ${{ inputs.token }} + FIRST_ISSUE_RESPONSE: ${{ inputs.first_issue_response }} + FIRST_PR_RESPONSE: ${{ inputs.first_pr_response }} + OPENAI_API_KEY: ${{ inputs.openai_api_key }} + OPENAI_MODEL: ${{ inputs.openai_model }} + run: | + ultralytics-actions-first-interaction + shell: bash + continue-on-error: true + # Commit Changes --------------------------------------------------------------------------------------------------- - name: Commit and Push Changes if: (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && github.event.action != 'closed' @@ -209,21 +224,6 @@ runs: shell: bash continue-on-error: false - # Autolabel Issues and PRs ---------------------------------------------------------------------------------------- - - name: Autolabel Issues and PRs - if: inputs.labels == 'true' && (github.event.action == 'opened' || github.event.action == 'created') - env: - PR_NUMBER: ${{ github.event.pull_request.number }} - GITHUB_TOKEN: ${{ inputs.token }} - FIRST_ISSUE_RESPONSE: ${{ inputs.first_issue_response }} - FIRST_PR_RESPONSE: ${{ inputs.first_pr_response }} - OPENAI_API_KEY: ${{ inputs.openai_api_key }} - OPENAI_MODEL: ${{ inputs.openai_model }} - run: | - ultralytics-actions-first-interaction - shell: bash - continue-on-error: true - # Broken links ----------------------------------------------------------------------------------------------------- - name: Broken Link Checker if: inputs.links == 'true' && github.event.action != 'closed' diff --git a/actions/__init__.py b/actions/__init__.py index 2261f1d..d3393d3 100644 --- a/actions/__init__.py +++ b/actions/__init__.py @@ -22,10 +22,4 @@ # ├── test_summarize_pr.py # └── ... -from .first_interaction import main as first_interaction_main -from .summarize_pr import main as summarize_pr_main -from .summarize_release import main as summarize_release_main -from .update_markdown_code_blocks import main as update_markdown_code_blocks_main - -__all__ = ["first_interaction_main", "summarize_pr_main", "summarize_release_main", "update_markdown_code_blocks_main"] -__version__ = "0.0.5" +__version__ = "0.0.6" diff --git a/actions/first_interaction.py b/actions/first_interaction.py index ddfe2b4..9b91adc 100644 --- a/actions/first_interaction.py +++ b/actions/first_interaction.py @@ -24,7 +24,7 @@ BLOCK_USER = os.getenv("BLOCK_USER", "false").lower() == "true" def get_event_content() -> Tuple[int, str, str, str, str, str, str]: - """Extracts the number, node_id, title, body, username, and issue_type.""" + """Extracts key information from GitHub event data for issues, pull requests, or discussions.""" with open(GITHUB_EVENT_PATH) as f: data = json.load(f) action = data["action"] # 'opened', 'closed', 'created' (discussion), etc. @@ -50,7 +50,7 @@ def get_event_content() -> Tuple[int, str, str, str, str, str, str]: def update_issue_pr_content(number: int, node_id: str, issue_type: str): - """Updates the title and body of the issue, pull request, or discussion.""" + """Updates the title and body of an issue, pull request, or discussion with predefined content.""" new_title = "Content Under Review" new_body = """This post has been flagged for review by [Ultralytics Actions](https://ultralytics.com/actions) due to possible spam, abuse, or off-topic content. For more information please see our: @@ -79,7 +79,7 @@ mutation($discussionId: ID!, $title: String!, $body: String!) { def close_issue_pr(number: int, node_id: str, issue_type: str): - """Closes the issue, pull request, or discussion.""" + """Closes the specified issue, pull request, or discussion using the GitHub API.""" if issue_type == "discussion": mutation = """ mutation($discussionId: ID!) { @@ -98,7 +98,7 @@ mutation($discussionId: ID!) { def lock_issue_pr(number: int, node_id: str, issue_type: str): - """Locks the issue, pull request, or discussion.""" + """Locks an issue, pull request, or discussion to prevent further interactions.""" if issue_type == "discussion": mutation = """ mutation($lockableId: ID!, $lockReason: LockReason) { @@ -119,7 +119,7 @@ mutation($lockableId: ID!, $lockReason: LockReason) { def block_user(username: str): - """Blocks a user from the organization.""" + """Blocks a user from the organization using the GitHub API.""" url = f"{GITHUB_API_URL}/orgs/{REPO_NAME.split('/')[0]}/blocks/{username}" r = requests.put(url, headers=GITHUB_HEADERS) print(f"{'Successful' if r.status_code == 204 else 'Fail'} user block for {username}: {r.status_code}") @@ -128,7 +128,7 @@ def block_user(username: str): def get_relevant_labels( issue_type: str, title: str, body: str, available_labels: Dict, current_labels: List ) -> List[str]: - """Uses OpenAI to determine the most relevant labels.""" + """Determines relevant labels for GitHub issues/PRs using OpenAI, considering title, body, and existing labels.""" # Remove mutually exclusive labels like both 'bug' and 'question' or inappropriate labels like 'help wanted' for label in ["help wanted", "TODO"]: # normal case available_labels.pop(label, None) # remove as should only be manually added @@ -212,7 +212,7 @@ query($owner: String!, $name: String!) { def apply_labels(number: int, node_id: str, labels: List[str], issue_type: str): - """Applies the given labels to the issue, pull request, or discussion.""" + """Applies specified labels to a GitHub issue, pull request, or discussion using the appropriate API.""" if "Alert" in labels: create_alert_label() @@ -243,13 +243,13 @@ mutation($labelableId: ID!, $labelIds: [ID!]!) { def create_alert_label(): - """Creates the 'Alert' label in the repository if it doesn't exist.""" + """Creates the 'Alert' label in the repository if it doesn't exist, with a red color and description.""" alert_label = {"name": "Alert", "color": "FF0000", "description": "Potential spam, abuse, or off-topic."} requests.post(f"{GITHUB_API_URL}/repos/{REPO_NAME}/labels", json=alert_label, headers=GITHUB_HEADERS) def is_org_member(username: str) -> bool: - """Checks if a user is a member of the organization.""" + """Checks if a user is a member of the organization using the GitHub API.""" org_name = REPO_NAME.split("/")[0] url = f"{GITHUB_API_URL}/orgs/{org_name}/members/{username}" r = requests.get(url, headers=GITHUB_HEADERS) @@ -257,7 +257,7 @@ def is_org_member(username: str) -> bool: def add_comment(number: int, node_id: str, comment: str, issue_type: str): - """Adds a comment to the issue, pull request, or discussion.""" + """Adds a comment to the specified issue, pull request, or discussion using the GitHub API.""" if issue_type == "discussion": mutation = """ mutation($discussionId: ID!, $body: String!) { @@ -276,7 +276,7 @@ mutation($discussionId: ID!, $body: String!) { def get_first_interaction_response(issue_type: str, title: str, body: str, username: str, number: int) -> str: - """Generates a custom response using LLM based on the issue/PR content and instructions.""" + """Generates a custom LLM response for GitHub issues, PRs, or discussions based on content.""" issue_discussion_response = f""" 👋 Hello @{username}, thank you for submitting a `{REPO_NAME}` 🚀 {issue_type.capitalize()}. To help us address your concern efficiently, please ensure you've provided the following information: @@ -370,7 +370,7 @@ YOUR {issue_type.upper()} RESPONSE: def main(): - """Runs autolabel action and adds custom response for new issues/PRs/Discussions.""" + """Executes autolabeling and custom response generation for new GitHub issues, PRs, and discussions.""" number, node_id, title, body, username, issue_type, action = get_event_content() available_labels = get_github_data("labels") label_descriptions = {label["name"]: label.get("description", "") for label in available_labels} diff --git a/actions/summarize_pr.py b/actions/summarize_pr.py index 6fa3446..73bf332 100644 --- a/actions/summarize_pr.py +++ b/actions/summarize_pr.py @@ -18,7 +18,7 @@ SUMMARY_START = ( def generate_pr_summary(repo_name, diff_text): - """Generates a professionally written yet accessible summary of a PR using OpenAI's API.""" + """Generates a concise, professional summary of a PR using OpenAI's API for Ultralytics repositories.""" if not diff_text: diff_text = "**ERROR: DIFF IS EMPTY, THERE ARE ZERO CODE CHANGES IN THIS PR." ratio = 3.3 # about 3.3 characters per token @@ -45,7 +45,7 @@ def generate_pr_summary(repo_name, diff_text): def update_pr_description(repo_name, pr_number, new_summary): - """Updates the original PR description with a new summary, replacing an existing summary if found.""" + """Updates the PR description with a new summary, replacing existing summary if present.""" # Fetch the current PR description pr_url = f"{GITHUB_API_URL}/repos/{repo_name}/pulls/{pr_number}" pr_response = requests.get(pr_url, headers=GITHUB_HEADERS) @@ -64,7 +64,7 @@ def update_pr_description(repo_name, pr_number, new_summary): def main(): - """Summarize PR.""" + """Summarize a pull request and update its description with an AI-generated summary.""" diff = get_pr_diff(PR_NUMBER) # Generate PR summary diff --git a/actions/summarize_release.py b/actions/summarize_release.py index 424c3d1..4a1afe5 100644 --- a/actions/summarize_release.py +++ b/actions/summarize_release.py @@ -24,14 +24,14 @@ PREVIOUS_TAG = os.getenv("PREVIOUS_TAG") def get_release_diff(repo_name: str, previous_tag: str, latest_tag: str) -> str: - """Get the diff between two tags.""" + """Retrieves the differences between two specified Git tags in a GitHub repository.""" url = f"{GITHUB_API_URL}/repos/{repo_name}/compare/{previous_tag}...{latest_tag}" r = requests.get(url, headers=GITHUB_HEADERS_DIFF) return r.text if r.status_code == 200 else f"Failed to get diff: {r.content}" def get_prs_between_tags(repo_name: str, previous_tag: str, latest_tag: str) -> list: - """Get PRs merged between two tags using the compare API.""" + """Retrieves and processes pull requests merged between two specified tags in a GitHub repository.""" url = f"{GITHUB_API_URL}/repos/{repo_name}/compare/{previous_tag}...{latest_tag}" r = requests.get(url, headers=GITHUB_HEADERS) r.raise_for_status() @@ -68,7 +68,7 @@ def get_prs_between_tags(repo_name: str, previous_tag: str, latest_tag: str) -> def get_new_contributors(repo: str, prs: list) -> set: - """Identify genuinely new contributors in the current release.""" + """Identify new contributors who made their first merged PR in the current release.""" new_contributors = set() for pr in prs: author = pr["author"] @@ -85,7 +85,7 @@ def get_new_contributors(repo: str, prs: list) -> set: def generate_release_summary(diff: str, prs: list, latest_tag: str, previous_tag: str, repo_name: str) -> str: - """Generate a summary for the release.""" + """Generate a concise release summary with key changes, purpose, and impact for a new Ultralytics version.""" pr_summaries = "\n\n".join( [f"PR #{pr['number']}: {pr['title']} by @{pr['author']}\n{pr['body'][:1000]}" for pr in prs] ) @@ -139,7 +139,7 @@ def generate_release_summary(diff: str, prs: list, latest_tag: str, previous_tag def create_github_release(repo_name: str, tag_name: str, name: str, body: str) -> int: - """Create a release on GitHub.""" + """Creates a GitHub release with specified tag, name, and body content for the given repository.""" url = f"{GITHUB_API_URL}/repos/{repo_name}/releases" data = {"tag_name": tag_name, "name": name, "body": body, "draft": False, "prerelease": False} r = requests.post(url, headers=GITHUB_HEADERS, json=data) @@ -147,7 +147,7 @@ def create_github_release(repo_name: str, tag_name: str, name: str, body: str) - def get_previous_tag() -> str: - """Get the previous tag from git tags.""" + """Retrieves the previous Git tag, excluding the current tag, using the git describe command.""" cmd = ["git", "describe", "--tags", "--abbrev=0", "--exclude", CURRENT_TAG] try: return subprocess.run(cmd, check=True, text=True, capture_output=True).stdout.strip() diff --git a/actions/update_markdown_code_blocks.py b/actions/update_markdown_code_blocks.py index 16b4c0a..28ff7b5 100644 --- a/actions/update_markdown_code_blocks.py +++ b/actions/update_markdown_code_blocks.py @@ -8,21 +8,21 @@ from pathlib import Path def extract_code_blocks(markdown_content): - """Extract Python code blocks with ``` followed by "python", "py", or "{ .py .annotate }".""" + """Extracts Python code blocks from markdown content using regex pattern matching.""" pattern = r"^( *)```(?:python|py|\{[ ]*\.py[ ]*\.annotate[ ]*\})\n(.*?)\n\1```" code_block_pattern = re.compile(pattern, re.DOTALL | re.MULTILINE) return code_block_pattern.findall(markdown_content) def remove_indentation(code_block, num_spaces): - """Removes `num_spaces` leading spaces from each line in `code_block` and returns the modified string.""" + """Removes specified leading spaces from each line in a code block to adjust indentation.""" lines = code_block.split("\n") stripped_lines = [line[num_spaces:] if len(line) >= num_spaces else line for line in lines] return "\n".join(stripped_lines) def add_indentation(code_block, num_spaces): - """Adds `num_spaces` leading spaces to each non-empty line in `code_block`.""" + """Adds specified number of leading spaces to non-empty lines in a code block.""" indent = " " * num_spaces lines = code_block.split("\n") indented_lines = [indent + line if line.strip() != "" else line for line in lines] @@ -30,7 +30,7 @@ def add_indentation(code_block, num_spaces): def format_code_with_ruff(temp_dir): - """Formats all Python code files in the `temp_dir` directory using the 'ruff' linter tool.""" + """Formats Python code files in the specified directory using ruff linter and docformatter tools.""" try: # Run ruff format subprocess.run( @@ -86,14 +86,14 @@ def format_code_with_ruff(temp_dir): def generate_temp_filename(file_path, index): - """Generates a unique temporary filename based on the file path and index.""" + """Generates a unique temporary filename using a hash of the file path and index.""" unique_string = f"{file_path.parent}_{file_path.stem}_{index}" unique_hash = hashlib.md5(unique_string.encode()).hexdigest() return f"temp_{unique_hash}.py" def process_markdown_file(file_path, temp_dir, verbose=False): - """Reads a markdown file, extracts Python code blocks, saves them to temp files, and updates the file.""" + """Processes a markdown file, extracting Python code blocks for formatting and updating the original file.""" try: markdown_content = Path(file_path).read_text() code_blocks = extract_code_blocks(markdown_content) @@ -119,7 +119,7 @@ def process_markdown_file(file_path, temp_dir, verbose=False): def update_markdown_file(file_path, markdown_content, temp_files): - """Updates the markdown file with formatted code blocks.""" + """Updates a markdown file with formatted Python code blocks extracted and processed externally.""" for num_spaces, original_code_block, temp_file_path in temp_files: try: with open(temp_file_path) as temp_file: @@ -143,7 +143,7 @@ def update_markdown_file(file_path, markdown_content, temp_files): def main(root_dir=Path.cwd(), verbose=False): - """Processes all markdown files in a specified directory and its subdirectories.""" + """Processes markdown files, extracts and formats Python code blocks, and updates the original files.""" root_path = Path(root_dir) markdown_files = list(root_path.rglob("*.md")) temp_dir = Path("temp_code_blocks") diff --git a/actions/utils/__init__.py b/actions/utils/__init__.py index cf6ba14..74e872f 100644 --- a/actions/utils/__init__.py +++ b/actions/utils/__init__.py @@ -10,6 +10,7 @@ from .github_utils import ( GITHUB_TOKEN, PR_NUMBER, REPO_NAME, + check_pypi_version, get_github_data, get_pr_diff, graphql_request, @@ -32,4 +33,5 @@ __all__ = ( "OPENAI_API_KEY", "OPENAI_MODEL", "get_completion", + "check_pypi_version", ) diff --git a/actions/utils/github_utils.py b/actions/utils/github_utils.py index 6c4b6cf..313eb45 100644 --- a/actions/utils/github_utils.py +++ b/actions/utils/github_utils.py @@ -42,3 +42,46 @@ def graphql_request(query: str, variables: dict = None) -> dict: success = "data" in result and not result.get("errors") print(f"{'Successful' if success else 'Fail'} discussion GraphQL request: {result.get('errors', 'No errors')}") return result + + +def check_pypi_version(pyproject_toml="pyproject.toml"): + """Compares local and PyPI package versions to determine if a new version should be published.""" + import tomllib # requires Python>=3.11 + + with open(pyproject_toml, "rb") as f: + pyproject = tomllib.load(f) + + package_name = pyproject["project"]["name"] + local_version = pyproject["project"].get("version", "dynamic") + + # If version is dynamic, extract it from the specified file + if local_version == "dynamic": + version_attr = pyproject["tool"]["setuptools"]["dynamic"]["version"]["attr"] + module_path, attr_name = version_attr.rsplit(".", 1) + with open(f"{module_path.replace('.', '/')}/__init__.py") as f: + local_version = next(line.split("=")[1].strip().strip("'\"") for line in f if line.startswith(attr_name)) + + print(f"Local Version: {local_version}") + + # Get online version from PyPI + response = requests.get(f"https://pypi.org/pypi/{package_name}/json") + online_version = response.json()["info"]["version"] if response.status_code == 200 else None + print(f"Online Version: {online_version or 'Not Found'}") + + # Determine if a new version should be published + if online_version: + local_ver = tuple(map(int, local_version.split("."))) + online_ver = tuple(map(int, online_version.split("."))) + major_diff = local_ver[0] - online_ver[0] + minor_diff = local_ver[1] - online_ver[1] + patch_diff = local_ver[2] - online_ver[2] + + publish = ( + (major_diff == 0 and minor_diff == 0 and 0 < patch_diff <= 2) + or (major_diff == 0 and minor_diff == 1 and local_ver[2] == 0) + or (major_diff == 1 and local_ver[1] == 0 and local_ver[2] == 0) + ) # should publish an update + else: + publish = True # publish as this is likely a first release + + return local_version, online_version, publish