From 1186f153cb01732900a681ff84d91b57b48fa2b8 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 29 Jun 2026 08:43:55 -0700 Subject: [PATCH] Add bump-dependency development skill and PyPI resolver (#172221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: AbΓ­lio Costa --- .claude/skills/bump-dependency/SKILL.md | 98 +++++++++ .../scripts/resolve_dependency.py | 205 ++++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 .claude/skills/bump-dependency/SKILL.md create mode 100755 .claude/skills/bump-dependency/scripts/resolve_dependency.py diff --git a/.claude/skills/bump-dependency/SKILL.md b/.claude/skills/bump-dependency/SKILL.md new file mode 100644 index 000000000000..77f4c9daa516 --- /dev/null +++ b/.claude/skills/bump-dependency/SKILL.md @@ -0,0 +1,98 @@ +--- +name: bump-dependency +description: Bumps a Python package dependency across Home Assistant Core integrations, regenerates core requirement files, runs verification tests and prek lint, and prepares a pull request with proper release/compare links. +--- + +# Bump Python Package Dependency in Home Assistant Core + +Follow these systematic steps to successfully bump a python package requirement in the repository, regenerate necessary derivative files, verify the integration, and raise a pull request. + +## Gotchas & Non-Obvious Constraints + +- **PR Template Integrity**: Follow Home Assistant's Pull Request template (`.github/PULL_REQUEST_TEMPLATE.md`) exactly as written, including any instructions inside the template itself. Preserve all sections, comments, and unchecked checkboxes unless the template explicitly says otherwise; the only allowed removal is the **Breaking change** section when the template instructs you to remove it if not applicable. +- **GitHub Tag Volatility**: Release tags on GitHub are highly inconsistent (e.g., `v1.2.3` vs `1.2.3` vs `release-1.2.3`). Always use the automated resolver `resolve_dependency.py` to check HEAD status for correct tags before hardcoding comparison URLs. + +## Step-by-Step Workflow Checklist + +### Phase A: Research and Plan +- [ ] **1. Identify Targets**: Note the requested target package and target version to bump. +- [ ] **2. Discover Codebase References**: Search the codebase to find all `manifest.json` and requirements files referencing the package. +- [ ] **3. Resolve Version/Tag Details**: Run the integrated validation helper script to resolve version details, GitHub repo, release tag format, and formatted PR links: + ```bash + uv run python3 ./.claude/skills/bump-dependency/scripts/resolve_dependency.py [--new-version ] + ``` +- [ ] **4. Plan-Validate-Execute (Draft Plan)**: Before modifying any files, write a brief, structured plan outlining the integrations to change, old version, new version, and the resolved comparison link. Show this draft plan to the user. + +### Phase B: Execute and Validate (Local Changes) +- [ ] **5. Check Uncommitted Changes**: Check for any uncommitted changes in the repository. If they exist, ask the user whether to stash, commit, or discard them before proceeding. +- [ ] **6. Git Branch Setup**: Create a clean branch starting from the latest `upstream/dev`: + ```bash + git fetch upstream dev + git checkout -b bump--to- upstream/dev + ``` +- [ ] **7. Apply Bump to manifests**: Update the version constraint string in all identified `manifest.json` files (e.g., change `"package==1.0.0"` to `"package==1.1.0"`). +- [ ] **8. Regenerate Core Requirements**: Run the requirements generator to update all derivative requirements and constraint files: + ```bash + uv run python3 -m script.gen_requirements_all + ``` +- [ ] **9. Validate Requirements**: Check `git diff` to ensure that only the targeted `manifest.json` files and `requirements_all.txt` (and potentially standard constraints) were modified. No unrelated files must be affected. +- [ ] **10. Local Venv Verification**: Install the exact targeted package version directly inside the virtual environment: + ```bash + uv pip install "==" + ``` + +### Phase C: Validation Loop (Tests & Lint) +- [ ] **11. Run Integration Tests**: Execute the pytest suite for all integrations that consume the bumped package: + ```bash + uv run pytest tests/components/ + ``` + - *Validation Loop*: If tests fail, analyze the error, apply appropriate fixes, and re-run pytest until all tests pass cleanly. +- [ ] **12. Run prek Lint Checks**: Run the local prek hooks on modified files: + ```bash + uv run prek run + ``` + - *Validation Loop*: If prek checks report any formatting or linting violations, fix them and repeat `uv run prek run` until it passes completely without errors. + +### Phase D: User Confirmation & PR Creation +- [ ] **13. Commit Changes**: Commit the clean changes: + ```bash + git add + git commit -m "Bump to " + ``` +- [ ] **14. Push Branch**: Push the local branch to your origin remote: + ```bash + git push origin bump--to- + ``` +- [ ] **15. PR Description Preparation**: Generate the pull request body from `.github/PULL_REQUEST_TEMPLATE.md`: + - **Proposed change**: Describe the package, old version, new version, target/source branches, and insert the resolved PyPI, changelog, and comparison diff links. + - **Type of change**: Check only 1 box in this section, and mark the `Dependency upgrade` checkbox as checked: `[x] Dependency upgrade`. + - **Breaking change**: You may remove the "Breaking change" section entirely from the template. + - **Validation checklists**: Mark `The code change is tested` checkbox as checked: `[x] The code change is tested`. + - **Keep remaining template intact**: Do NOT remove any other commented-out blocks, headers, or unchecked checkboxes in the template. +- [ ] **16. Mandatory Review Presentation**: Format the PR proposal using the **PR Presentation Template** below and display it to the user. **Stop and wait for the user to review and explicitly confirm/approve the PR template and draft details before creating the PR.** +- [ ] **17. Raise Pull Request**: Once the user approves, create the Pull Request using the GitHub CLI: + ```bash + gh pr create --repo home-assistant/core --base dev --head :bump--to- --title "Bump to " --body-file + ``` + +## PR Presentation Template + +```markdown +### πŸš€ Dependency Bump Pull Request Draft Review + +- **Package**: `` (`` β†’ ``) +- **PR Title**: `Bump to ` +- **Target Branch**: `dev` +- **Head Branch**: `:bump--to-` + +#### πŸ”— PyPI & GitHub Links +- **PyPI Release**: https://pypi.org/project/// +- **Changelog Link**: `` +- **Comparison Diff**: `` + +#### πŸ“ Modified Files +- `` + +#### πŸ“ Proposed PR Body + +``` diff --git a/.claude/skills/bump-dependency/scripts/resolve_dependency.py b/.claude/skills/bump-dependency/scripts/resolve_dependency.py new file mode 100755 index 000000000000..a0db0f0cee6c --- /dev/null +++ b/.claude/skills/bump-dependency/scripts/resolve_dependency.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +# ruff: noqa: T201, D103, BLE001 +"""Helper script to resolve package details, GitHub repo, release tags, and diff links from PyPI.""" + +import argparse +import json +import re +import sys +import urllib.error +import urllib.parse +import urllib.request + +from packaging.version import Version + +_VERSION_MATCH = r"^[a-zA-Z0-9_\-\.\+\!\*]+$" +_REPO_MATCH = r"https?://(?:www\.)?github\.com/([^/]+)/([^/]+)" +# Project owner or repo name +_NAME_MATCH = r"^[a-zA-Z0-9_\-\.]+$" + + +def get_pypi_data(package_name): + # Sanitize and URL-quote the package name to prevent URL path injection + safe_package_name = urllib.parse.quote(package_name) + url = f"https://pypi.org/pypi/{safe_package_name}/json" + req = urllib.request.Request( + url, + headers={ + "User-Agent": "HomeAssistant-Skill-Resolver/1.0", + "Accept": "application/json", + }, + ) + try: + with urllib.request.urlopen(req, timeout=10) as response: + return json.loads(response.read().decode()) + except urllib.error.HTTPError as e: + print(f"Error fetching PyPI data (HTTP {e.code}): {e.reason}", file=sys.stderr) + sys.exit(1) + except urllib.error.URLError as e: + print(f"Network error fetching PyPI data: {e.reason}", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"Unexpected error fetching PyPI data: {e}", file=sys.stderr) + sys.exit(1) + + +def find_github_repo(info): + urls = [] + if info.get("home_page"): + urls.append(info["home_page"]) + if info.get("project_urls"): + urls.extend(info["project_urls"].values()) + + for u in urls: + if not u: + continue + cleaned_url = u.replace("git+", "") + m = re.search(_REPO_MATCH, cleaned_url, re.IGNORECASE) + if m: + owner, repo = m.groups() + # Strip query parameters, hashes, trailing slashes, and .git extension + repo = repo.split("?")[0].split("#")[0].split("/")[0] + repo = repo.removesuffix(".git") + # Validate that the owner and repo name conform to valid formats, + # preventing prompt injection payloads embedded in repository URLs. + if re.match(_NAME_MATCH, owner) and re.match(_NAME_MATCH, repo): + return f"https://github.com/{owner}/{repo}" + return None + + +def check_github_tag(repo_url, version): + # Try with 'v' prefix, without prefix, and with 'release-' prefix + tag_options = [f"v{version}", version, f"release-{version}"] + for tag in tag_options: + # Check both tree and releases paths on GitHub + for path_template in [f"tree/{tag}", f"releases/tag/{tag}"]: + url = f"{repo_url}/{path_template}" + try: + req = urllib.request.Request( + url, + method="HEAD", + headers={"User-Agent": "HomeAssistant-Skill-Resolver/1.0"}, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + if resp.status == 200: + return tag + except urllib.error.HTTPError as e: + # If it's a 404, we continue checking other tag/path options + if e.code == 404: + continue + # For non-404 HTTP errors (like 403 Forbidden/429 rate limit), exit with error to avoid false reports + print( + f"\n[ERROR] HTTP error contacting GitHub ({e.code} {e.reason}) for tag '{tag}'", + file=sys.stderr, + ) + sys.exit(1) + except urllib.error.URLError as e: + # Connection or timeout error + print( + f"\n[ERROR] Network error contacting GitHub: {e.reason}", + file=sys.stderr, + ) + sys.exit(1) + except Exception as e: + # Unexpected exceptions + print( + f"\n[ERROR] Unexpected error verifying tag '{tag}': {e}", + file=sys.stderr, + ) + sys.exit(1) + return None + + +def main(): + parser = argparse.ArgumentParser( + description="Resolve PyPI package info and GitHub release diffs." + ) + parser.add_argument("package", help="Name of the PyPI package") + parser.add_argument( + "old_version", help="Current version installed in Home Assistant" + ) + parser.add_argument( + "--new-version", help="Target version to bump to (defaults to latest on PyPI)" + ) + args = parser.parse_args() + + if not re.match(_NAME_MATCH, args.package): + print(f"[ERROR] Invalid package name format: '{args.package}'", file=sys.stderr) + sys.exit(1) + + if not re.match(_VERSION_MATCH, args.old_version): + print( + f"[ERROR] Invalid old version format: '{args.old_version}'", file=sys.stderr + ) + sys.exit(1) + + if args.new_version and not re.match(_VERSION_MATCH, args.new_version): + print( + f"[ERROR] Invalid target version format: '{args.new_version}'", + file=sys.stderr, + ) + sys.exit(1) + + data = get_pypi_data(args.package) + info = data.get("info", {}) + + # Filter out pre-releases from the releases list to identify the latest stable version + stable_versions = [] + for v in data.get("releases", {}): + try: + ver = Version(v) + if not ver.is_prerelease: + stable_versions.append(ver) + except Exception: + continue + latest = str(max(stable_versions)) if stable_versions else info.get("version") + + if latest and not re.match(_VERSION_MATCH, latest): + print("[ERROR] Invalid latest version resolved from PyPI.", file=sys.stderr) + sys.exit(1) + + target_version = args.new_version or latest + if not target_version: + print("[ERROR] Could not resolve a target version from PyPI.", file=sys.stderr) + sys.exit(1) + + github_repo = find_github_repo(info) + + print("--- RESOLVED DEPENDENCY DETAILS ---") + print(f"Package: {args.package}") + print(f"Current Version: {args.old_version}") + print(f"Latest (PyPI): {latest}") + print(f"Target Version: {target_version}") + + if github_repo: + print(f"GitHub Repository: {github_repo}") + + # Verify tag formats on GitHub + old_tag = check_github_tag(github_repo, args.old_version) + new_tag = check_github_tag(github_repo, target_version) + + if not old_tag or not new_tag: + missing_tags = [] + if not old_tag: + missing_tags.append(args.old_version) + if not new_tag: + missing_tags.append(target_version) + print( + f"\n[ERROR] Could not resolve GitHub release tag for version(s): {', '.join(missing_tags)}", + file=sys.stderr, + ) + sys.exit(1) + + changelog_url = f"{github_repo}/releases/tag/{new_tag}" + compare_url = f"{github_repo}/compare/{old_tag}...{new_tag}" + + print("\n--- PR DOCUMENTATION LINKS ---") + print(f"Changelog Link: {changelog_url}") + print(f"Comparison Diff Link: {compare_url}") + else: + print("\n[WARNING] GitHub repository could not be resolved from PyPI metadata.") + print("Please resolve the release notes and diff links manually.") + + +if __name__ == "__main__": + main()