diff --git a/.env.example b/.env.example index 1e49e90..b4e1ea8 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,20 @@ +# GitHub App credentials (for API-capable operations) GH_CLIENT_ID="123456" GH_CLIENT_PRIVATE_KEY="base64...your...pem...keyfile" GH_AUTH_TOKEN="ghp_123456" + +# Playwright bot credentials (for browser automation) +GH_BOT_USERNAME=PSRT-GHSA-Automation +GH_BOT_PASSWORD= +GH_BOT_OTP_SECRET= # Key used to generate OTP codes + +# CVE API credentials CVE_USERNAME="user@example.org" CVE_API_KEY="123456" CVE_ENV="testproddev" + +# Sentry +SENTRY_DSN= + +# Set to "true" to disable comment posting (staging/testing mode) +DONT_COMMENT=false diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index c218712..1aa6adc 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -1,4 +1,4 @@ -name: "PSRT GHSA Bot" +name: "PSRT GHSA Cron Bot" on: workflow_dispatch: diff --git a/.github/workflows/health-check.yml b/.github/workflows/health-check.yml new file mode 100644 index 0000000..fd0c9a1 --- /dev/null +++ b/.github/workflows/health-check.yml @@ -0,0 +1,33 @@ +name: "Health Check" + +on: + workflow_dispatch: + schedule: + - cron: "15 * * * *" + +jobs: + monitor: + runs-on: ubuntu-latest + name: "Monitor Workflow Health" + steps: + - uses: actions/checkout@v5 + + - name: Set up uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version-file: "pyproject.toml" + + - name: Install dependencies + run: uv sync --locked --no-editable --no-dev + + - name: Check workflow status and report to Sentry + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: uv run python src/psrt_ghsa_bot/health_check.py \ No newline at end of file diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..bdca2f2 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,69 @@ +name: "PSRT GHSA Playwright Bot" + +on: + workflow_dispatch: + schedule: + - cron: "*/5 * * * *" + +jobs: + process-comments: + runs-on: ubuntu-latest + name: "Process GHSA Comments" + steps: + - uses: actions/checkout@v5 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version-file: "pyproject.toml" + + - name: Install dependencies + run: uv sync --locked --no-editable --no-dev + + - name: Install Playwright browsers + run: uv run playwright install --with-deps chromium + + - name: Restore state from cache + id: cache-state + uses: actions/cache/restore@v4 + with: + path: state.json + key: playwright-state-${{ github.run_id }} + restore-keys: | + playwright-state- + + - name: Process comments + run: uv run python -m psrt_ghsa_bot.comment_processor + env: + GH_CLIENT_ID: ${{ vars.GH_CLIENT_ID }} + GH_CLIENT_SECRET: ${{ secrets.GH_CLIENT_SECRET }} + GH_CLIENT_PRIVATE_KEY: ${{ secrets.GH_CLIENT_PRIVATE_KEY }} + CVE_USERNAME: ${{ vars.CVE_USERNAME }} + CVE_API_KEY: ${{ secrets.CVE_API_KEY }} + CVE_ENV: ${{ vars.CVE_ENV }} + GH_BOT_USERNAME: ${{ vars.GH_BOT_USERNAME }} + GH_BOT_PASSWORD: ${{ secrets.GH_BOT_PASSWORD }} + GH_BOT_OTP_SECRET: ${{ secrets.GH_BOT_OTP_SECRET }} + + - name: Save state to cache + if: always() + uses: actions/cache/save@v4 + with: + path: state.json + key: playwright-state-${{ github.run_id }} + + - name: Commit state file + if: always() + run: | + git config user.name "PSRT-GHSA-Automation[bot]" + git config user.email "bot@python.org" + git add state.json + git diff --quiet || git commit -m "Update comment processing state [skip ci]" + git push || true \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9120a68..cd64451 100644 --- a/.gitignore +++ b/.gitignore @@ -165,3 +165,11 @@ cython_debug/ playwright/.auth/ playwright-state/ **/playwright-state/ + +# Playwright videos and traces +playwright-videos/ +playwright-traces/ +*.webm +trace.zip +debug_*.png +tests/PLAYWRIGHT_FULL.test \ No newline at end of file diff --git a/Makefile b/Makefile index e4daae3..10a118b 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,16 @@ .DEFAULT_GOAL:=help .ONESHELL: +ACT_INSTALLED := $(shell command -v act 2> /dev/null) + +.PHONY: help upgrade lint fmt fmt-check type-check ty check test ci +.PHONY: act-check act-list act-ci act-health-check act-playwright act-cron +.PHONY: cron playwright health-check help: ## Display this help text for Makefile @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z0-9_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) +##@ Development + upgrade: ## Upgrade all dependencies to the latest stable versions @uv lock --upgrade @echo "=> Dependencies Updated" @@ -14,16 +21,53 @@ lint: ## Lint the code fmt: ## Format the code @uv run ruff format . -mt-check: ## Runs Ruff format in check mode (no changes) +fmt-check: ## Runs Ruff format in check mode (no changes) @uv run --no-sync ruff format --check . type-check: ## Run type-checking @uv run ty check +ty: type-check ## Alias for type-check + +check: lint fmt type-check ## Run all checks except tests + test: ## Run tests + @test -f tests/PLAYWRIGHT_FULL.test && uv run playwright install --with-deps chromium 2>/dev/null || true @uv run pytest ci: lint fmt type-check test ## Run everything -app: ## Run the app - @uv run python app.py \ No newline at end of file +##@ GitHub Actions (Local Testing) + +act-check: ## Check if act is installed +ifndef ACT_INSTALLED + @echo "act is not installed. Install it from: https://nektosact.com/installation/index.html" + @exit 1 +endif + +act-list: act-check ## List all available GitHub Actions workflows + @act -l + +act-ci: act-check ## Test CI workflow locally using act + @act -W .github/workflows/ci.yml + +act-health-check: act-check ## Test health-check workflow locally using act + @act -W .github/workflows/health-check.yml + +act-playwright: act-check ## Test playwright workflow locally using act + @act -W .github/workflows/playwright.yml + +act-cron: act-check ## Test cron workflow locally using act + @act -W .github/workflows/cron.yml + +##@ Live Bot Commands +### These all require .env file with the vars set based on .env.example! + +cron: ## Run the cron bot (app.py) + @uv run python -m psrt_ghsa_bot.app + +playwright: ## Run playwright bot + @uv run python -m psrt_ghsa_bot.comment_processor + +health-check: ## Run health check + @uv run python -m psrt_ghsa_bot.health_check diff --git a/README.md b/README.md index cacc96f..4be658d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,83 @@ # PSRT GHSA Bot Bot which adds the PSRT GitHub team (`python/psrt`) and CVE IDs to GitHub Security Advisories. + +## Architecture + +### GitHub Actions + +- The cron bot runs off of [`.github/workflows/cron.yml`](.github/workflows/cron.yml) and runs at the top of each hour. + - Calls [src/psrt_ghsa_bot/app.py](src/psrt_ghsa_bot/app.py) + - Fetches open GHSAs from all installed orgs/repos + - Adds PSRT team to GHSAs without it + - Assigns CVE IDs to draft GHSAs without one +- The Playwright bot runs off of [`.github/workflows/playwright.yml`](.github/workflows/playwright.yml) and runs every 5 minutes. + - Calls [src/psrt_ghsa_bot/comment_processor.py](src/psrt_ghsa_bot/comment_processor.py) + - Reads GHSA comments via Playwright (no API available) + - Parses `@` commands, executes if authorized + - Posts responses, tracks state in `state.json` +- Health checks are done via [`.github/workflows/health-check.yml`](.github/workflows/health-check.yml) and runs every 15 minutes. + - It checks the status using the `gh` CLI and reports to Sentry if the bot is not healthy via Sentry cron monitors. + +### Why Playwright? + +The GHSA API is limited and has not really been developed in awhile. As such, it is missing a lot of features +like: +- Commenting on GHSAs +- Adding teams to GHSAs +- Assigning CVE IDs to GHSAs +- Removing temporary forks generated inside a GHSA that collaborators use fro remediation +- No webhooks to respond to things.. so we do the GHA polling thing... + +That's why there is this weird split between the cron.yml and playwright.yml. As API things +are added, we can move more into app.py/cron.yml and remove the playwright stuff (gladly!) + +## Installation + +The GitHub app (API-related activities) **MUST** be installed in all GitHub organizations +you want scanned. The GitHub user (`GH_BOT_USERNAME`, used by Playwright) **MUST** have access to the repos that +you want to interact with. + +**Important:** The GitHub App installation and GitHub user require the same permissions on repositories. +The Playwright bot uses the GitHub App installation to generate a list of repos +(~[comment_processor.py:130-140](src/psrt_ghsa_bot/comment_processor.py#L130-L140)), so any permission +mismatch will cause failures + + +## Development + +Uses `uv` for dependency management and `pytest` for testing. + +### Setup + +Make sure you have `uv` installed at https://docs.astral.sh/uv/getting-started/installation/ +Quickly, for Linux/macOS: +```shell +curl -LsSf https://astral.sh/uv/install.sh | sh +``` +or (not recommended): +```shell +pipx install uv +``` + +Afterwards, you can use `make` to run the commands in the [`Makefile`](Makefile). +- `make upgrade` - Upgrade all dependencies to the latest stable versions. + +Every time you run `uv run` or anything it automatically installs/syncs the dependencies +and it's near-instant so there is no `make install` or anything. + +### Tests + +Only unique things here are `PLAYWRIGHT_FULL.test`, which can can see more about in [`PLAYWRIGHT_FULL.test.example`](tests/PLAYWRIGHT_FULL.test.example). +This just tells the [`Makefile`](Makefile) target `make test` to run `playwright install` before running the tests +and then enables some of the skipped tests. These tests assume a test organization and all the setup behind that +because it is an integration test and will comment on and read a GHSA advisory. + +### Scripts + +There is a [`scripts/`](scripts/) directory with some local dev scripts, namely one that will +set up an organization with all the things needed to develop (TODO: it doesn't actually do anything yet.) + +The idea behind the bootstrap_org.py is that it will: +- Take your test org, set up a `psrt` (or whatever) team, create a repo with some GHSA, then comment, read the comments, etc. +This helps more from the integration testing side of things without doing it all in some public, busy repo like `python/CPython` :) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 7caa95c..a524354 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,8 +7,12 @@ requires-python = ">=3.14.0" dependencies = [ "cvelib>=1.4.0", "githubkit[auth-app]>=0.13.5", + # we might could put this into a dep group to not load it every time we install + # inside gh actions for cron.yml or the sentry check in "playwright>=1.55.0", + "pyotp>=2.9.0", "python-dotenv>=1.0.0", + "sentry-sdk>=2.22.0", ] [dependency-groups] @@ -32,11 +36,67 @@ line-length = 120 indent-width = 4 [tool.ruff.lint] -ignore = ["D203", "D213", "COM812"] +select = ["ALL"] +ignore = [ + "D203", + "D213", + "COM812", + "T201", + "TD", # todo without author + "FIX002", # Line contains TODO + "PLR0913", # Too many arguments + "PLR0911", # Too many returns + "C901", # Too complex + "ARG001", # unused arg + "BLE001", # blind except +] [tool.ruff.format] quote-style = "double" indent-style = "space" +[tool.ruff.lint.per-file-ignores] +"tests/**/*.*" = [ + "A", + "ARG", + "B", + "BLE", + "C901", + "D", + "DTZ", + "EM", + "FBT", + "G", + "N", + "PGH", + "PIE", + "PLR", + "PLW", + "PTH", + "RSE", + "S", + "S101", + "SIM", + "TC", + "TRY", + "SLF", + "ANN", + "FIX", + "TD", + "ERA", +] +"scripts/**/*.*" = [ + "INP001", # Implicit namespace package + "EXE001", # Shebang not executable + "RUF001", # Ambiguous unicode + "DTZ", # Timezone issues + "S", # Security issues + "T201", # Print statements +] + [tool.ruff.lint.pydocstyle] convention = "google" + +[tool.pytest.ini_options] +addopts = "-p no:anyio" +testpaths = ["tests/unit"] diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..ca6e887 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Scripts for PSRT GHSA bot.""" diff --git a/scripts/demo_get_comments.py b/scripts/demo_get_comments.py new file mode 100644 index 0000000..c441227 --- /dev/null +++ b/scripts/demo_get_comments.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python +"""Demo script for reading GHSA comments using playwright to read comments. + +This demonstrates Issue #3 implementation: Reading comments from GitHub +Security Advisories that are not accessible via the GitHub API. + +Usage: + # Headless mode (default) + uv run python scripts/demo_get_comments.py + + # Debug mode with visible browser (for local development) + uv run python scripts/demo_get_comments.py --debug + +Requirements: + - Authenticated session (run with --debug for first time manual auth, + or set GH_BOT_USERNAME/GH_BOT_PASSWORD env vars) + + +Example: + ``` + āœ— uv run python scripts/demo_get_comments.py --debug + šŸ” Reading comments from jolt-org/ghsa-testing/GHSA-f3x5-4pp6-r2mf + + šŸ› Debug mode: Browser window visible, slow motion enabled + + šŸ” Authenticating... + šŸ” Checking saved authentication state... + āœ… Using saved authentication state + šŸ“„ Fetching comments from GHSA... + + āœ… Found 1 comment(s): + + ================================================================================ + + [1] Comment by @JacobCoffee + ID: comment-0 + Created: 2025-11-05 20:03:21+00:00 + Updated: 2025-11-05 20:03:21+00:00 + Bot: False + Body: + + test + + -------------------------------------------------------------------------------- + + āœ… Demo complete! + ``` +""" + +import argparse + +from psrt_ghsa_bot.polyfills import GitHubPlaywrightClient, get_ghsa_comments + + +def main() -> None: + """Demonstrate reading GHSA comments.""" + # Parse command line arguments + parser = argparse.ArgumentParser(description="Demo script for reading GHSA comments") + parser.add_argument( + "--debug", + action="store_true", + help="Run in debug mode with visible browser window (headless=False, slow_mo=500)", + ) + parser.add_argument( + "--owner", + default="jolt-org", + help="Repository owner (default: jolt-org)", + ) + parser.add_argument( + "--repo", + default="ghsa-testing", + help="Repository name (default: ghsa-testing)", + ) + parser.add_argument( + "--ghsa", + default="GHSA-f3x5-4pp6-r2mf", + help="GHSA ID (default: GHSA-f3x5-4pp6-r2mf)", + ) + args = parser.parse_args() + + owner = args.owner + repo = args.repo + ghsa_id = args.ghsa + + print(f"šŸ” Reading comments from {owner}/{repo}/{ghsa_id}\n") + + # Configure client based on debug mode + # Create client and authenticate + if args.debug: + print("šŸ› Debug mode: Browser window visible, slow motion enabled\n") + client = GitHubPlaywrightClient(headless=False, slow_mo=500) + else: + client = GitHubPlaywrightClient(headless=True) + + with client: + print("šŸ” Authenticating...") + client.authenticate() + + # Get comments + print("šŸ“„ Fetching comments from GHSA...\n") + comments = get_ghsa_comments(client, owner, repo, ghsa_id) + + # Display results + if not comments: + print("ā„¹ļø No comments found on this GHSA.") + else: + print(f"āœ… Found {len(comments)} comment(s):\n") + print("=" * 80) + + for i, comment in enumerate(comments, 1): + print(f"\n[{i}] Comment by @{comment.author}") + print(f" ID: {comment.id}") + print(f" Created: {comment.created_at}") + print(f" Updated: {comment.updated_at}") + print(f" Bot: {comment.is_bot_comment}") + print(" Body:\n") + + # Indent comment body + for line in comment.body.split("\n"): + print(f" {line}") + + print("\n" + "-" * 80) + + print("\nāœ… Demo complete!") + + +if __name__ == "__main__": + main() diff --git a/scripts/demo_post_comment.py b/scripts/demo_post_comment.py new file mode 100644 index 0000000..ef2de8b --- /dev/null +++ b/scripts/demo_post_comment.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Demo script for posting a comment to a GitHub Security Advisory. + +This script demonstrates how to use the post_ghsa_comment function to add +a comment to a GHSA using Playwright automation. + +Usage: + python scripts/demo_post_comment.py + python scripts/demo_post_comment.py --debug + python scripts/demo_post_comment.py --ghsa GHSA-xxxx-xxxx-xxxx --comment "My comment text" +""" + +import argparse +import sys +from datetime import datetime + +from psrt_ghsa_bot.polyfills import GitHubPlaywrightClient, post_ghsa_comment + + +def main() -> None: + """Run the demo.""" + parser = argparse.ArgumentParser(description="Post a comment to a GHSA") + parser.add_argument( + "--owner", + default="jolt-org", + help="Repository owner (default: jolt-org)", + ) + parser.add_argument( + "--repo", + default="ghsa-testing", + help="Repository name (default: ghsa-testing)", + ) + parser.add_argument( + "--ghsa", + default="GHSA-f3x5-4pp6-r2mf", + help="GHSA ID (default: GHSA-f3x5-4pp6-r2mf)", + ) + parser.add_argument( + "--comment", + default=None, + help="Comment text to post (default: auto-generated timestamp comment)", + ) + parser.add_argument( + "--debug", + action="store_true", + help="Run in debug mode (visible browser, slow motion)", + ) + + args = parser.parse_args() + + owner = args.owner + repo = args.repo + ghsa_id = args.ghsa + + # Generate a default comment with timestamp if not provided + if args.comment: + comment_text = args.comment + else: + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + comment_text = f"Test comment posted by demo script at {timestamp}" + + print(f"šŸ“ Posting comment to {owner}/{repo}/{ghsa_id}\n") + print(f"Comment text: {comment_text}\n") + + if args.debug: + print("šŸ› Debug mode: Browser window visible, slow motion enabled\n") + + # Initialize Playwright client + with GitHubPlaywrightClient( + headless=not args.debug, + slow_mo=1000 if args.debug else 0, + ) as client: + # Authenticate + print("šŸ” Authenticating...") + try: + client.authenticate() + except RuntimeError as e: + print(f"\nāŒ Authentication failed: {e}") + print("\nPlease ensure you have set up authentication:") + print(" 1. Set GH_BOT_USERNAME and GH_BOT_PASSWORD in .env") + print(" 2. Or run scripts/setup_auth.py for manual login") + sys.exit(1) + + # Post the comment + print("šŸ“¤ Posting comment to GHSA...") + try: + comment_id = post_ghsa_comment(client, owner, repo, ghsa_id, comment_text) + + print("\nāœ… Comment posted successfully!") + print(f" Comment ID: {comment_id}") + print(f" View at: https://github.com/{owner}/{repo}/security/advisories/{ghsa_id}") + + except Exception as e: + print(f"\nāŒ Failed to post comment: {e}") + sys.exit(1) + + print("\nāœ… Demo complete!") + + +if __name__ == "__main__": + main() diff --git a/src/psrt_ghsa_bot/IDEAS.md b/src/psrt_ghsa_bot/IDEAS.md new file mode 100644 index 0000000..3dd83e7 --- /dev/null +++ b/src/psrt_ghsa_bot/IDEAS.md @@ -0,0 +1,17 @@ +- When someone @'s the bot, since they are a real user, can we use GH api + to find all mentions from their notification and act on that instead of scraping? + that would mean we wouldnt have to keep state tracking and processing things + "AFTER or ON" whenever the gh action ran las based on state +- If Playwright action runs before cron action, should we have playwright kick it off + so that the right groups are assigned? + +- When someone duplicate a coammand we shouldnt run it twice: + ``` + 2025-11-19 21:56:42,654 - __main__ - INFO - Command executed successfully: help + 2025-11-19 21:56:42,655 - __main__ - INFO - Executing command: help from @JacobCoffee on GHSA-j5pm-9w6r-h5rr + 2025-11-19 21:56:46,917 - __main__ - INFO - Command executed successfully: help + 2025-11-19 21:56:46,918 - __main__ - INFO - Checking GHSA: jolt-org/ghsa-testing/GHSA-f3x5-4pp6-r2mf (state: draft) + ``` + Caused 2 bot responses that were huge, so we should just do one somehow. +- for running out of headless, record video, etc. set sentinels in settings.py then use make targets +- if someone has multiple @s in the bot maybe we can process all found diff --git a/src/psrt_ghsa_bot/__init__.py b/src/psrt_ghsa_bot/__init__.py index a781b92..94fcdcb 100644 --- a/src/psrt_ghsa_bot/__init__.py +++ b/src/psrt_ghsa_bot/__init__.py @@ -1,3 +1,7 @@ """PSRT GHSA Bot package.""" __version__ = "0.1.0" + +from psrt_ghsa_bot.logging_config import setup_logging + +setup_logging() diff --git a/src/psrt_ghsa_bot/_monitoring.py b/src/psrt_ghsa_bot/_monitoring.py new file mode 100644 index 0000000..a55ce74 --- /dev/null +++ b/src/psrt_ghsa_bot/_monitoring.py @@ -0,0 +1,79 @@ +"""Sentry cron monitoring integration for GH act workflows.""" + +import os +from typing import TYPE_CHECKING + +import sentry_sdk +from sentry_sdk import crons + +if TYPE_CHECKING: + from psrt_ghsa_bot.config import CheckinStatus + + +def init_sentry() -> None: + """Initialize Sentry SDK with DSN from envvars.""" + dsn = os.environ.get("SENTRY_DSN") + if not dsn: + return + + sentry_sdk.init( + dsn=dsn, + enable_tracing=False, + ) + + +def capture_checkin( + monitor_slug: str, + status: CheckinStatus, + duration: float | None = None, +) -> str | None: + """Capture a Sentry cron check-in. + + Args: + monitor_slug: The unique identifier for this monitor (e.g., "psrt-ghsa-cron") + status: The status of the check-in (STATUS_IN_PROGRESS, STATUS_OK, or STATUS_ERROR) + duration: Optional duration in seconds for the job execution + + Returns: + Check-in ID if successful else none + """ + if not os.environ.get("SENTRY_DSN"): + return None + + try: + return crons.capture_checkin( + monitor_slug=monitor_slug, + status=status, + duration=duration, + ) + except (ImportError, AttributeError): + return None + + +def report_workflow_failure(workflow_name: str, run_id: str, conclusion: str) -> None: + """Report a workflow failure to Sentry as an error event. + + This is used by the health check workflow (health-chcek.yml) to report when other workflows fail. + We want to check status of our playright actions and our cron.yml action to make sure they are + running and since they arent running constantly on some web service anywhere this is the idea.. + + Args: + workflow_name: Name of the failed workflow + run_id: GitHub Actions run ID + conclusion: The conclusion status from GitHub Actions + """ + if not os.environ.get("SENTRY_DSN"): + return + + sentry_sdk.capture_message( + f"Workflow '{workflow_name}' failed with status: {conclusion}", + level="error", + extras={ + "workflow_name": workflow_name, + "run_id": run_id, + "conclusion": conclusion, + "workflow_url": ( + f"https://github.com/{os.environ.get('GITHUB_REPOSITORY', 'unknown')}/actions/runs/{run_id}" + ), + }, + ) diff --git a/src/psrt_ghsa_bot/app.py b/src/psrt_ghsa_bot/app.py index 9a8fec5..d90eb57 100644 --- a/src/psrt_ghsa_bot/app.py +++ b/src/psrt_ghsa_bot/app.py @@ -2,19 +2,21 @@ import base64 import datetime +import json import os import typing +from datetime import UTC +from http import HTTPStatus from cvelib.cve_api import CveApi from dotenv import load_dotenv from githubkit import AppAuthStrategy, GitHub +from githubkit.exception import RequestFailed load_dotenv() -if typing.TYPE_CHECKING: - pass -PSRT_GITHUB_TEAM_SLUG = "psrt" +PSRT_GITHUB_TEAM_SLUG = "psrt" # TODO: configurable def get_repository_advisories( @@ -23,9 +25,6 @@ def get_repository_advisories( repo: str, ) -> typing.Iterable[dict[str, typing.Any]]: """Lists repository security advisories using the REST API.""" - from githubkit.exception import RequestFailed - import json - try: # Use direct request instead of paginate to avoid validation issues response = github.rest.security_advisories.list_repository_advisories( @@ -34,20 +33,21 @@ def get_repository_advisories( ) # Parse JSON directly to bypass Pydantic validation advisories = json.loads(response.content) - for advisory in advisories: - yield advisory + yield from advisories except RequestFailed as e: # 404 means no advisories or no access - that's okay - if e.response.status_code == 404: + if e.response.status_code == HTTPStatus.NOT_FOUND: return raise def reserve_one_cve(cve_api: CveApi) -> str: - """Reserves a single CVE ID""" - resp = cve_api.reserve(count=1, random=True, year=str(datetime.date.today().year)) + """Reserves a single CVE ID.""" + resp = cve_api.reserve(count=1, random=True, year=str(datetime.datetime.now(tz=UTC).year)) cve_ids = [cve["cve_id"] for cve in resp["cve_ids"]] - assert len(cve_ids) == 1 + if len(cve_ids) != 1: + msg = f"Expected 1 CVE ID, got {len(cve_ids)}" + raise ValueError(msg) return cve_ids[0] @@ -62,11 +62,8 @@ def apply_to_repo(github: GitHub, owner: str, repo: str, cve_api: CveApi) -> Non # We only operate on in-progress security advisories. if state not in ("triage", "draft"): - print(f" ā­ļø Skipping {ghsa_id} (state: {state})") continue - print(f" šŸ“‹ Processing {ghsa_id} (state: {state})") - # Maintain a dictionary of updates to make and then submit them all at once. patch_data = {} @@ -75,10 +72,8 @@ def apply_to_repo(github: GitHub, owner: str, repo: str, cve_api: CveApi) -> Non if state == "draft" and security_advisory.get("cve_id") is None: cve_id = reserve_one_cve(cve_api) patch_data["cve_id"] = cve_id - print(f" āœ… Will reserve CVE ID: {cve_id}") patch_data["collaborating_teams"] = [PSRT_GITHUB_TEAM_SLUG] - print(f" āž• Will ensure team present: {PSRT_GITHUB_TEAM_SLUG}") # Apply updates, if any, to the security advisory. if patch_data: @@ -88,16 +83,15 @@ def apply_to_repo(github: GitHub, owner: str, repo: str, cve_api: CveApi) -> Non ghsa_id=ghsa_id, data=patch_data, ) - print(" šŸ’¾ Updated advisory") else: - print(" ā­ļø No updates needed") + pass if advisory_count == 0: - print(" ā„¹ļø No security advisories found") + pass def main() -> None: - print("Starting PSRT GitHub Security Advisory bot...") + """Main entry point for cron.yml.""" gh_client_private_key = base64.b64decode(os.environ["GH_CLIENT_PRIVATE_KEY"]).decode().strip() github = GitHub( AppAuthStrategy(os.environ["GH_CLIENT_ID"], gh_client_private_key), @@ -109,16 +103,11 @@ def main() -> None: env=os.environ.get("CVE_ENV", "prod"), ) - print("Fetching installations...") # Apply to all repositories for each installation. installations = github.rest.paginate( github.rest.apps.list_installations, ) - installation_count = 0 for installation_data in installations: - installation_count += 1 - print(f"\nProcessing installation {installation_count}: {installation_data.account.login}") - installation_github = github.with_auth( github.auth.as_installation(installation_data.id), ) @@ -127,11 +116,8 @@ def main() -> None: map_func=lambda r: r.parsed_data.repositories, ) for repo in repos: - print(f" Checking repo: {repo.owner.login}/{repo.name}") apply_to_repo(installation_github, repo.owner.login, repo.name, cve_api) - print(f"\nDone! Processed {installation_count} installation(s).") - if __name__ == "__main__": main() diff --git a/src/psrt_ghsa_bot/commands/__init__.py b/src/psrt_ghsa_bot/commands/__init__.py new file mode 100644 index 0000000..ef83000 --- /dev/null +++ b/src/psrt_ghsa_bot/commands/__init__.py @@ -0,0 +1,14 @@ +"""Command processing system for PSRT GHSA Bot.""" + +from psrt_ghsa_bot.commands.authorization import AuthorizationResult, is_authorized +from psrt_ghsa_bot.commands.executor import CommandResult, execute_command +from psrt_ghsa_bot.commands.parser import Command, parse_command + +__all__ = [ + "AuthorizationResult", + "Command", + "CommandResult", + "execute_command", + "is_authorized", + "parse_command", +] diff --git a/src/psrt_ghsa_bot/commands/authorization.py b/src/psrt_ghsa_bot/commands/authorization.py new file mode 100644 index 0000000..fd8f498 --- /dev/null +++ b/src/psrt_ghsa_bot/commands/authorization.py @@ -0,0 +1,200 @@ +"""Authorization checks for command execution. + +Determines if a user is authorized to execute bot commands based on: +- GHSA collaborator status (?) +- Repository admin status +- python/psrt team membership TODO: make this configurable, and allow list of org/teams? + like, what if we want PSRT to be able to responds across all PSF repos? (psf, python, pycon, pypi?) + or maybe we just say "team is $TEAM, and this bot works in $ORG as long as you are member of + that $TEAM" so we leave the user mgmt to the org admins. yeah.. probably that. +""" + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from githubkit import GitHub + +from http import HTTPStatus + + +@dataclass +class AuthorizationResult: + """Result of authorization check.""" + + authorized: bool + """Whether the user is authorized""" + reason: str + """Human-readable reason for the decision""" + + +def is_authorized( + github: GitHub, + username: str, + owner: str, + repo: str, + ghsa_id: str, +) -> AuthorizationResult: + """Check if user is authorized to execute commands. + + Authorization hierarchy: + 1. Members of python/psrt team + 2. GHSA collaborators + 3. Repository admins + + Args: + github: Authenticated GitHub client + username: GitHub username to check + owner: Repository owner + repo: Repository name + ghsa_id: GHSA identifier + + Returns: + AuthorizationResult indicating if user is authorized and a rason + """ + if _is_psrt_team_member(github, username): + return AuthorizationResult( + authorized=True, + reason=f"User {username} is a member of python/psrt team", + ) + + if _is_ghsa_collaborator(github, username, owner, repo, ghsa_id): + return AuthorizationResult( + authorized=True, + reason=f"User {username} is a collaborator on this advisory", + ) + + if _is_repo_admin(github, username, owner, repo): + return AuthorizationResult( + authorized=True, + reason=f"User {username} is an admin of {owner}/{repo}", + ) + + return AuthorizationResult( + authorized=False, + reason=f"User {username} is not authorized to execute commands", + ) + + +def _is_psrt_team_member(github: GitHub, username: str) -> bool: + """Check if user is member of python/psrt team. + + Args: + github: Authenticated GitHub client + username: GitHub username to check + + Returns: + True if user is a member of the python/psrt team + """ + try: + response = github.rest.teams.get_member_in_org( + org="python", + team_slug="psrt", + username=username, + ) + except Exception: + return False + else: + return response.status_code == HTTPStatus.NO_CONTENT + + +def _is_ghsa_collaborator( + github: GitHub, + username: str, + owner: str, + repo: str, + ghsa_id: str, +) -> bool: + """Check if user is collaborator on the GHSA. + + Args: + github: Authenticated GitHub client + username: GitHub username to check + owner: Repository owner + repo: Repository name + ghsa_id: GHSA identifier + + Returns: + True if user is a collaborator on the advisory + """ + try: + response = github.rest.security_advisories.get_repository_advisory( + owner=owner, + repo=repo, + ghsa_id=ghsa_id, + ) + advisory = response.json() + + collaborators = advisory.get("collaborating_users") or [] + teams = advisory.get("collaborating_teams") or [] + + for collaborator in collaborators: + login = collaborator.get("login") + if login and login.lower() == username.lower(): + return True + + return any((slug := team.get("slug")) and _is_team_member(github, owner, slug, username) for team in teams) + except Exception: + return False + + +def _is_team_member( + github: GitHub, + org: str, + team_slug: str, + username: str, +) -> bool: + """Check if user is member of a specific team. + + Args: + github: Authenticated GitHub client + org: Organization name + team_slug: Team slug + username: GitHub username to check + + Returns: + True if user is a member of the team + """ + try: + response = github.rest.teams.get_member_in_org( + org=org, + team_slug=team_slug, + username=username, + ) + except Exception: + return False + else: + return response.status_code == HTTPStatus.NO_CONTENT + + +def _is_repo_admin( + github: GitHub, + username: str, + owner: str, + repo: str, +) -> bool: + """Check if user has admin permissions on repository. + + Args: + github: Authenticated GitHub client + username: GitHub username to check + owner: Repository owner + repo: Repository name + + Returns: + True if user is a repository admin + """ + try: + response = github.rest.repos.get_collaborator_permission_level( + owner=owner, + repo=repo, + username=username, + ) + data = response.json() + permission = data.get("permission") + if not permission: + return False + except Exception: + return False + else: + return permission == "admin" diff --git a/src/psrt_ghsa_bot/commands/executor.py b/src/psrt_ghsa_bot/commands/executor.py new file mode 100644 index 0000000..da7abe3 --- /dev/null +++ b/src/psrt_ghsa_bot/commands/executor.py @@ -0,0 +1,312 @@ +"""Command execution engine for PSRT GHSA Bot. + +TODO: Maybe we should look into easily extensiblke commands + like how discord.py or others do it so it can autodiscover + cmds and register them and keep this file just for the + executor, auth, results, and parser... +""" + +import os +from dataclasses import dataclass +from datetime import datetime +from typing import TYPE_CHECKING + +from cvelib.cve_api import CveApi + +from psrt_ghsa_bot.app import reserve_one_cve +from psrt_ghsa_bot.commands.authorization import AuthorizationResult, is_authorized +from psrt_ghsa_bot.commands.parser import Command, get_help_text, get_unknown_command_response + +if TYPE_CHECKING: + from githubkit import GitHub + + from psrt_ghsa_bot.polyfills.playwright_base import GitHubPlaywrightClient + + +@dataclass +class CommandResult: + """Result of command execution.""" + + success: bool + """Whether the command executed successfully""" + message: str + """Response message to post as comment""" + error: Exception | None = None + """Exception if command failed""" + + +def execute_command( + cmd: Command, + github: GitHub, + playwright_client: GitHubPlaywrightClient, + owner: str, + repo: str, + ghsa_id: str, +) -> CommandResult: + """Execute a parsed command. + + Example: "@ assign-cve" + + These are based on src/psrt_ghsa_bot/commands/parser.py:AVAILABLE_COMMANDS + and do an auth check before trying. + + Args: + cmd: Parsed command to execute + github: Authenticated GitHub API client + playwright_client: Playwright client for UI automation + owner: Repository owner + repo: Repository name + ghsa_id: GHSA identifier + + Returns: + CommandResult with success status and response message + """ + # TODO: i dont like this.. it will just grow and grow... + auth_result = is_authorized(github, cmd.author, owner, repo, ghsa_id) + + if not auth_result.authorized: + return _unauthorized_result(cmd, auth_result) + + if cmd.action == "help": + return _handle_help(playwright_client) + + if cmd.action == "status": + return _handle_status(cmd, github, owner, repo, ghsa_id) + + if cmd.action == "reject": + return _handle_reject(cmd, github, owner, repo, ghsa_id) + + if cmd.action == "assign-cve": + return _handle_assign_cve(cmd, github, owner, repo, ghsa_id) + + if cmd.action == "publish": + return _handle_publish(cmd, github, owner, repo, ghsa_id) + + return CommandResult( + success=False, + message=get_unknown_command_response(cmd.action, playwright_client.username), + ) + + +def _unauthorized_result(cmd: Command, auth_result: AuthorizationResult) -> CommandResult: + """Generate result for unauthorized command attempt. + + Args: + cmd: The command that was attempted + auth_result: Authorization check result + + Returns: + CommandResult with unauthorized message + """ + message = ( + f"āŒ **Unauthorized**\n\n" + f"@{cmd.author}, you are not authorized to execute bot commands.\n\n" + f"**Reason:** {auth_result.reason}\n\n" + f"Only members of the `python/psrt` team and advisory collaborators can use bot commands." + ) + + return CommandResult(success=False, message=message) + + +def _handle_help(playwright_client: GitHubPlaywrightClient) -> CommandResult: + """Handle help command. + + Args: + playwright_client: Playwright client (for bot username) + + Returns: + CommandResult with help text + """ + help_text = get_help_text(playwright_client.username) + return CommandResult(success=True, message=help_text) + + +def _handle_status(_cmd: Command, github: GitHub, owner: str, repo: str, ghsa_id: str) -> CommandResult: + """Handle status command. + + Args: + cmd: Parsed command + github: GitHub API client + owner: Repository owner + repo: Repository name + ghsa_id: GHSA identifier + + Returns: + CommandResult with status information + """ + try: + response = github.rest.security_advisories.get_repository_advisory(owner=owner, repo=repo, ghsa_id=ghsa_id) + advisory = response.json() + + state = advisory["state"] + cve_id = advisory.get("cve_id") or "None assigned" + created_at = advisory["created_at"] + updated_at = advisory["updated_at"] + created = datetime.fromisoformat(created_at) + days_old = (datetime.now(created.tzinfo) - created).days + + message = ( + f"šŸ“Š **Advisory Status**\n\n" + f"**Repository:** {owner}/{repo}\n" + f"**Advisory:** {ghsa_id}\n" + f"**State:** {state}\n" + f"**CVE ID:** {cve_id}\n" + f"**Created:** {days_old} days ago\n" + f"**Last Updated:** {updated_at}" + ) + + return CommandResult(success=True, message=message) + + except Exception as e: + return CommandResult( + success=False, + message=f"āŒ **Error:** Failed to get advisory status: {e!s}", + error=e, + ) + + +def _handle_reject(cmd: Command, github: GitHub, owner: str, repo: str, ghsa_id: str) -> CommandResult: + """Handle CVE rejection command. + + Args: + cmd: Parsed command + github: GitHub API client + owner: Repository owner + repo: Repository name + ghsa_id: GHSA identifier + + Returns: + CommandResult with rejection confirmation + """ + if not cmd.arguments: + return CommandResult( + success=False, + message="āŒ **Error:** Missing CVE ID\n\nUsage: `reject `\n\nExample: `reject CVE-2024-1234`", + ) + + cve_id = cmd.arguments[0] + + try: + response = github.rest.security_advisories.get_repository_advisory(owner=owner, repo=repo, ghsa_id=ghsa_id) + advisory = response.json() + + current_cve = advisory.get("cve_id") + + if current_cve is None: + return CommandResult( + success=False, + message=f"āŒ **Error:** Advisory {ghsa_id} has no CVE ID assigned.", + ) + + if current_cve != cve_id: + return CommandResult( + success=False, + message=( + f"āŒ **Error:** CVE ID mismatch\n\n" + f"Advisory {ghsa_id} is associated with {current_cve}, not {cve_id}." + ), + ) + + github.rest.security_advisories.update_repository_advisory( + owner=owner, + repo=repo, + ghsa_id=ghsa_id, + data={"cve_id": None}, + ) + + message = ( + f"🚫 **CVE Rejected**\n\n" + f"**Advisory:** {ghsa_id}\n" + f"**CVE ID:** {cve_id}\n\n" + f"The CVE ID has been removed from this advisory.\n\n" + f"_Note: This does not withdraw the CVE from the CVE system. " + f"CVE rejection via API requires additional CVE API integration._" + ) + + return CommandResult(success=True, message=message) + + except Exception as e: + return CommandResult( + success=False, + message=f"āŒ **Error:** Failed to reject CVE: {e!s}", + error=e, + ) + + +def _handle_assign_cve(cmd: Command, github: GitHub, owner: str, repo: str, ghsa_id: str) -> CommandResult: + """Handle CVE assignment command. + + Args: + cmd: Parsed command + github: GitHub API client + owner: Repository owner + repo: Repository name + ghsa_id: GHSA identifier + + Returns: + CommandResult with assignment confirmation + """ + try: + response = github.rest.security_advisories.get_repository_advisory(owner=owner, repo=repo, ghsa_id=ghsa_id) + advisory = response.json() + current_cve = advisory.get("cve_id") + + if current_cve is not None: + return CommandResult( + success=False, + message=( + f"**CVE Already Assigned**\n\n" + f"Advisory {ghsa_id} already has CVE ID {current_cve} assigned.\n\n" + f"Use `status` to view advisory details or `reject {current_cve}` to remove it." + ), + ) + + cve_api = CveApi( + org="PSF", + username=os.environ["CVE_USERNAME"], + api_key=os.environ["CVE_API_KEY"], + env=os.environ.get("CVE_ENV", "prod"), + ) + + cve_id = reserve_one_cve(cve_api) + + github.rest.security_advisories.update_repository_advisory( + owner=owner, + repo=repo, + ghsa_id=ghsa_id, + data={"cve_id": cve_id}, + ) + + message = ( + f"šŸ”– **CVE Assigned**\n\n" + f"**Advisory:** {ghsa_id}\n" + f"**CVE ID:** {cve_id}\n\n" + f"A new CVE ID has been reserved and associated with this advisory." + ) + + return CommandResult(success=True, message=message) + + except Exception as e: + return CommandResult( + success=False, + message=f"āŒ **Error:** Failed to assign CVE: {e!s}", + error=e, + ) + + +def _handle_publish(cmd: Command, github: GitHub, owner: str, repo: str, ghsa_id: str) -> CommandResult: + """Handle advisory publication command. + + Args: + cmd: Parsed command + github: GitHub API client + owner: Repository owner + repo: Repository name + ghsa_id: GHSA identifier + + Returns: + CommandResult with publication confirmation + """ + message = "šŸ“¢ **Publish Advisory** (Stub)\n\nbut for now I am just a stub cmd :)" + + return CommandResult(success=True, message=message) diff --git a/src/psrt_ghsa_bot/commands/parser.py b/src/psrt_ghsa_bot/commands/parser.py new file mode 100644 index 0000000..779188a --- /dev/null +++ b/src/psrt_ghsa_bot/commands/parser.py @@ -0,0 +1,227 @@ +"""Command parser for extracting bot commands from GHSA comments.""" + +import os +import re +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import TypedDict + + +class CommandInfo(TypedDict, total=False): + """Type definition for command metadata because type checke rhates me.""" + + description: str + usage: str + example: str + aliases: list[str] + + +@dataclass +class Command: + """Represents a parsed command from a GHSA comment.""" + + action: str + """The command action (e.g., 'help', 'reject', 'assign-cve')""" + arguments: list[str] + """List of arguments provided to the command""" + author: str + """GitHub username who issued the command""" + comment_id: str + """ID of the comment containing the command""" + timestamp: datetime + """When the command was issued""" + + def __repr__(self) -> str: + """String repr for debugs.""" + args_str = " ".join(self.arguments) if self.arguments else "(no args)" + return f"Command({self.action} {args_str} by {self.author})" + + +AVAILABLE_COMMANDS: dict[str, CommandInfo] = { + "help": { + "description": "Show this help message with all available commands", + "usage": "help", + "example": "help", + }, + "reject": { + "description": "Reject/withdraw a CVE ID for this advisory", + "usage": "reject ", + "example": "reject CVE-2024-1234", + "aliases": ["withdraw"], + }, + "assign-cve": { + "description": "Request CVE ID assignment for this advisory", + "usage": "assign-cve", + "example": "assign-cve", + "aliases": ["request-cve"], + }, + "status": { + "description": "Show current status of this advisory and associated CVE", + "usage": "status", + "example": "status", + }, + "publish": { + "description": "Publish this advisory and associated CVE ID", + "usage": "publish", + "example": "publish", + "aliases": ["release", "complete"], + }, +} + +COMMAND_ALIASES = { + "withdraw": "reject", + "request-cve": "assign-cve", + "release": "publish", + "complete": "publish", +} + + +def _build_command_pattern(bot_username: str) -> re.Pattern[str]: + """Build regex pattern for matching bot commands. + + Args: + bot_username: The bot's GitHub username.. gotten from env var. + + Returns: + Compiled regex pattern that matches @ [args] + """ + escaped_username = re.escape(bot_username) + return re.compile( + rf"@{escaped_username}\s+(\S+)(?:\s+(.+))?", + re.IGNORECASE | re.MULTILINE, + ) + + +def parse_command( + comment_body: str | None, + author: str, + comment_id: str, + bot_username: str, + timestamp: datetime | None = None, +) -> Command | None: + """Parse a command from a comment body. + + Looks for pattern: @ [arguments...] + + Args: + comment_body: The full text of the comment + author: GitHub username who wrote the comment + comment_id: Unique identifier for the comment + bot_username: GitHub username of the bot to look for + timestamp: When the comment was created (defaults to now) + + Returns: + Parsed Command object, or None if no valid command found + + Example: + >>> parse_command( + ... "@ reject CVE-2024-1234", + ... "JacobCoffee", + ... "comment-123", + ... "" + ... ) + Command(reject CVE-2024-1234 by JacobCoffee) + """ + if not comment_body: + return None + + pattern = _build_command_pattern(bot_username) + match = pattern.search(comment_body) + if not match: + return None + + action = match.group(1).lower() + action = COMMAND_ALIASES.get(action, action) + + arguments_str = match.group(2) + arguments = arguments_str.split() if arguments_str else [] + + if timestamp is None: + timestamp = datetime.now(tz=UTC) + + return Command( + action=action, + arguments=arguments, + author=author, + comment_id=comment_id, + timestamp=timestamp, + ) + + +def is_valid_command(action: str) -> bool: + """Check if an action is a recognized command. + + Args: + action: The command action to validate + + Returns: + True if the action is recognized, False otherwise + """ + return action.lower() in AVAILABLE_COMMANDS + + +def get_help_text(bot_username: str | None = None) -> str: + """Generate help text listing all available commands. + + Args: + bot_username: The bot's GitHub username to use in examples. + Defaults to GH_BOT_USERNAME environment variable. + + Returns: + Formatted markdown help text + """ + lines = [ + "# PSRT GHSA Bot Commands", + "", + "Available commands:", + "", + ] + + for cmd_info in AVAILABLE_COMMANDS.values(): + usage = f"@{bot_username} {cmd_info['usage']}" + example = f"@{bot_username} {cmd_info['example']}" + + lines.append(f"### `{usage}`") + lines.append(cmd_info["description"]) + lines.append(f"**Example:** `{example}`") + + if "aliases" in cmd_info: + aliases = ", ".join(f"`{alias}`" for alias in cmd_info["aliases"]) + lines.append(f"**Aliases:** {aliases}") + + lines.append("") + + lines.extend( + [ + "--", + "", + f"_To use a command, mention `@{bot_username}` followed by the command name and any required arguments._", + "", + "_Only members of the `python/psrt` team and advisory collaborators can execute commands._", + ] + ) + + return "\n".join(lines) + + +def get_unknown_command_response(action: str, bot_username: str | None = None) -> str: + """Generate response message for unknown commands. + + Args: + action: The unrecognized command action + bot_username: The bot's GitHub username to use in help message. + Defaults to GH_BOT_USERNAME environment variable. + + Returns: + Formatted error message with help text + """ + if bot_username is None: + bot_username = os.environ.get("GH_BOT_USERNAME", "PSRT-GHSA-Automation") + + available = ", ".join(f"`{cmd}`" for cmd in AVAILABLE_COMMANDS) + + return ( + f"āŒ Unknown command: `{action}`\n\n" + f"Available commands: {available}\n\n" + f"Use `@{bot_username} help` for detailed usage information." + ) diff --git a/src/psrt_ghsa_bot/comment_processor.py b/src/psrt_ghsa_bot/comment_processor.py new file mode 100644 index 0000000..05aaf77 --- /dev/null +++ b/src/psrt_ghsa_bot/comment_processor.py @@ -0,0 +1,245 @@ +"""Comment processing service for PSRT GHSA Bot.""" + +import base64 +import contextlib +import logging +import os +from dataclasses import dataclass + +from dotenv import load_dotenv +from githubkit import AppAuthStrategy, GitHub + +from psrt_ghsa_bot import config +from psrt_ghsa_bot.app import get_repository_advisories +from psrt_ghsa_bot.commands.executor import execute_command +from psrt_ghsa_bot.commands.parser import parse_command +from psrt_ghsa_bot.polyfills.comments import get_ghsa_comments, post_ghsa_comment +from psrt_ghsa_bot.polyfills.playwright_base import GitHubPlaywrightClient +from psrt_ghsa_bot.state import StateManager + +load_dotenv() + +logger = logging.getLogger(__name__) + + +def _maybe_post_comment( + playwright_client: GitHubPlaywrightClient, + owner: str, + repo: str, + ghsa_id: str, + message: str, +) -> None: + """Post a comment if DONT_COMMENT is not set, otherwise log what would be posted. + + Fixes PLR0912 Too many branches + """ + if config.DONT_COMMENT: + logger.info("[DONT_COMMENT] Would post: %s...", message[:100]) + else: + post_ghsa_comment(playwright_client, owner, repo, ghsa_id, message) + + +@dataclass +class CommentProcessingStats: + """Statistics for a comment processing run.""" + + ghsas_checked: int = 0 + comments_found: int = 0 + commands_found: int = 0 + commands_executed: int = 0 + commands_skipped: int = 0 + errors: int = 0 + + +def process_ghsa_comments( + github: GitHub, + playwright_client: GitHubPlaywrightClient, + state_manager: StateManager, + owner: str, + repo: str, + ghsa_id: str, +) -> int: + """Process comments for a single GHSA. + + Args: + github: Authenticated GitHub API client + playwright_client: Playwright client for UI automation + state_manager: State manager for tracking processed commands + owner: Repository owner + repo: Repository name + ghsa_id: GHSA identifier + + Returns: + Number of commands executed + """ + ghsa_key = f"{owner}/{repo}/{ghsa_id}" + bot_username = playwright_client.username + if not bot_username: + msg = "Bot username not configured" + raise RuntimeError(msg) + + try: + comments = get_ghsa_comments(playwright_client, owner, repo, ghsa_id) + except PermissionError as e: + logger.warning("Access denied to %s: %s", ghsa_id, e) + return 0 + except Exception: + logger.exception("Failed to fetch comments for %s", ghsa_id) + return 0 + + if not comments: + logger.debug("No comments found on %s", ghsa_id) + return 0 + + logger.info("Processing %d comments on %s", len(comments), ghsa_id) + commands_executed = 0 + for comment in comments: + comment_id = comment.id + author = comment.author + body = comment.body + + if author == bot_username: + logger.debug("Skipping bot's own comment: %s", comment_id) + continue + + cmd = parse_command(body, author, comment_id, bot_username, comment.created_at) + + if cmd is None: + logger.debug("No command in comment from @%s", author) + continue + + if not state_manager.should_process_comment(ghsa_key, comment_id, comment.created_at): + logger.debug("Command already processed: %s from @%s", cmd.action, author) + continue + + logger.info("Executing command: %s from @%s on %s", cmd.action, author, ghsa_id) + try: + result = execute_command(cmd, github, playwright_client, owner, repo, ghsa_id) + _maybe_post_comment(playwright_client, owner, repo, ghsa_id, result.message) + state_manager.mark_command_processed(ghsa_key, comment_id) + commands_executed += 1 + logger.info("Command executed successfully: %s", cmd.action) + except Exception: + logger.exception("Command execution failed: %s from @%s on %s", cmd.action, author, ghsa_id) + error_message = ( + f"āŒ **Command Execution Failed**\n\n" + f"@{author}, an error occurred while processing your command.\n\n" + f"Please contact the PSRT team if this error persists." + ) + + with contextlib.suppress(Exception): + _maybe_post_comment(playwright_client, owner, repo, ghsa_id, error_message) + + return commands_executed + + +def process_all_comments( + github: GitHub, + playwright_client: GitHubPlaywrightClient, + state_manager: StateManager, +) -> CommentProcessingStats: + """Process comments for all GHSAs across all installations. + + Args: + github: Authenticated GitHub API client (app-level) + playwright_client: Playwright client for UI automation + state_manager: State manager for tracking processed commands + + Returns: + CommentProcessingStats with run statistics + """ + stats = CommentProcessingStats() + installations = github.rest.paginate(github.rest.apps.list_installations) + + for installation_data in installations: + installation_name = installation_data.account.login + logger.info("Processing installation: %s", installation_name) + + installation_github = github.with_auth(github.auth.as_installation(installation_data.id)) + repos = installation_github.rest.paginate( + installation_github.rest.apps.list_repos_accessible_to_installation, + map_func=lambda r: r.parsed_data.repositories, + ) + + for repo in repos: + owner = repo.owner.login + repo_name = repo.name + + try: + advisories = list(get_repository_advisories(installation_github, owner, repo_name)) + if not advisories: + continue + + count = len(advisories) + logger.debug("Found %d advisories in %s/%s", count, owner, repo_name) + + for advisory in advisories: + ghsa_id = advisory["ghsa_id"] + state_str = advisory["state"] + + if state_str not in ("triage", "draft"): + logger.debug("Skipping %s (state: %s)", ghsa_id, state_str) + continue + + stats.ghsas_checked += 1 + logger.info("Checking GHSA: %s/%s/%s (state: %s)", owner, repo_name, ghsa_id, state_str) + try: + commands_executed = process_ghsa_comments( + installation_github, + playwright_client, + state_manager, + owner, + repo_name, + ghsa_id, + ) + stats.commands_executed += commands_executed + except Exception: + logger.exception("Error processing %s", ghsa_id) + stats.errors += 1 + + except Exception: + logger.exception("Error accessing repository %s/%s", owner, repo_name) + stats.errors += 1 + + return stats + + +def main() -> None: + """Cmment processing machine.""" + logger.info("PSRT GHSA Bot - Comment Processor") + logger.info("=" * 50) + + if config.DONT_COMMENT: + logger.warning("DONT_COMMENT MODE ENABLED - Comments will NOT be posted") + + logger.info("Initializing GitHub API client...") + gh_client_private_key = base64.b64decode(os.environ["GH_CLIENT_PRIVATE_KEY"]).decode().strip() + github = GitHub(AppAuthStrategy(os.environ["GH_CLIENT_ID"], gh_client_private_key)) + + logger.info("Loading state manager...") + state_manager = StateManager() + state_manager.load() + + logger.info("Starting Playwright browser...") + with GitHubPlaywrightClient() as playwright_client: + logger.info("Authenticating to GitHub...") + playwright_client.authenticate() + logger.info("Authentication successful!") + + logger.info("Processing comments across all installations...") + stats = process_all_comments(github, playwright_client, state_manager) + + logger.info("=" * 50) + logger.info("Processing Summary:") + logger.info(" GHSAs checked: %d", stats.ghsas_checked) + logger.info(" Commands executed: %d", stats.commands_executed) + logger.info(" Errors: %d", stats.errors) + logger.info("=" * 50) + + logger.info("Saving state...") + state_manager.save() + logger.info("Done!") + + +if __name__ == "__main__": + main() diff --git a/src/psrt_ghsa_bot/config.py b/src/psrt_ghsa_bot/config.py new file mode 100644 index 0000000..5ef9517 --- /dev/null +++ b/src/psrt_ghsa_bot/config.py @@ -0,0 +1,16 @@ +"""Common configuration for the PSRT GHSA Bot.""" + +import os +from typing import Final, Literal + +DONT_COMMENT: bool = os.getenv("DONT_COMMENT", "").lower() in ("true", "1", "yes") + +type CheckinStatus = Literal["in_progress", "ok", "error"] + +MONITOR_SLUG_HEALTH: Final[str] = "psrt-health-monitor" +MONITOR_SLUG_GHSA: Final[str] = "psrt-ghsa-cron" +MONITOR_SLUG_PLAYWRIGHT: Final[str] = "psrt-playwright-cron" + +STATUS_IN_PROGRESS: Final[CheckinStatus] = "in_progress" +STATUS_OK: Final[CheckinStatus] = "ok" +STATUS_ERROR: Final[CheckinStatus] = "error" diff --git a/src/psrt_ghsa_bot/health_check.py b/src/psrt_ghsa_bot/health_check.py new file mode 100644 index 0000000..f33809a --- /dev/null +++ b/src/psrt_ghsa_bot/health_check.py @@ -0,0 +1,101 @@ +"""Health check script for monitoring GitHub Actions workflows. + +TODO: i'd like to know exactly the error and which workflow is failing + and send it to sentry or slack or an email or whatever +""" + +import json +import logging +import subprocess +import sys + +from psrt_ghsa_bot._monitoring import capture_checkin, init_sentry, report_workflow_failure +from psrt_ghsa_bot.config import ( + MONITOR_SLUG_GHSA, + MONITOR_SLUG_HEALTH, + MONITOR_SLUG_PLAYWRIGHT, + STATUS_ERROR, + STATUS_IN_PROGRESS, + STATUS_OK, +) + +logger = logging.getLogger(__name__) + +WORKFLOWS_TO_CHECK = [ + {"file": "cron.yml", "monitor_slug": MONITOR_SLUG_GHSA}, + {"file": "playwright.yml", "monitor_slug": MONITOR_SLUG_PLAYWRIGHT}, +] + + +def check_workflow_health() -> None: + """Check the health of configured workflows and report to Sentry.""" + init_sentry() + capture_checkin(MONITOR_SLUG_HEALTH, STATUS_IN_PROGRESS) + workflow_statuses = {workflow["file"]: False for workflow in WORKFLOWS_TO_CHECK} + + for workflow in WORKFLOWS_TO_CHECK: + logger.info("Checking workflow: %s", workflow["file"]) + + result = subprocess.run( # noqa: S603 + [ # noqa: S607 + "gh", + "run", + "list", + "--workflow", + workflow["file"], + "--json", + "conclusion,status,databaseId", + "--limit", + "5", + ], + check=False, + capture_output=True, + text=True, + ) + + if result.returncode != 0: + logger.warning("Failed to get workflow runs: %s", result.stderr) + continue + + try: + runs = json.loads(result.stdout) + except ValueError: + logger.warning("Failed to parse workflow runs for %s", workflow["file"]) + continue + + if not runs: + logger.warning("No runs found for %s", workflow["file"]) + continue + + completed_runs = [r for r in runs if r["status"] == "completed"] + if not completed_runs: + logger.info("No completed runs yet for %s", workflow["file"]) + continue + + latest_run = completed_runs[0] + conclusion = latest_run["conclusion"] + run_id = latest_run["databaseId"] + logger.info("Latest run: %s - %s", run_id, conclusion) + + if conclusion in ["failure", "timed_out", "cancelled"]: + logger.error("Workflow failed with status: %s", conclusion) + report_workflow_failure(workflow["file"], str(run_id), conclusion) + capture_checkin(workflow["monitor_slug"], STATUS_ERROR) + elif conclusion == "success": + logger.info("Workflow succeeded") + capture_checkin(workflow["monitor_slug"], STATUS_OK) + workflow_statuses[workflow["file"]] = True + else: + logger.warning("Unexpected conclusion: %s", conclusion) + + if all(workflow_statuses.values()): + capture_checkin(MONITOR_SLUG_HEALTH, STATUS_OK) + logger.info("All workflows healthy") + else: + capture_checkin(MONITOR_SLUG_HEALTH, STATUS_ERROR) + logger.error("Some workflows are unhealthy") + sys.exit(1) + + +if __name__ == "__main__": + check_workflow_health() diff --git a/src/psrt_ghsa_bot/logging_config.py b/src/psrt_ghsa_bot/logging_config.py new file mode 100644 index 0000000..5896593 --- /dev/null +++ b/src/psrt_ghsa_bot/logging_config.py @@ -0,0 +1,24 @@ +"""Centralized logging configuration for PSRT GHSA Bot.""" + +import logging +import sys + + +def setup_logging(level: int = logging.INFO) -> None: + """Configure logging for entire app. + + Args: + level: Logging level (default: INFO) + """ + logging.basicConfig( + level=level, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[ + logging.FileHandler("psrt-ghsa-bot.log"), + logging.StreamHandler(sys.stdout), + ], + ) + + # set these higher so they arent noiys.. + logging.getLogger("playwright").setLevel(logging.WARNING) + logging.getLogger("githubkit").setLevel(logging.WARNING) diff --git a/src/psrt_ghsa_bot/polyfills/__init__.py b/src/psrt_ghsa_bot/polyfills/__init__.py new file mode 100644 index 0000000..a005883 --- /dev/null +++ b/src/psrt_ghsa_bot/polyfills/__init__.py @@ -0,0 +1,10 @@ +"""GitHub API polyfills using Playwright for UI automation. + +Things that we can't do with the GitHub API but need to do with Playwright +like comment reading/writing, etc. +""" + +from psrt_ghsa_bot.polyfills.comments import GHSAComment, get_ghsa_comments, post_ghsa_comment +from psrt_ghsa_bot.polyfills.playwright_base import GitHubPlaywrightClient + +__all__ = ["GHSAComment", "GitHubPlaywrightClient", "get_ghsa_comments", "post_ghsa_comment"] diff --git a/src/psrt_ghsa_bot/polyfills/comments/__init__.py b/src/psrt_ghsa_bot/polyfills/comments/__init__.py new file mode 100644 index 0000000..56a81a6 --- /dev/null +++ b/src/psrt_ghsa_bot/polyfills/comments/__init__.py @@ -0,0 +1,9 @@ +"""GHSA comment operations using Playwright.""" + +from psrt_ghsa_bot.polyfills.comments.get_comments import ( + GHSAComment, + get_ghsa_comments, +) +from psrt_ghsa_bot.polyfills.comments.post_comment import post_ghsa_comment + +__all__ = ["GHSAComment", "get_ghsa_comments", "post_ghsa_comment"] diff --git a/src/psrt_ghsa_bot/polyfills/comments/get_comments.py b/src/psrt_ghsa_bot/polyfills/comments/get_comments.py new file mode 100644 index 0000000..48719ef --- /dev/null +++ b/src/psrt_ghsa_bot/polyfills/comments/get_comments.py @@ -0,0 +1,320 @@ +"""GitHub API polyfill for reading GHSA comments using Playwright. + +GitHub does not provide REST/GraphQL API endpoints for reading comments +on Security Advisories in draft/triage state. This module uses browser +automation to extract comment data from the GitHub web UI. +""" + +import logging +import os +from dataclasses import dataclass +from datetime import datetime +from typing import TYPE_CHECKING + +from playwright.sync_api import Locator +from playwright.sync_api import TimeoutError as PlaywrightTimeoutError + +if TYPE_CHECKING: + from psrt_ghsa_bot.polyfills.playwright_base import GitHubPlaywrightClient + +logger = logging.getLogger(__name__) + + +@dataclass +class GHSAComment: + """Represents a comment on a GitHub Security Advisory.""" + + id: str + """Unique identifier for the comment (extracted from DOM)""" + author: str + """GitHub username of the comment author""" + body: str + """Comment content/text""" + created_at: datetime + """Timestamp when comment was created""" + updated_at: datetime + """Timestamp when comment was last updated""" + is_bot_comment: bool + """True if comment is from or other bot account""" + + def __repr__(self) -> str: + """For debugging.""" + return f"GHSAComment(id={self.id}, author={self.author}, created={self.created_at})" + + +def get_ghsa_comments( + client: GitHubPlaywrightClient, + owner: str, + repo: str, + ghsa_id: str, +) -> list[GHSAComment]: + """Get all comments from a GitHub Security Advisory using Playwright. + + This function navigates to the GHSA page, extracts all comments from the + timeline, and handles pagination if needed. + + Args: + client: Authenticated GitHubPlaywrightClient instance + owner: Repository owner (organization or user) + repo: Repository name + ghsa_id: GHSA identifier (e.g., GHSA-xxxx-xxxx-xxxx) + + Returns: + List of GHSAComment objects in chronological order + + Raises: + RuntimeError: If GHSA page cannot be loaded or comments cannot be parsed + PlaywrightTimeoutError: If page load times out + + Example: + >>> with GitHubPlaywrightClient() as client: + ... client.authenticate() + ... comments = get_ghsa_comments(client, "python", "cpython", "GHSA-1234-5678-9abc") + ... for comment in comments: + ... print(f"{comment.author}: {comment.body[:50]}") + """ + client.navigate_to_ghsa(owner, repo, ghsa_id) + + if "404" in client.page.title() or client.page.locator("text=404").count() > 0: + msg = f"Advisory {ghsa_id} not found or bot lacks access (collaborator permissions required)" + raise PermissionError(msg) + + try: + client.page.wait_for_selector(".timeline-comment, .js-comment", timeout=10000, state="attached") + except PlaywrightTimeoutError: + logger.debug("Timeout waiting for comments to load on %s", ghsa_id) + + _load_all_comments(client) + return _extract_comments(client) + + +def _load_all_comments(client: GitHubPlaywrightClient) -> None: + """Load all comments by clicking 'Load more' buttons until exhausted. + + GitHub may paginate comments with "Show more..." / "Load more" buttons. + This function clicks them until all comments are visible. + + Args: + client: GitHubPlaywrightClient instance + """ + max_iterations = 50 # no loops allowee + iteration = 0 + + while iteration < max_iterations: + load_more_selectors = [ + "button:has-text('Show more')", + "button:has-text('Load more')", + "a:has-text('Show more')", + ".ajax-pagination-btn", + ] + + load_more_button = None + for selector in load_more_selectors: + try: + button = client.page.locator(selector).first + if button.is_visible(timeout=1000): + load_more_button = button + break + except PlaywrightTimeoutError: + continue + + if not load_more_button: + break + + try: + load_more_button.click() + client.page.wait_for_timeout(1000) + iteration += 1 + except Exception: + break + + +def _extract_comments(client: GitHubPlaywrightClient) -> list[GHSAComment]: + """Extract all comment data from the current page. + + Uses multiple fallback selectors to handle GitHub UI changes. + + Args: + client: GitHubPlaywrightClient instance + + Returns: + List of parsed GHSAComment objects + """ + comments: list[GHSAComment] = [] + comment_selectors = [ + ".js-comment-container .timeline-comment", + ".timeline-comment", + ".js-comment", + "div.TimelineItem.js-comment-container", + ] + + comment_elements: list[Locator] = [] + for selector in comment_selectors: + try: + elements = client.page.locator(selector).all() + logger.debug("Selector '%s' found %d elements", selector, len(elements)) + if elements: + comment_elements = elements + break + except Exception as e: + logger.debug("Selector '%s' failed: %s", selector, e) + continue + + if not comment_elements: + logger.debug("No comment elements found with any selector") + return comments + + logger.debug("Parsing %d comment elements", len(comment_elements)) + + for idx, element in enumerate(comment_elements): + try: + comment = _parse_comment_element(element, idx) + if comment: + comments.append(comment) + logger.debug("Parsed comment from @%s: %s", comment.author, comment.body[:30]) + else: + logger.debug("Element %d returned None (not a comment)", idx) + except Exception as e: + logger.debug("Failed to parse element %d: %s", idx, e) + continue + + return comments + + +# noinspection D +def _parse_comment_element(element: Locator, fallback_idx: int) -> GHSAComment | None: + """Parse a single comment element into a GHSAComment object. + + Args: + element: Playwright locator for the comment container + fallback_idx: Index to use if comment ID cannot be extracted + + Returns: + Parsed GHSAComment or None if parsing fails + """ + comment_id = None + for attr in ["data-comment-id", "id", "data-gid"]: + try: + comment_id = element.get_attribute(attr) + if comment_id: + break + except Exception: + logger.exception("Failed to get attribute %s", attr) + continue + + if not comment_id: + comment_id = f"comment-{fallback_idx}" + + author = None + author_selectors = [ + ".author", + ".timeline-comment-header .author", + "[data-hovercard-type='user']", + ".comment-user", + "a.Link--primary", + ] + + for selector in author_selectors: + try: + author_element = element.locator(selector).first + author = author_element.text_content(timeout=1000) + if author: + author = author.strip() + break + except Exception: + logger.exception("Failed to get author with selector %s", selector) + continue + + if not author: + logger.debug("Element %d: No author found, skipping", fallback_idx) + return None + + body = None + body_selectors = [ + ".comment-body", + ".markdown-body", + "[data-testid='comment-body']", + ".js-comment-body", + ] + + for selector in body_selectors: + try: + body_element = element.locator(selector).first + body = body_element.text_content(timeout=1000) + if body: + body = body.strip() + break + except Exception: + logger.exception("Failed to get body with selector %s", selector) + continue + + if not body: + body = "" # empty? comment body + + created_at = _extract_timestamp(element, "created") + updated_at = _extract_timestamp(element, "updated") or created_at + + is_bot = _is_bot_author(element, author) + + return GHSAComment( + id=comment_id, + author=author, + body=body, + created_at=created_at, + updated_at=updated_at, + is_bot_comment=is_bot, + ) + + +def _extract_timestamp(element: Locator, timestamp_type: str) -> datetime: + """Extract created or updated timestamp from comment element. + + Args: + element: Comment element locator + timestamp_type: "created" or "updated" + + Returns: + Parsed datetime from the element + + Raises: + ValueError: If timestamp cannot be extracted from any known selector + """ + timestamp_selectors = [ + "relative-time", + "time[datetime]", + ".timestamp", + f"[title*='{timestamp_type}']", + ] + + for selector in timestamp_selectors: + try: + time_element = element.locator(selector).first + datetime_str = time_element.get_attribute("datetime", timeout=1000) + if datetime_str: + return datetime.fromisoformat(datetime_str) + except Exception: + logger.debug("Selector %s did not match for %s timestamp", selector, timestamp_type) + continue + + msg = f"Could not extract {timestamp_type} timestamp from comment element" + raise ValueError(msg) + + +def _is_bot_author(element: Locator, author: str) -> bool: + """Determine if comment author is a bot account. + + Args: + element: Comment element locator + author: Author username + + Returns: + True if author is a bot + """ + if author == os.environ.get("GH_BOT_USERNAME", "PSRT-GHSA-Automation"): + return True + + try: + bot_badge = element.locator(".Label:has-text('Bot')").first + return bot_badge.is_visible(timeout=500) + except Exception: + return False diff --git a/src/psrt_ghsa_bot/polyfills/comments/post_comment.py b/src/psrt_ghsa_bot/polyfills/comments/post_comment.py new file mode 100644 index 0000000..be5b5b3 --- /dev/null +++ b/src/psrt_ghsa_bot/polyfills/comments/post_comment.py @@ -0,0 +1,152 @@ +"""For posting comments to GHSA using Playwright.""" + +import logging +from typing import TYPE_CHECKING + +from playwright.sync_api import TimeoutError as PlaywrightTimeoutError + +if TYPE_CHECKING: + from psrt_ghsa_bot.polyfills.playwright_base import GitHubPlaywrightClient + +logger = logging.getLogger(__name__) + + +def post_ghsa_comment( + client: GitHubPlaywrightClient, + owner: str, + repo: str, + ghsa_id: str, + comment_body: str, +) -> str: + """Post a comment to a GitHub Security Advisory using Playwright. + + Navigates to the GHSA page, locates the comment form, + fills in the comment text, and submits it. + + Args: + client: Authenticated GitHubPlaywrightClient instance + owner: Repository owner (organization or user) + repo: Repository name + ghsa_id: GHSA identifier (e.g., GHSA-xxxx-xxxx-xxxx) + comment_body: The text content of the comment to post + + Returns: + Comment ID of the newly created comment + + Raises: + RuntimeError: If comment cannot be posted or form not found + PlaywrightTimeoutError: If page load or form submission times out + ValueError: If comment_body is empty + + Example:i + >>> with GitHubPlaywrightClient() as client: + ... client.authenticate() + ... comment_id = post_ghsa_comment( + ... client, + ... "python", + ... "cpython", + ... "GHSA-1234-5678-9abc", + ... "This vulnerability has been verified." + ... ) + ... print(f"Posted comment: {comment_id}") + """ + if not comment_body or not comment_body.strip(): + msg = "comment_body cannot be empty" + raise ValueError(msg) + + client.navigate_to_ghsa(owner, repo, ghsa_id) + + _fill_comment_form(client, comment_body) + _submit_comment(client) + + return _wait_for_comment_posted(client, comment_body) + + +def _fill_comment_form(client: GitHubPlaywrightClient, comment_body: str) -> None: + """Locate and fill the comment textarea. + + Args: + client: GitHubPlaywrightClient instance + comment_body: Comment text to fill in + + Raises: + RuntimeError: If comment form cannot be found + """ + try: + textarea = client.page.locator('textarea[name="body"]').first + textarea.wait_for(state="visible", timeout=10000) + textarea.click() + textarea.fill(comment_body) + except PlaywrightTimeoutError: + msg = ( + "Could not find comment textarea. The page structure may have changed, " + "or you may not have permission to comment on this advisory." + ) + raise RuntimeError(msg) from None + + +def _submit_comment(client: GitHubPlaywrightClient) -> None: + """Submit the comment form. + + Args: + client: GitHubPlaywrightClient instance + + Raises: + RuntimeError: If submit button cannot be found or clicked + """ + try: + submit_button = client.page.locator('button[type="submit"][name="comment"]').first + submit_button.wait_for(state="visible", timeout=5000) + submit_button.click() + except PlaywrightTimeoutError: + msg = "Could not find comment submit button. The page structure may have changed." + raise RuntimeError(msg) from None + + +def _wait_for_comment_posted( + client: GitHubPlaywrightClient, + expected_body: str, + timeout: int = 15000, +) -> str: + """Wait for the comment to appear on the page after submission. + + Args: + client: GitHubPlaywrightClient instance + expected_body: The comment body we expect to see + timeout: Maximum time to wait in milliseconds + + Returns: + The ID of the newly posted comment + + Raises: + RuntimeError: If comment doesn't appear within timeout + """ + client.page.wait_for_timeout(2000) + + try: + # Find timeline item containing our comment text by checking text content + timeline_items = client.page.locator(".TimelineItem").all() + + for item in timeline_items: + try: + text_content = item.text_content(timeout=1000) + if text_content and expected_body.strip() in text_content: + comment_id = item.get_attribute("id") + if comment_id and comment_id.startswith("advisory-comment-"): + return comment_id.replace("advisory-comment-", "") + return "comment-posted" + except Exception: + logger.exception("Error checking timeline item: %s", item) + + msg = ( + f"Comment was submitted but could not be found on the page within {timeout}ms. " + "It may have been posted successfully but not yet visible." + ) + raise RuntimeError(msg) from None + + except PlaywrightTimeoutError: + msg = ( + f"Comment was submitted but could not be found on the page within {timeout}ms. " + "It may have been posted successfully but not yet visible." + ) + raise RuntimeError(msg) from None diff --git a/src/psrt_ghsa_bot/polyfills/playwright_base.py b/src/psrt_ghsa_bot/polyfills/playwright_base.py new file mode 100644 index 0000000..c0f4711 --- /dev/null +++ b/src/psrt_ghsa_bot/polyfills/playwright_base.py @@ -0,0 +1,291 @@ +"""Base Playwright client for GitHub UI automation. + +This module provides authentication and navigation utilities for automating +GitHub web UI interactions that are not available through the API. +""" + +import logging +import os +from pathlib import Path +from typing import Any, Self + +import pyotp +from dotenv import load_dotenv +from playwright.sync_api import Browser, BrowserContext, Page, Playwright, sync_playwright + +load_dotenv() + +logger = logging.getLogger(__name__) + + +class GitHubPlaywrightClient: + """Client for automating GitHub UI interactions using Playwright. + + Contains helpers to quickly login, go to GHSA, and interact with the UI. + Also some debug options for localdev like slow_mo and record_video + and headless/nonheadless so you can see whats happening if something break + """ + + def __init__( + self, + *, + headless: bool = True, + auth_token: str | None = None, + storage_state_path: str | None = None, + slow_mo: int = 0, + record_video: bool = False, + ) -> None: + """Initialize the GitHub Playwright client. + + Args: + headless: Whether to run browser in headless mode + auth_token: GitHub personal access token for authentication + storage_state_path: Path to saved authentication state file + slow_mo: Slow down operations by specified milliseconds (useful for debugging) + record_video: Whether to record videos of browser sessions + """ + self.headless = headless + self.auth_token = auth_token or os.getenv("GH_AUTH_TOKEN") + self.storage_state_path = storage_state_path or os.getenv( + "GH_AUTH_STATE_PATH", + "playwright/.auth/github_state.json", + ) + self.slow_mo = slow_mo + self.record_video = record_video + self.username = os.getenv("GH_BOT_USERNAME") + + self._playwright: Playwright | None = None + self._browser: Browser | None = None + self._context: BrowserContext | None = None + self._page: Page | None = None + + def __enter__(self) -> Self: + """Context manager entry.""" + self.start() + return self + + def __exit__(self, *args: object) -> None: + """Context manager exit.""" + self.close() + + def start(self) -> None: + """Start the Playwright browser session.""" + self._playwright = sync_playwright().start() + self._browser = self._playwright.chromium.launch( + headless=self.headless, + slow_mo=self.slow_mo, + ) + + storage_state_file = Path(self.storage_state_path) + storage_state: str | None = None + if storage_state_file.exists(): + storage_state = str(storage_state_file) + + context_options: dict[str, Any] = {"storage_state": storage_state} + if self.record_video: + context_options["record_video_dir"] = "playwright-videos/" + + self._context = self._browser.new_context(**context_options) + self._page = self._context.new_page() + + def close(self) -> None: + """Close the browser and cleanup resources.""" + if self._page: + self._page.close() + self._page = None + if self._context: + self._context.close() + self._context = None + if self._browser: + self._browser.close() + self._browser = None + if self._playwright: + self._playwright.stop() + self._playwright = None + + @property + def page(self) -> Page: + """Get the current page instance.""" + if not self._page: + msg = "Browser not started. Call start() first or use context manager." + raise RuntimeError(msg) + return self._page + + @property + def context(self) -> BrowserContext: + """Get the current browser context.""" + if not self._context: + msg = "Browser not started. Call start() first or use context manager." + raise RuntimeError(msg) + return self._context + + def authenticate(self, *, force: bool = False) -> None: + """Authenticate to GitHub using storage state or credentials. + + Authentication methods tried in order: + 1. Existing storage state (saved session) - PREFERRED + 2. Automated login with GH_BOT_USERNAME/GH_BOT_PASSWORD + + # if user/pass/otp is not sufficient the only other options + # i think we could do is oauth maybe? GH_SESSION storing + # requires rotating it manually every 30 days or so... + # this was easiestf or now! + + Args: + force: Force re-authentication even if state exists + + Raises: + RuntimeError: If all authentication methods fail + """ + storage_state_file = Path(self.storage_state_path) + + # Check if we already have a valid session loaded from storage state + # else we need to login (below) + if storage_state_file.exists() and not force and self._is_authenticated(): + return + + username = os.getenv("GH_BOT_USERNAME") + password = os.getenv("GH_BOT_PASSWORD") + if username and password: + self._login_with_credentials(username, password) + # Save the new session state + storage_state_file.parent.mkdir(parents=True, exist_ok=True) + self.context.storage_state(path=str(storage_state_file)) + return + + msg = ( + "Authentication failed. Set GH_BOT_USERNAME and GH_BOT_PASSWORD environment variables, " + "or use authenticate_manual() for interactive login." + ) + raise RuntimeError(msg) + + def authenticate_manual(self, timeout: int = 300_000) -> None: + """Perform manual authentication via GitHub's login page. + + This method navigates to GitHub's login page and waits for the user + to complete the login process manually. Once authenticated, the session + state is saved for future use. + + Args: + timeout: Maximum time to wait for manual login (in milliseconds) + """ + self.page.goto("https://github.com/login") + + # Wait for successful login by checking for redirect to main page + # or presence of user menu + try: + self.page.wait_for_url("https://github.com/**", timeout=timeout) + self.page.wait_for_selector("button[aria-label='Open user navigation menu']", timeout=10000) + except Exception as err: + msg = f"Manual authentication failed or timed out: {err}" + raise RuntimeError(msg) from err + + storage_state_file = Path(self.storage_state_path) + storage_state_file.parent.mkdir(parents=True, exist_ok=True) + self.context.storage_state(path=str(storage_state_file)) + + logger.info("Authentication successful! Session saved.") + logger.info("State saved to: %s", storage_state_file) + + def _login_with_credentials(self, username: str, password: str) -> None: + """Perform automated login with username and password. + + This handles the standard GitHub login flow. 2FA must be pre-approved + or the storage state from setup_auth.py must be used. + + Args: + username: GitHub username + password: GitHub password + + Raises: + RuntimeError: If login fails or requires 2FA + """ + self.page.goto("https://github.com/login", wait_until="networkidle", timeout=30000) + self.page.wait_for_selector('input[name="login"]', state="visible", timeout=15000) + + self.page.locator('input[name="login"]').fill(username) + self.page.locator('input[name="password"]').fill(password) + self.page.locator('input[type="submit"][value="Sign in"]').click() + + self.page.wait_for_timeout(2000) + + if "sessions/two-factor" in self.page.url: + otp_secret = os.getenv("GH_BOT_OTP_SECRET") + if not otp_secret: + msg = ( + "2FA required but GH_BOT_OTP_SECRET not set. " + "Set environment variable or use authenticate_manual() for interactive login." + ) + raise RuntimeError(msg) + + try: + totp = pyotp.TOTP(otp_secret) + otp_code = totp.now() + logger.info("Using OTP code for 2FA...") + + self.page.locator('input[name="app_otp"]').fill(otp_code) + self.page.locator('button[type="submit"]:has-text("Verify")').click() + self.page.wait_for_timeout(3000) + + except ImportError: + msg = "2FA required but pyotp not installed. did you 'uv sync' the project?" + raise RuntimeError(msg) from None + + if not self._is_authenticated(): + msg = "Login failed - authentication check failed" + raise RuntimeError(msg) + + def _is_authenticated(self) -> bool: + """Check if the current session is authenticated. + + Checks cookies for dotcom_user or user_session indicators. + + Returns: + True if authenticated, False otherwise + """ + try: + self.page.goto("https://github.com", wait_until="domcontentloaded", timeout=15000) + + cookies = self.context.cookies() + auth_cookies = { + cookie["name"]: cookie for cookie in cookies if cookie["domain"] in [".github.com", "github.com"] + } + + has_user_session = "user_session" in auth_cookies + has_dotcom_user = "dotcom_user" in auth_cookies + except Exception: + return False + else: + return has_user_session and has_dotcom_user + + def navigate_to_ghsa(self, owner: str, repo: str, ghsa_id: str) -> None: + """Navigate to a specific GitHub Security Advisory page. + + Does some fanangling because networkidle never happens since + GH does polling for live updates to broadcas to everyone + subscribed to the ws. + + Args: + owner: Repository owner (organization or user) + repo: Repository name + ghsa_id: GHSA identifier (e.g., GHSA-xxxx-xxxx-xxxx) + """ + url = f"https://github.com/{owner}/{repo}/security/advisories/{ghsa_id}" + self.page.goto(url, wait_until="domcontentloaded", timeout=30000) + + try: + self.page.wait_for_selector(".js-timeline-item, .TimelineItem", timeout=15000) + except Exception: + self.page.wait_for_selector("body", timeout=5000) + + def wait_for_page_ready(self, timeout: int = 30000) -> None: + """Wait for the page to be fully loaded and interactive. + + Uses domcontentloaded instead of networkidle because GitHub pages + maintain WebSocket connections for live updates which prevent + networkidle from ever being reached. + + Args: + timeout: Maximum time to wait in milliseconds + """ + self.page.wait_for_load_state("domcontentloaded", timeout=timeout) diff --git a/src/psrt_ghsa_bot/state.py b/src/psrt_ghsa_bot/state.py new file mode 100644 index 0000000..994621d --- /dev/null +++ b/src/psrt_ghsa_bot/state.py @@ -0,0 +1,193 @@ +"""State management for tracking processed comments and commands. + +We don't continuously run the playwright process, we run it periodically in GHA. +So, we need state tracking to keep track of the last time we ran so we only +process comments that have been created AFTER the last time we ran. + +Instead of storing all processed command hashes (which grows unbounded), +we use timestamp-based filtering: +- Store `last_processed_at` per GHSA +- Only process comments newer than this timestamp +- For replay capability, add comment IDs to `commands_to_reprocess` +""" + +import json +from dataclasses import dataclass, field +from datetime import UTC, datetime +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Mapping + + +@dataclass +class GHSAState: + """State for a single GHSA.""" + + last_processed_at: str | None = None + commands_processed_count: int = 0 + + def to_dict(self) -> dict[str, Any]: + """Convert to JSON-serializable dict.""" + return { + "last_processed_at": self.last_processed_at, + "commands_processed_count": self.commands_processed_count, + } + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> GHSAState: + """Creat from dict.""" + return cls( + last_processed_at=data.get("last_processed_at"), + commands_processed_count=data.get("commands_processed_count", 0), + ) + + +@dataclass +class BotState: + """Global bot state.""" + + last_run: str | None = None + ghsas: dict[str, GHSAState] = field(default_factory=dict) + commands_to_reprocess: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + """Convert to JSON-serializable dict.""" + return { + "last_run": self.last_run, + "ghsas": {key: state.to_dict() for key, state in self.ghsas.items()}, + "commands_to_reprocess": self.commands_to_reprocess, + } + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> BotState: + """Create from dict.""" + return cls( + last_run=data.get("last_run"), + ghsas={key: GHSAState.from_dict(value) for key, value in data.get("ghsas", {}).items()}, + commands_to_reprocess=data.get("commands_to_reprocess", []), + ) + + +class StateManager: + """Manages bot state with GHA cache + git storage.""" + + def __init__(self, state_file: Path | None = None) -> None: + """Initialize state manager. + + Args: + state_file: Path to state file. Defaults to repo root state.json + """ + self.state_file = state_file or Path(__file__).parent.parent.parent / "state.json" + self._state: BotState | None = None + + def load(self) -> BotState: + """Load state from file or create new.""" + if self._state is not None: + return self._state + + if self.state_file.exists(): + try: + with self.state_file.open() as f: + data = json.load(f) + self._state = BotState.from_dict(data) + except (json.JSONDecodeError, KeyError): + self._state = BotState() + else: + self._state = BotState() + + return self._state + + def save(self) -> None: + """Save state to file.""" + if self._state is None: + return + + self._state.last_run = datetime.now(UTC).isoformat() + + self.state_file.parent.mkdir(parents=True, exist_ok=True) + with self.state_file.open("w") as f: + json.dump(self._state.to_dict(), f, indent=2) + + def get_ghsa_state(self, ghsa_id: str) -> GHSAState: + """Get or create state for a GHSA. + + Args: + ghsa_id: GHSA identifier (e.g., "python/cpython/GHSA-xxxx-xxxx-xxxx") + + Returns: + GHSAState for the given GHSA + """ + state = self.load() + if ghsa_id not in state.ghsas: + state.ghsas[ghsa_id] = GHSAState() + return state.ghsas[ghsa_id] + + def should_process_comment(self, ghsa_id: str, comment_id: str, comment_created_at: datetime) -> bool: + """Check if a comment should be processed based on timestamp. + + Args: + ghsa_id: GHSA identifier + comment_id: GitHub comment ID + comment_created_at: When the comment was created + + Returns: + True if comment should be processed (newer than last run or in reprocess list) + """ + state = self.load() + + if comment_id in state.commands_to_reprocess: + return True + + ghsa_state = self.get_ghsa_state(ghsa_id) + if ghsa_state.last_processed_at is None: + return True + + last_processed = datetime.fromisoformat(ghsa_state.last_processed_at) + return comment_created_at > last_processed + + def mark_command_processed(self, ghsa_id: str, comment_id: str) -> None: + """Mark a command as processed. + + Args: + ghsa_id: GHSA identifier + comment_id: GitHub comment ID + """ + state = self.load() + ghsa_state = self.get_ghsa_state(ghsa_id) + ghsa_state.commands_processed_count += 1 + ghsa_state.last_processed_at = datetime.now(UTC).isoformat() + + if comment_id in state.commands_to_reprocess: + state.commands_to_reprocess.remove(comment_id) + + def update_ghsa_state(self, ghsa_id: str) -> None: + """Update state for a GHSA after processing. + + Args: + ghsa_id: GHSA identifier + """ + ghsa_state = self.get_ghsa_state(ghsa_id) + ghsa_state.last_processed_at = datetime.now(UTC).isoformat() + + def add_command_to_reprocess(self, comment_id: str) -> None: + """Add a comment ID to the reprocess list. + + This allows replaying commands by adding their comment IDs + via a PR to the state file. + + Args: + comment_id: GitHub comment ID to reprocess + """ + state = self.load() + if comment_id not in state.commands_to_reprocess: + state.commands_to_reprocess.append(comment_id) + + def get_commands_to_reprocess(self) -> list[str]: + """Get list of comment IDs pending reprocessing. + + Returns: + List of comment IDs to reprocess + """ + return self.load().commands_to_reprocess.copy() diff --git a/src/psrt_ghsa_bot/utils/__init__.py b/src/psrt_ghsa_bot/utils/__init__.py new file mode 100644 index 0000000..f5d3dc7 --- /dev/null +++ b/src/psrt_ghsa_bot/utils/__init__.py @@ -0,0 +1 @@ +"""Utility functions.""" diff --git a/src/psrt_ghsa_bot/utils/bot_helpers.py b/src/psrt_ghsa_bot/utils/bot_helpers.py new file mode 100644 index 0000000..5dd539a --- /dev/null +++ b/src/psrt_ghsa_bot/utils/bot_helpers.py @@ -0,0 +1,21 @@ +"""Helpers to be used by the bot when needed.""" + +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from psrt_ghsa_bot.polyfills.playwright_base import GitHubPlaywrightClient + +logger = logging.getLogger(__name__) + + +def take_debug_screenshot(client: GitHubPlaywrightClient, ghsa_id: str) -> None: + """Take a screenshot, saving locally to disk. + + Args: + client: Playwright client + ghsa_id: GHSA ID for the advisory + """ + logger.debug("Taking debug screenshot for %s", ghsa_id) + client.page.screenshot(path=f"debug_{ghsa_id}.png") + logger.info("Screenshot saved to debug_%s.png", ghsa_id) diff --git a/state.json b/state.json new file mode 100644 index 0000000..a7b9982 --- /dev/null +++ b/state.json @@ -0,0 +1,22 @@ +{ + "last_run": "2025-11-26T15:48:25.302350+00:00", + "ghsas": { + "jolt-org/ghsa-testing/GHSA-j5pm-9w6r-h5rr": { + "last_processed_at": "2025-11-25T17:49:46.335109+00:00", + "commands_processed_count": 1 + }, + "jolt-org/ghsa-testing/GHSA-f3x5-4pp6-r2mf": { + "last_processed_at": "2025-11-25T17:49:57.575833+00:00", + "commands_processed_count": 1 + }, + "jolt-org/ghsa-testing/GHSA-r5v8-ggrp-885r": { + "last_processed_at": "2025-11-26T15:39:50.646053+00:00", + "commands_processed_count": 5 + }, + "jolt-org/ghsa-testing/GHSA-v2g9-x2f3-j9g6": { + "last_processed_at": "2025-11-26T15:48:20.365872+00:00", + "commands_processed_count": 4 + } + }, + "commands_to_reprocess": [] +} \ No newline at end of file diff --git a/tests/PLAYWRIGHT_FULL.test.example b/tests/PLAYWRIGHT_FULL.test.example new file mode 100644 index 0000000..2b7e24b --- /dev/null +++ b/tests/PLAYWRIGHT_FULL.test.example @@ -0,0 +1,8 @@ +# Touch this file to enable full Playwright integration tests +# These tests require: +# - Playwright browsers installed (playwright install) +# - Authentication state (playwright/.auth/github_state.json) +# - Network access to GitHub +# +# These tests are skipped in CI by default to avoid flakiness +# remove ``.example` from the filename to enable them \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..33f01fb --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,84 @@ +"""Shared pytest config & fixtures.""" + +import os +from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +from psrt_ghsa_bot.polyfills import GitHubPlaywrightClient + +if TYPE_CHECKING: + from collections.abc import Generator + +PLAYWRIGHT_FULL = Path("tests/PLAYWRIGHT_FULL.test").exists() +AUTH_STATE_EXISTS = Path("playwright/.auth/github_state.json").exists() + +requires_playwright_auth = pytest.mark.skipif( + not (PLAYWRIGHT_FULL and AUTH_STATE_EXISTS), + reason="Requires PLAYWRIGHT_FULL.test file and authentication state", +) + + +@pytest.fixture +def client() -> GitHubPlaywrightClient: + """Create a GitHubPlaywrightClient instance for testing.""" + return GitHubPlaywrightClient(headless=True) + + +@pytest.fixture +def authenticated_client() -> Generator[GitHubPlaywrightClient]: + """Create an authenticated GitHubPlaywrightClient instance for testing.""" + client = GitHubPlaywrightClient(headless=True) + client.start() + client.authenticate() + yield client + client.close() + + +@pytest.fixture +def test_ghsa() -> Generator[dict[str, str]]: + """Create a test GHSA and clean it up after the test. + + Returns dict with: owner, repo, ghsa_id + """ + from githubkit import GitHub, TokenAuthStrategy # noqa: PLC0415 + + token = os.getenv("GH_AUTH_TOKEN") + if not token: + pytest.skip("GH_AUTH_TOKEN not set") + + github = GitHub(TokenAuthStrategy(token)) # type: ignore[arg-type] + owner = "jolt-org" + repo = "ghsa-testing" + + response = github.rest.security_advisories.create_repository_advisory( + owner=owner, + repo=repo, + data={ + "summary": f"Test Advisory {datetime.now().timestamp()}", + "description": "This is a test advisory created by pytest. It will be deleted automatically.", + "severity": "low", + "vulnerabilities": [ + { + "package": {"ecosystem": "pip", "name": "test-package"}, + "vulnerable_version_range": "< 1.0.0", + } + ], + }, + ) + + ghsa_id = response.json()["ghsa_id"] + + yield {"owner": owner, "repo": repo, "ghsa_id": ghsa_id} + + try: + github.rest.security_advisories.update_repository_advisory( + owner=owner, + repo=repo, + ghsa_id=ghsa_id, + data={"state": "closed"}, + ) + except Exception: + pass diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..39befb6 --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1 @@ +"""Integration tests that hit live GitHub APIs (or pages).""" diff --git a/tests/integration/test_comments.py b/tests/integration/test_comments.py new file mode 100644 index 0000000..ff4f155 --- /dev/null +++ b/tests/integration/test_comments.py @@ -0,0 +1,173 @@ +"""Integration tests for GHSA comment functionality (hit live GitHub).""" + +from datetime import datetime + +import pytest + +from psrt_ghsa_bot.polyfills import ( + GitHubPlaywrightClient, + get_ghsa_comments, + post_ghsa_comment, +) +from tests.conftest import requires_playwright_auth + + +@requires_playwright_auth +def test_get_ghsa_comments_basic(authenticated_client: GitHubPlaywrightClient) -> None: + """Test basic comment retrieval from a GHSA.""" + comments = get_ghsa_comments( + authenticated_client, + owner="jolt-org", + repo="ghsa-testing", + ghsa_id="GHSA-f3x5-4pp6-r2mf", + ) + assert isinstance(comments, list) + + +@requires_playwright_auth +def test_get_ghsa_comments_with_no_comments(authenticated_client: GitHubPlaywrightClient) -> None: + """Test retrieval from a GHSA with no comments.""" + comments = get_ghsa_comments( + authenticated_client, + owner="jolt-org", + repo="ghsa-testing", + ghsa_id="GHSA-f3x5-4pp6-r2mf", + ) + assert isinstance(comments, list) + + +@requires_playwright_auth +def test_get_ghsa_comments_bot_detection(authenticated_client: GitHubPlaywrightClient) -> None: + """Test that bot comments are properly detected.""" + comments = get_ghsa_comments( + authenticated_client, + owner="jolt-org", + repo="ghsa-testing", + ghsa_id="GHSA-f3x5-4pp6-r2mf", + ) + + bot_comments = [c for c in comments if c.is_bot_comment] + for bot_comment in bot_comments: + assert "bot" in bot_comment.author.lower() or "[bot]" in bot_comment.author + + +@requires_playwright_auth +def test_get_ghsa_comments_chronological_order(authenticated_client: GitHubPlaywrightClient) -> None: + """Test that comments are returned in chronological order.""" + comments = get_ghsa_comments( + authenticated_client, + owner="jolt-org", + repo="ghsa-testing", + ghsa_id="GHSA-f3x5-4pp6-r2mf", + ) + + if len(comments) >= 2: + for i in range(len(comments) - 1): + assert comments[i].created_at <= comments[i + 1].created_at + + +@requires_playwright_auth +def test_get_ghsa_comments_error_handling_invalid_ghsa() -> None: + """Test error handling for invalid GHSA ID.""" + with GitHubPlaywrightClient(headless=True) as client: + client.authenticate() + + with pytest.raises((PermissionError, Exception)) as exc_info: + get_ghsa_comments( + client, + owner="jolt-org", + repo="ghsa-testing", + ghsa_id="GHSA-0000-0000-0000", + ) + + if exc_info.type is not PermissionError: + error_msg = str(exc_info.value).lower() + assert "ghsa" in error_msg or "404" in error_msg or "not found" in error_msg + + +@requires_playwright_auth +def test_get_ghsa_comments_pagination() -> None: + """Test pagination handling for GHSAs with many comments.""" + with GitHubPlaywrightClient(headless=True) as client: + client.authenticate() + + comments = get_ghsa_comments( + client, + owner="test-org", + repo="test-repo", + ghsa_id="GHSA-xxxx-xxxx-xxxx", + ) + + assert len(comments) > 20 + + +@requires_playwright_auth +def test_post_ghsa_comment_basic(authenticated_client: GitHubPlaywrightClient, test_ghsa: dict[str, str]) -> None: + """Test basic comment posting to a GHSA.""" + test_comment = f"Test comment from pytest at {datetime.now().isoformat()}" + + comment_id = post_ghsa_comment( + authenticated_client, + owner=test_ghsa["owner"], + repo=test_ghsa["repo"], + ghsa_id=test_ghsa["ghsa_id"], + comment_body=test_comment, + ) + + assert isinstance(comment_id, str) + assert len(comment_id) > 0 + + +@requires_playwright_auth +def test_post_and_read_comment_roundtrip(authenticated_client: GitHubPlaywrightClient) -> None: + """Test posting a comment and then reading it back.""" + unique_text = f"Roundtrip test {datetime.now().timestamp()}" + + comment_id = post_ghsa_comment( + authenticated_client, + owner="jolt-org", + repo="ghsa-testing", + ghsa_id="GHSA-f3x5-4pp6-r2mf", + comment_body=unique_text, + ) + + assert comment_id is not None + + comments = get_ghsa_comments( + authenticated_client, + owner="jolt-org", + repo="ghsa-testing", + ghsa_id="GHSA-f3x5-4pp6-r2mf", + ) + + posted_comment = next((c for c in comments if unique_text in c.body), None) + assert posted_comment is not None + assert posted_comment.body == unique_text + + +@requires_playwright_auth +def test_post_comment_with_markdown(authenticated_client: GitHubPlaywrightClient) -> None: + """Test posting a comment with markdown formatting.""" + markdown_comment = f"""# Test Comment {datetime.now().timestamp()} + +This comment contains **bold**, *italic*, and `code`. + +- List item 1 +- List item 2 + +```python +def hello(): + return "world" +``` +""" + + comment_id = post_ghsa_comment( + authenticated_client, + owner="jolt-org", + repo="ghsa-testing", + ghsa_id="GHSA-f3x5-4pp6-r2mf", + comment_body=markdown_comment, + ) + + assert isinstance(comment_id, str) + assert len(comment_id) > 0 diff --git a/tests/integration/test_playwright.py b/tests/integration/test_playwright.py new file mode 100644 index 0000000..2c71478 --- /dev/null +++ b/tests/integration/test_playwright.py @@ -0,0 +1,69 @@ +"""Integration tests for Playwright client (hit live GitHub).""" + +from pathlib import Path + +import pytest + +from psrt_ghsa_bot.polyfills.playwright_base import GitHubPlaywrightClient +from tests.conftest import requires_playwright_auth + + +def test_navigate_to_public_page(client: GitHubPlaywrightClient) -> None: + """Test navigation to a public GitHub page.""" + with client: + client.page.goto("https://github.com") + assert "github.com" in client.page.url + + +@requires_playwright_auth +def test_authentication_with_saved_state(client: GitHubPlaywrightClient) -> None: + """Test authentication using saved state from manual login.""" + with client: + client.authenticate() + assert client._is_authenticated() + + +@requires_playwright_auth +def test_navigate_to_ghsa_page(client: GitHubPlaywrightClient) -> None: + """Test navigation to a GHSA page (requires authentication).""" + with client: + client.authenticate() + client.navigate_to_ghsa("jolt-org", "ghsa-testing", "GHSA-f3x5-4pp6-r2mf") + + assert "GHSA-f3x5-4pp6-r2mf" in client.page.url + assert "security/advisories" in client.page.url + + +@requires_playwright_auth +def test_authentication_state_persistence(client: GitHubPlaywrightClient) -> None: + """Test that authentication state is saved and can be reused.""" + storage_state_path = Path("playwright/.auth/github_state.json") + + with client: + if storage_state_path.exists(): + assert client._is_authenticated() + + assert storage_state_path.exists() + + +def test_wait_for_page_ready(client: GitHubPlaywrightClient) -> None: + """Test the wait_for_page_ready helper.""" + with client: + client.page.goto("https://github.com") + client.wait_for_page_ready(timeout=10000) + + +@pytest.mark.skip(reason="Manual test - run explicitly to set up initial authentication") +def test_manual_authentication() -> None: + """Test manual authentication flow. + + This test is marked as manual and should be run explicitly when needed + to set up initial authentication. + + Only really for localdev because we want CI to be automagic so use PAT for that. + + Run with: pytest tests/integration/test_playwright.py::test_manual_authentication -v + """ + with GitHubPlaywrightClient(headless=False) as client: + client.authenticate_manual(timeout=120000) + assert client._is_authenticated() diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..99c5b85 --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,401 @@ +"""Tests for command parsing system. + +todo: test for auth checks to make sure they are handled properly +""" + +import os +from datetime import UTC, datetime + +import pytest + +from psrt_ghsa_bot.commands.parser import ( + AVAILABLE_COMMANDS, + Command, + get_help_text, + get_unknown_command_response, + is_valid_command, + parse_command, +) + + +@pytest.fixture +def bot_username() -> str: + """Get bot username from environment or use default.""" + return os.environ.get("GH_BOT_USERNAME", "psrt-ghsabot") + + +class TestCommandParsing: + """Test command parsing from comment text.""" + + def test_parse_simple_command(self) -> None: + """Test parsing a simple command without arguments.""" + result = parse_command( + "@psrt-ghsabot help", + "testuser", + "comment-1", + "psrt-ghsabot", + datetime(2024, 1, 1, 12, 0, 0), + ) + + assert result is not None + assert result.action == "help" + assert result.arguments == [] + assert result.author == "testuser" + assert result.comment_id == "comment-1" + assert result.timestamp == datetime(2024, 1, 1, 12, 0, 0) + + def test_parse_command_with_single_argument(self) -> None: + """Test parsing command with one argument.""" + result = parse_command( + "@psrt-ghsabot reject CVE-2024-1234", + "maintainer", + "comment-2", + "psrt-ghsabot", + ) + + assert result is not None + assert result.action == "reject" + assert result.arguments == ["CVE-2024-1234"] + assert result.author == "maintainer" + + def test_parse_command_with_multiple_arguments(self) -> None: + """Test parsing command with multiple arguments.""" + result = parse_command( + "@psrt-ghsabot some-cmd arg1 arg2 arg3", + "user", + "comment-3", + "psrt-ghsabot", + ) + + assert result is not None + assert result.action == "some-cmd" + assert result.arguments == ["arg1", "arg2", "arg3"] + + def test_parse_command_case_insensitive(self) -> None: + """Test that bot mention and command are case-insensitive.""" + variations = [ + "@PSRT-GHSABOT help", + "@Psrt-GhsaBot help", + "@psrt-ghsabot HELP", + "@psrt-ghsabot Help", + ] + + for text in variations: + result = parse_command(text, "user", "comment", "psrt-ghsabot") + assert result is not None + assert result.action == "help" + + def test_parse_command_in_middle_of_text(self) -> None: + """Test parsing command embedded in larger comment.""" + comment = """ + I think we should handle this differently. + + @psrt-ghsabot reject CVE-2024-5678 + + Let me know if you agree. + """ + + result = parse_command(comment, "user", "comment", "psrt-ghsabot") + assert result is not None + assert result.action == "reject" + assert result.arguments == ["CVE-2024-5678"] + + def test_parse_command_with_extra_whitespace(self) -> None: + """Test parsing handles extra whitespace correctly.""" + result = parse_command( + "@psrt-ghsabot reject CVE-2024-9999", + "user", + "comment", + "psrt-ghsabot", + ) + + assert result is not None + assert result.action == "reject" + assert result.arguments == ["CVE-2024-9999"] + + def test_parse_command_with_alias(self) -> None: + """Test that command aliases work correctly.""" + result = parse_command( + "@psrt-ghsabot withdraw CVE-2024-1111", + "user", + "comment", + "psrt-ghsabot", + ) + + assert result is not None + assert result.action == "reject" + assert result.arguments == ["CVE-2024-1111"] + + def test_parse_publish_command(self) -> None: + """Test parsing publish command.""" + result = parse_command( + "@psrt-ghsabot publish", + "user", + "comment", + "psrt-ghsabot", + ) + + assert result is not None + assert result.action == "publish" + assert result.arguments == [] + + def test_parse_publish_aliases(self) -> None: + """Test that publish aliases work correctly.""" + for alias in ["release", "complete"]: + result = parse_command( + f"@psrt-ghsabot {alias}", + "user", + "comment", + "psrt-ghsabot", + ) + + assert result is not None + assert result.action == "publish" + + def test_parse_no_command(self) -> None: + """Test that None is returned when no command is found.""" + result = parse_command( + "Just a regular comment without bot mention", + "user", + "comment", + "psrt-ghsabot", + ) + + assert result is None + + def test_parse_incomplete_mention(self) -> None: + """Test that incomplete mentions don't parse.""" + result = parse_command("@psrt", "user", "comment", "psrt-ghsabot") + assert result is None + + result = parse_command("psrt-ghsabot help", "user", "comment", "psrt-ghsabot") + assert result is None + + def test_parse_empty_comment(self) -> None: + """Test parsing empty or None comment.""" + assert parse_command("", "user", "comment", "psrt-ghsabot") is None + assert parse_command(None, "user", "comment", "psrt-ghsabot") is None + + def test_parse_command_default_timestamp(self) -> None: + """Test that timestamp defaults to current time.""" + before = datetime.now(tz=UTC) + result = parse_command("@psrt-ghsabot help", "user", "comment", "psrt-ghsabot") + after = datetime.now(tz=UTC) + + assert result is not None + assert before <= result.timestamp <= after + + def test_parse_command_custom_bot_username(self) -> None: + """Test parsing with custom bot username.""" + result = parse_command( + "@my-custom-bot reject CVE-2024-9999", + "user", + "comment", + "my-custom-bot", + ) + + assert result is not None + assert result.action == "reject" + assert result.arguments == ["CVE-2024-9999"] + + def test_parse_command_username_with_special_chars(self) -> None: + """Test parsing bot username with regex special characters.""" + result = parse_command( + "@bot.test+dev reject CVE-2024-9999", + "user", + "comment", + "bot.test+dev", + ) + + assert result is not None + assert result.action == "reject" + + +class TestCommandValidation: + """Test command validation functions.""" + + def test_is_valid_command_recognized(self) -> None: + """Test that recognized commands are valid.""" + for cmd in AVAILABLE_COMMANDS: + assert is_valid_command(cmd) + + def test_is_valid_command_case_insensitive(self) -> None: + """Test validation is case-insensitive.""" + assert is_valid_command("HELP") + assert is_valid_command("Help") + assert is_valid_command("reject") + assert is_valid_command("REJECT") + + def test_is_valid_command_unrecognized(self) -> None: + """Test that unrecognized commands are invalid.""" + assert not is_valid_command("unknown") + assert not is_valid_command("foo") + assert not is_valid_command("delete-everything") + + def test_is_valid_command_publish(self) -> None: + """Test that publish command is recognized.""" + assert is_valid_command("publish") + assert is_valid_command("PUBLISH") + assert is_valid_command("Publish") + + +class TestHelpText: + """Test help text generation.""" + + def test_get_help_text_format(self) -> None: + """Test that help text is properly formatted.""" + help_text = get_help_text("psrt-ghsabot") + + assert "PSRT GHSA Bot Commands" in help_text + assert "Available commands:" in help_text + + for cmd_name, cmd_info in AVAILABLE_COMMANDS.items(): + assert cmd_name in help_text.lower() + assert cmd_info["description"] in help_text + assert f"@psrt-ghsabot {cmd_info['usage']}" in help_text + assert f"@psrt-ghsabot {cmd_info['example']}" in help_text + + def test_get_help_text_includes_all_commands(self) -> None: + """Test that all commands are documented in help.""" + help_text = get_help_text("psrt-ghsabot") + + for cmd_name in AVAILABLE_COMMANDS: + assert cmd_name in help_text.lower() + + def test_get_help_text_shows_aliases(self) -> None: + """Test that command aliases are shown in help.""" + help_text = get_help_text("psrt-ghsabot") + + assert "withdraw" in help_text + assert "request-cve" in help_text + assert "release" in help_text + assert "complete" in help_text + + def test_get_help_text_custom_bot_username(self) -> None: + """Test help text with custom bot username.""" + help_text = get_help_text("my-custom-bot") + + assert "@my-custom-bot help" in help_text + assert "@my-custom-bot reject" in help_text + assert "@my-custom-bot publish" in help_text + assert "@psrt-ghsabot" not in help_text + + +class TestUnknownCommandResponse: + """Test unknown command response generation.""" + + def test_get_unknown_command_response_includes_action(self) -> None: + """Test that response includes the unknown action.""" + response = get_unknown_command_response("foo") + + assert "foo" in response + assert "Unknown command" in response + + def test_get_unknown_command_response_lists_available(self) -> None: + """Test that response lists available commands.""" + response = get_unknown_command_response("invalid") + + for cmd in AVAILABLE_COMMANDS: + assert cmd in response.lower() + + def test_get_unknown_command_response_suggests_help(self, bot_username: str) -> None: + """Test that response suggests using help.""" + response = get_unknown_command_response("bad", bot_username) + + assert "help" in response.lower() + assert f"@{bot_username} help" in response + + +class TestCommandRepresentation: + """Test Command dataclass methods.""" + + def test_command_repr(self) -> None: + """Test Command __repr__ output.""" + cmd = Command( + action="reject", + arguments=["CVE-2024-1234"], + author="testuser", + comment_id="comment-1", + timestamp=datetime(2024, 1, 1), + ) + + repr_str = repr(cmd) + assert "reject" in repr_str + assert "CVE-2024-1234" in repr_str + assert "testuser" in repr_str + + def test_command_repr_no_arguments(self) -> None: + """Test Command __repr__ with no arguments.""" + cmd = Command( + action="help", + arguments=[], + author="user", + comment_id="comment", + timestamp=datetime.now(), + ) + + repr_str = repr(cmd) + assert "help" in repr_str + assert "(no args)" in repr_str + + +class TestEdgeCases: + """Test edge cases and error conditions.""" + + def test_command_with_special_characters_in_args(self) -> None: + """Test parsing arguments with special characters.""" + result = parse_command( + "@psrt-ghsabot test arg-with-dash arg_with_underscore", + "user", + "comment", + "psrt-ghsabot", + ) + + assert result is not None + assert result.arguments == ["arg-with-dash", "arg_with_underscore"] + + def test_multiple_bot_mentions(self) -> None: + """Test that only first mention is parsed.""" + comment = """ + @psrt-ghsabot help + + Actually, wait: + @psrt-ghsabot status + """ + + result = parse_command(comment, "user", "comment", "psrt-ghsabot") + assert result is not None + assert result.action == "help" + + def test_command_at_start_of_line(self) -> None: + """Test command at beginning of comment.""" + result = parse_command( + "@psrt-ghsabot reject CVE-2024-1234", + "user", + "comment", + "psrt-ghsabot", + ) + assert result is not None + + def test_command_at_end_of_line(self) -> None: + """Test command at end of comment.""" + result = parse_command( + "Here's my command: @psrt-ghsabot help", + "user", + "comment", + "psrt-ghsabot", + ) + assert result is not None + + def test_command_on_its_own_line(self) -> None: + """Test command on a line by itself.""" + comment = """ + Some context here. + + @psrt-ghsabot reject CVE-2024-1234 + + More context below. + """ + result = parse_command(comment, "user", "comment", "psrt-ghsabot") + assert result is not None + assert result.action == "reject" diff --git a/tests/test_executor.py b/tests/test_executor.py new file mode 100644 index 0000000..408479c --- /dev/null +++ b/tests/test_executor.py @@ -0,0 +1,242 @@ +"""Tests for command execution.""" + +from datetime import datetime +from unittest.mock import Mock + +import pytest + +from psrt_ghsa_bot.commands import CommandResult, execute_command +from psrt_ghsa_bot.commands.parser import Command + + +@pytest.fixture +def mock_github(): + """Create mock GitHub client.""" + return Mock() + + +@pytest.fixture +def mock_playwright(): + """Create mock Playwright client.""" + mock = Mock() + mock.username = "test-bot" + return mock + + +@pytest.fixture +def sample_command(): + """Create sample command.""" + return Command( + action="help", + arguments=[], + author="testuser", + comment_id="comment-1", + timestamp=datetime.now(), + ) + + +class TestCommandExecution: + """Test command execution.""" + + def test_execute_help_command(self, sample_command, mock_github, mock_playwright) -> None: + """Test executing help command.""" + mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) + + result = execute_command( + sample_command, + mock_github, + mock_playwright, + "python", + "cpython", + "GHSA-1234", + ) + + assert isinstance(result, CommandResult) + assert result.success + assert "PSRT GHSA Bot Commands" in result.message + assert "@test-bot help" in result.message + + def test_execute_status_command_stub(self, mock_github, mock_playwright) -> None: + """Test executing status command (stub).""" + cmd = Command( + action="status", + arguments=[], + author="testuser", + comment_id="comment-1", + timestamp=datetime.now(), + ) + + mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) + + advisory_response = Mock() + advisory_response.json.return_value = { + "state": "draft", + "cve_id": "CVE-2024-1234", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-15T00:00:00Z", + } + mock_github.rest.security_advisories.get_repository_advisory.return_value = advisory_response + + result = execute_command( + cmd, + mock_github, + mock_playwright, + "python", + "cpython", + "GHSA-1234", + ) + + assert result.success + assert "Advisory Status" in result.message + assert "GHSA-1234" in result.message + assert "draft" in result.message + + def test_execute_reject_command_with_cve(self, mock_github, mock_playwright) -> None: + """Test executing reject command with CVE ID.""" + cmd = Command( + action="reject", + arguments=["CVE-2024-1234"], + author="testuser", + comment_id="comment-1", + timestamp=datetime.now(), + ) + + mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) + + advisory_response = Mock() + advisory_response.json.return_value = {"cve_id": "CVE-2024-1234"} + mock_github.rest.security_advisories.get_repository_advisory.return_value = advisory_response + + result = execute_command( + cmd, + mock_github, + mock_playwright, + "python", + "cpython", + "GHSA-1234", + ) + + assert result.success + assert "CVE Rejected" in result.message + assert "CVE-2024-1234" in result.message + + def test_execute_reject_without_cve_fails(self, mock_github, mock_playwright) -> None: + """Test executing reject command without CVE ID fails.""" + cmd = Command( + action="reject", + arguments=[], + author="testuser", + comment_id="comment-1", + timestamp=datetime.now(), + ) + + mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) + + result = execute_command( + cmd, + mock_github, + mock_playwright, + "python", + "cpython", + "GHSA-1234", + ) + + assert not result.success + assert "Missing CVE ID" in result.message + + def test_execute_assign_cve_command(self, mock_github, mock_playwright) -> None: + """Test executing assign-cve command.""" + cmd = Command( + action="assign-cve", + arguments=[], + author="testuser", + comment_id="comment-1", + timestamp=datetime.now(), + ) + + mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) + + advisory_response = Mock() + advisory_response.json.return_value = {"cve_id": None} + mock_github.rest.security_advisories.get_repository_advisory.return_value = advisory_response + + result = execute_command( + cmd, + mock_github, + mock_playwright, + "python", + "cpython", + "GHSA-1234", + ) + + # Assign CVE requires CVE API credentials which won't be available in tests + # So we expect it to fail but still test it's calling the right handler + assert "assign" in result.message.lower() or "cve" in result.message.lower() + + def test_execute_publish_command(self, mock_github, mock_playwright) -> None: + """Test executing publish command.""" + cmd = Command( + action="publish", + arguments=[], + author="testuser", + comment_id="comment-1", + timestamp=datetime.now(), + ) + + mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) + + result = execute_command( + cmd, + mock_github, + mock_playwright, + "python", + "cpython", + "GHSA-1234", + ) + + assert result.success + assert "Publish Advisory" in result.message + + def test_execute_unknown_command(self, mock_github, mock_playwright) -> None: + """Test executing unknown command.""" + cmd = Command( + action="unknown-action", + arguments=[], + author="testuser", + comment_id="comment-1", + timestamp=datetime.now(), + ) + + mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=204) + + result = execute_command( + cmd, + mock_github, + mock_playwright, + "python", + "cpython", + "GHSA-1234", + ) + + assert not result.success + assert "Unknown command" in result.message + assert "unknown-action" in result.message + + def test_execute_unauthorized_user(self, sample_command, mock_github, mock_playwright) -> None: + """Test executing command as unauthorized user.""" + mock_github.rest.teams.get_member_in_org.return_value = Mock(status_code=404) + mock_github.rest.security_advisories.get_repository_advisory.side_effect = Exception("Not found") + mock_github.rest.repos.get_collaborator_permission_level.side_effect = Exception("Not found") + + result = execute_command( + sample_command, + mock_github, + mock_playwright, + "python", + "cpython", + "GHSA-1234", + ) + + assert not result.success + assert "Unauthorized" in result.message + assert "testuser" in result.message diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e0310a0 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +"""Unit tests.""" diff --git a/tests/test_app.py b/tests/unit/test_app.py similarity index 100% rename from tests/test_app.py rename to tests/unit/test_app.py diff --git a/tests/unit/test_comments.py b/tests/unit/test_comments.py new file mode 100644 index 0000000..603ac74 --- /dev/null +++ b/tests/unit/test_comments.py @@ -0,0 +1,74 @@ +"""Unit tests for GHSA comment functionality.""" + +from datetime import datetime + +import pytest + +from psrt_ghsa_bot.polyfills import ( + GHSAComment, + GitHubPlaywrightClient, + post_ghsa_comment, +) + + +def test_ghsa_comment_dataclass_repr() -> None: + """Test the GHSAComment repr method.""" + comment = GHSAComment( + id="123", + author="testuser", + body="Test comment", + created_at=datetime(2024, 1, 1, 12, 0, 0), + updated_at=datetime(2024, 1, 1, 12, 0, 0), + is_bot_comment=False, + ) + + repr_str = repr(comment) + assert "GHSAComment" in repr_str + assert "id=123" in repr_str + assert "author=testuser" in repr_str + assert "2024-01-01 12:00:00" in repr_str + + +def test_ghsa_comment_dataclass_fields() -> None: + """Test that GHSAComment has all required fields.""" + comment = GHSAComment( + id="test-id-123", + author="octocat", + body="This is a test comment body", + created_at=datetime(2024, 1, 15, 10, 30, 0), + updated_at=datetime(2024, 1, 15, 11, 0, 0), + is_bot_comment=False, + ) + + assert comment.id == "test-id-123" + assert comment.author == "octocat" + assert comment.body == "This is a test comment body" + assert comment.created_at == datetime(2024, 1, 15, 10, 30, 0) + assert comment.updated_at == datetime(2024, 1, 15, 11, 0, 0) + assert comment.is_bot_comment is False + + +def test_post_comment_empty_body_error() -> None: + """Test that posting an empty comment raises ValueError.""" + client = GitHubPlaywrightClient(headless=True) + with pytest.raises(ValueError, match="comment_body cannot be empty"): + post_ghsa_comment( + client, + owner="jolt-org", + repo="ghsa-testing", + ghsa_id="GHSA-f3x5-4pp6-r2mf", + comment_body="", + ) + + +def test_post_comment_whitespace_only_error() -> None: + """Test that posting whitespace-only comment raises ValueError.""" + client = GitHubPlaywrightClient(headless=True) + with pytest.raises(ValueError, match="comment_body cannot be empty"): + post_ghsa_comment( + client, + owner="jolt-org", + repo="ghsa-testing", + ghsa_id="GHSA-f3x5-4pp6-r2mf", + comment_body=" \n\t ", + ) diff --git a/tests/unit/test_playwright_base.py b/tests/unit/test_playwright_base.py new file mode 100644 index 0000000..8e6cb95 --- /dev/null +++ b/tests/unit/test_playwright_base.py @@ -0,0 +1,29 @@ +"""Unit tests for the Playwright base client.""" + +import pytest + +from psrt_ghsa_bot.polyfills.playwright_base import GitHubPlaywrightClient + + +@pytest.fixture +def client() -> GitHubPlaywrightClient: + """Create a GitHubPlaywrightClient instance for testing.""" + return GitHubPlaywrightClient(headless=True) + + +def test_client_context_manager() -> None: + """Test that the client works as a context manager.""" + with GitHubPlaywrightClient(headless=True) as client: + assert client.page is not None + assert client.context is not None + + +def test_client_start_and_close(client: GitHubPlaywrightClient) -> None: + """Test that the client can be started and closed manually.""" + client.start() + assert client.page is not None + assert client.context is not None + + client.close() + with pytest.raises(RuntimeError): + _ = client.page diff --git a/tests/unit/test_state.py b/tests/unit/test_state.py new file mode 100644 index 0000000..b11293d --- /dev/null +++ b/tests/unit/test_state.py @@ -0,0 +1,230 @@ +"""Tests for state management.""" + +import json +from datetime import UTC, datetime, timedelta +from pathlib import Path +from tempfile import TemporaryDirectory + +from psrt_ghsa_bot.state import BotState, GHSAState, StateManager + + +def test_ghsa_state_to_dict() -> None: + state = GHSAState( + last_processed_at="2025-11-19T12:00:00Z", + commands_processed_count=2, + ) + + data = state.to_dict() + + assert data["last_processed_at"] == "2025-11-19T12:00:00Z" + assert data["commands_processed_count"] == 2 + + +def test_ghsa_state_from_dict() -> None: + data = { + "last_processed_at": "2025-11-19T12:00:00Z", + "commands_processed_count": 2, + } + + state = GHSAState.from_dict(data) + + assert state.last_processed_at == "2025-11-19T12:00:00Z" + assert state.commands_processed_count == 2 + + +def test_bot_state_to_dict() -> None: + bot_state = BotState( + last_run="2025-11-19T12:00:00Z", + ghsas={ + "python/cpython/GHSA-xxxx": GHSAState( + last_processed_at="2025-11-19T11:00:00Z", + commands_processed_count=1, + ) + }, + commands_to_reprocess=["comment-abc"], + ) + + data = bot_state.to_dict() + + assert data["last_run"] == "2025-11-19T12:00:00Z" + assert "python/cpython/GHSA-xxxx" in data["ghsas"] + assert data["ghsas"]["python/cpython/GHSA-xxxx"]["last_processed_at"] == "2025-11-19T11:00:00Z" + assert data["commands_to_reprocess"] == ["comment-abc"] + + +def test_bot_state_from_dict() -> None: + data = { + "last_run": "2025-11-19T12:00:00Z", + "ghsas": { + "python/cpython/GHSA-xxxx": { + "last_processed_at": "2025-11-19T11:00:00Z", + "commands_processed_count": 1, + } + }, + "commands_to_reprocess": ["comment-abc"], + } + + bot_state = BotState.from_dict(data) + + assert bot_state.last_run == "2025-11-19T12:00:00Z" + assert "python/cpython/GHSA-xxxx" in bot_state.ghsas + assert bot_state.ghsas["python/cpython/GHSA-xxxx"].last_processed_at == "2025-11-19T11:00:00Z" + assert bot_state.commands_to_reprocess == ["comment-abc"] + + +def test_state_manager_load_empty() -> None: + with TemporaryDirectory() as tmpdir: + state_file = Path(tmpdir) / "state.json" + manager = StateManager(state_file) + + state = manager.load() + + assert state.last_run is None + assert len(state.ghsas) == 0 + assert state.commands_to_reprocess == [] + + +def test_state_manager_save_and_load() -> None: + with TemporaryDirectory() as tmpdir: + state_file = Path(tmpdir) / "state.json" + manager = StateManager(state_file) + + state = manager.load() + state.ghsas["python/cpython/GHSA-xxxx"] = GHSAState(last_processed_at="2025-11-19T12:00:00Z") + + manager.save() + + with Path.open(state_file) as f: + data = json.load(f) + + assert "python/cpython/GHSA-xxxx" in data["ghsas"] + assert data["ghsas"]["python/cpython/GHSA-xxxx"]["last_processed_at"] == "2025-11-19T12:00:00Z" + + +def test_state_manager_get_ghsa_state() -> None: + with TemporaryDirectory() as tmpdir: + state_file = Path(tmpdir) / "state.json" + manager = StateManager(state_file) + + ghsa_state = manager.get_ghsa_state("python/cpython/GHSA-xxxx") + + assert ghsa_state.last_processed_at is None + assert ghsa_state.commands_processed_count == 0 + + +def test_state_manager_should_process_comment_new_ghsa() -> None: + with TemporaryDirectory() as tmpdir: + state_file = Path(tmpdir) / "state.json" + manager = StateManager(state_file) + + should_process = manager.should_process_comment( + "python/cpython/GHSA-xxxx", + "comment-123", + datetime.now(UTC), + ) + + assert should_process + + +def test_state_manager_should_process_comment_newer_than_last() -> None: + with TemporaryDirectory() as tmpdir: + state_file = Path(tmpdir) / "state.json" + manager = StateManager(state_file) + + ghsa_state = manager.get_ghsa_state("python/cpython/GHSA-xxxx") + ghsa_state.last_processed_at = (datetime.now(UTC) - timedelta(hours=1)).isoformat() + + should_process = manager.should_process_comment( + "python/cpython/GHSA-xxxx", + "comment-123", + datetime.now(UTC), + ) + + assert should_process + + +def test_state_manager_should_not_process_older_comment() -> None: + with TemporaryDirectory() as tmpdir: + state_file = Path(tmpdir) / "state.json" + manager = StateManager(state_file) + + ghsa_state = manager.get_ghsa_state("python/cpython/GHSA-xxxx") + ghsa_state.last_processed_at = datetime.now(UTC).isoformat() + + old_comment_time = datetime.now(UTC) - timedelta(hours=1) + should_process = manager.should_process_comment( + "python/cpython/GHSA-xxxx", + "comment-123", + old_comment_time, + ) + + assert not should_process + + +def test_state_manager_should_process_comment_in_reprocess_list() -> None: + with TemporaryDirectory() as tmpdir: + state_file = Path(tmpdir) / "state.json" + manager = StateManager(state_file) + + ghsa_state = manager.get_ghsa_state("python/cpython/GHSA-xxxx") + ghsa_state.last_processed_at = datetime.now(UTC).isoformat() + + manager.add_command_to_reprocess("comment-123") + + old_comment_time = datetime.now(UTC) - timedelta(hours=1) + should_process = manager.should_process_comment( + "python/cpython/GHSA-xxxx", + "comment-123", + old_comment_time, + ) + + assert should_process + + +def test_state_manager_mark_command_processed() -> None: + with TemporaryDirectory() as tmpdir: + state_file = Path(tmpdir) / "state.json" + manager = StateManager(state_file) + + manager.mark_command_processed("python/cpython/GHSA-xxxx", "comment-123") + + ghsa_state = manager.get_ghsa_state("python/cpython/GHSA-xxxx") + assert ghsa_state.commands_processed_count == 1 + assert ghsa_state.last_processed_at is not None + + +def test_state_manager_mark_command_removes_from_reprocess() -> None: + with TemporaryDirectory() as tmpdir: + state_file = Path(tmpdir) / "state.json" + manager = StateManager(state_file) + + manager.add_command_to_reprocess("comment-123") + assert "comment-123" in manager.get_commands_to_reprocess() + + manager.mark_command_processed("python/cpython/GHSA-xxxx", "comment-123") + + assert "comment-123" not in manager.get_commands_to_reprocess() + + +def test_state_manager_update_ghsa_state() -> None: + with TemporaryDirectory() as tmpdir: + state_file = Path(tmpdir) / "state.json" + manager = StateManager(state_file) + + manager.update_ghsa_state("python/cpython/GHSA-xxxx") + + ghsa_state = manager.get_ghsa_state("python/cpython/GHSA-xxxx") + assert ghsa_state.last_processed_at is not None + + +def test_state_manager_add_command_to_reprocess() -> None: + with TemporaryDirectory() as tmpdir: + state_file = Path(tmpdir) / "state.json" + manager = StateManager(state_file) + + manager.add_command_to_reprocess("comment-123") + manager.add_command_to_reprocess("comment-456") + manager.add_command_to_reprocess("comment-123") + + reprocess_list = manager.get_commands_to_reprocess() + assert reprocess_list == ["comment-123", "comment-456"] diff --git a/uv.lock b/uv.lock index 7a561a2..b1ec4ad 100644 --- a/uv.lock +++ b/uv.lock @@ -47,11 +47,11 @@ wheels = [ [[package]] name = "certifi" -version = "2025.10.5" +version = "2025.11.12" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, ] [[package]] @@ -114,14 +114,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.0" +version = "8.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] [[package]] @@ -205,7 +205,7 @@ wheels = [ [[package]] name = "githubkit" -version = "0.13.5" +version = "0.13.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -214,9 +214,9 @@ dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3e/c2/4cdff48431a373bfa9e01c1ccfdb2f1b8f3fd27860a3be4001b0122d4a26/githubkit-0.13.5.tar.gz", hash = "sha256:d47f1aea19473a8aaef6a6413debfa420740f0207de42d32e98758cb6728fb12", size = 2286056, upload-time = "2025-10-25T16:12:42.415Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/74/a61ac8110585951a21bdc0ca39b0a41cd069a5c6d9e3a0cf607519fe30d2/githubkit-0.13.6.tar.gz", hash = "sha256:77f8f59bedbd503d1b581e5a93d993416467ee534f8ce7bd36e75282a8f51785", size = 2621144, upload-time = "2025-11-09T10:06:10.691Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/45/c765290bf71f32c545829495924b2ecf058166385dec8af3c108bdc6617f/githubkit-0.13.5-py3-none-any.whl", hash = "sha256:665d67f90258044980585cc71e1e19d78e755b0efea69728520373cfc8d28f86", size = 6046859, upload-time = "2025-10-25T16:12:40.625Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/6c87f4ba88af118712e90e90b949708b6296aa75fedf4fa3d8c54fb73a09/githubkit-0.13.6-py3-none-any.whl", hash = "sha256:8fe3c93365d04039479db347b122ffedc3812e74ea2a3f6e745f2d0039e727e2", size = 6360763, upload-time = "2025-11-09T10:06:09.126Z" }, ] [package.optional-dependencies] @@ -385,21 +385,21 @@ wheels = [ [[package]] name = "playwright" -version = "1.55.0" +version = "1.56.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet" }, { name = "pyee" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/80/3a/c81ff76df266c62e24f19718df9c168f49af93cabdbc4608ae29656a9986/playwright-1.55.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:d7da108a95001e412effca4f7610de79da1637ccdf670b1ae3fdc08b9694c034", size = 40428109, upload-time = "2025-08-28T15:46:20.357Z" }, - { url = "https://files.pythonhosted.org/packages/cf/f5/bdb61553b20e907196a38d864602a9b4a461660c3a111c67a35179b636fa/playwright-1.55.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8290cf27a5d542e2682ac274da423941f879d07b001f6575a5a3a257b1d4ba1c", size = 38687254, upload-time = "2025-08-28T15:46:23.925Z" }, - { url = "https://files.pythonhosted.org/packages/4a/64/48b2837ef396487807e5ab53c76465747e34c7143fac4a084ef349c293a8/playwright-1.55.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:25b0d6b3fd991c315cca33c802cf617d52980108ab8431e3e1d37b5de755c10e", size = 40428108, upload-time = "2025-08-28T15:46:27.119Z" }, - { url = "https://files.pythonhosted.org/packages/08/33/858312628aa16a6de97839adc2ca28031ebc5391f96b6fb8fdf1fcb15d6c/playwright-1.55.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c6d4d8f6f8c66c483b0835569c7f0caa03230820af8e500c181c93509c92d831", size = 45905643, upload-time = "2025-08-28T15:46:30.312Z" }, - { url = "https://files.pythonhosted.org/packages/83/83/b8d06a5b5721931aa6d5916b83168e28bd891f38ff56fe92af7bdee9860f/playwright-1.55.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29a0777c4ce1273acf90c87e4ae2fe0130182100d99bcd2ae5bf486093044838", size = 45296647, upload-time = "2025-08-28T15:46:33.221Z" }, - { url = "https://files.pythonhosted.org/packages/06/2e/9db64518aebcb3d6ef6cd6d4d01da741aff912c3f0314dadb61226c6a96a/playwright-1.55.0-py3-none-win32.whl", hash = "sha256:29e6d1558ad9d5b5c19cbec0a72f6a2e35e6353cd9f262e22148685b86759f90", size = 35476046, upload-time = "2025-08-28T15:46:36.184Z" }, - { url = "https://files.pythonhosted.org/packages/46/4f/9ba607fa94bb9cee3d4beb1c7b32c16efbfc9d69d5037fa85d10cafc618b/playwright-1.55.0-py3-none-win_amd64.whl", hash = "sha256:7eb5956473ca1951abb51537e6a0da55257bb2e25fc37c2b75af094a5c93736c", size = 35476048, upload-time = "2025-08-28T15:46:38.867Z" }, - { url = "https://files.pythonhosted.org/packages/21/98/5ca173c8ec906abde26c28e1ecb34887343fd71cc4136261b90036841323/playwright-1.55.0-py3-none-win_arm64.whl", hash = "sha256:012dc89ccdcbd774cdde8aeee14c08e0dd52ddb9135bf10e9db040527386bd76", size = 31225543, upload-time = "2025-08-28T15:46:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/6b/31/a5362cee43f844509f1f10d8a27c9cc0e2f7bdce5353d304d93b2151c1b1/playwright-1.56.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:b33eb89c516cbc6723f2e3523bada4a4eb0984a9c411325c02d7016a5d625e9c", size = 40611424, upload-time = "2025-11-11T18:39:10.175Z" }, + { url = "https://files.pythonhosted.org/packages/ef/95/347eef596d8778fb53590dc326c344d427fa19ba3d42b646fce2a4572eb3/playwright-1.56.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b228b3395212b9472a4ee5f1afe40d376eef9568eb039fcb3e563de8f4f4657b", size = 39400228, upload-time = "2025-11-11T18:39:13.915Z" }, + { url = "https://files.pythonhosted.org/packages/b9/54/6ad97b08b2ca1dfcb4fbde4536c4f45c0d9d8b1857a2d20e7bbfdf43bf15/playwright-1.56.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:0ef7e6fd653267798a8a968ff7aa2dcac14398b7dd7440ef57524e01e0fbbd65", size = 40611424, upload-time = "2025-11-11T18:39:17.093Z" }, + { url = "https://files.pythonhosted.org/packages/e4/76/6d409e37e82cdd5dda3df1ab958130ae32b46e42458bd4fc93d7eb8749cb/playwright-1.56.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:404be089b49d94bc4c1fe0dfb07664bda5ffe87789034a03bffb884489bdfb5c", size = 46263122, upload-time = "2025-11-11T18:39:20.619Z" }, + { url = "https://files.pythonhosted.org/packages/4f/84/fb292cc5d45f3252e255ea39066cd1d2385c61c6c1596548dfbf59c88605/playwright-1.56.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64cda7cf4e51c0d35dab55190841bfcdfb5871685ec22cb722cd0ad2df183e34", size = 46110645, upload-time = "2025-11-11T18:39:24.005Z" }, + { url = "https://files.pythonhosted.org/packages/61/bd/8c02c3388ae14edc374ac9f22cbe4e14826c6a51b2d8eaf86e89fabee264/playwright-1.56.0-py3-none-win32.whl", hash = "sha256:d87b79bcb082092d916a332c27ec9732e0418c319755d235d93cc6be13bdd721", size = 35639837, upload-time = "2025-11-11T18:39:27.174Z" }, + { url = "https://files.pythonhosted.org/packages/64/27/f13b538fbc6b7a00152f4379054a49f6abc0bf55ac86f677ae54bc49fb82/playwright-1.56.0-py3-none-win_amd64.whl", hash = "sha256:3c7fc49bb9e673489bf2622855f9486d41c5101bbed964638552b864c4591f94", size = 35639843, upload-time = "2025-11-11T18:39:30.851Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c7/3ee8b556107995846576b4fe42a08ed49b8677619421f2afacf6ee421138/playwright-1.56.0-py3-none-win_arm64.whl", hash = "sha256:2745490ae8dd58d27e5ea4d9aa28402e8e2991eb84fb4b2fd5fbde2106716f6f", size = 31248959, upload-time = "2025-11-11T18:39:33.998Z" }, ] [[package]] @@ -419,7 +419,9 @@ dependencies = [ { name = "cvelib" }, { name = "githubkit", extra = ["auth-app"] }, { name = "playwright" }, + { name = "pyotp" }, { name = "python-dotenv" }, + { name = "sentry-sdk" }, ] [package.dev-dependencies] @@ -437,7 +439,9 @@ requires-dist = [ { name = "cvelib", specifier = ">=1.4.0" }, { name = "githubkit", extras = ["auth-app"], specifier = ">=0.13.5" }, { name = "playwright", specifier = ">=1.55.0" }, + { name = "pyotp", specifier = ">=2.9.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "sentry-sdk", specifier = ">=2.22.0" }, ] [package.metadata.requires-dev] @@ -461,7 +465,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.12.3" +version = "2.12.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -469,39 +473,48 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/1e/4f0a3233767010308f2fd6bd0814597e3f63f1dc98304a9112b8759df4ff/pydantic-2.12.3.tar.gz", hash = "sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74", size = 819383, upload-time = "2025-10-17T15:04:21.222Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/6b/83661fa77dcefa195ad5f8cd9af3d1a7450fd57cc883ad04d65446ac2029/pydantic-2.12.3-py3-none-any.whl", hash = "sha256:6986454a854bc3bc6e5443e1369e06a3a456af9d339eda45510f517d9ea5c6bf", size = 462431, upload-time = "2025-10-17T15:04:19.346Z" }, + { url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" }, ] [[package]] name = "pydantic-core" -version = "2.41.4" +version = "2.41.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/18/d0944e8eaaa3efd0a91b0f1fc537d3be55ad35091b6a87638211ba691964/pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5", size = 457557, upload-time = "2025-10-14T10:23:47.909Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/28/d3325da57d413b9819365546eb9a6e8b7cbd9373d9380efd5f74326143e6/pydantic_core-2.41.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1", size = 2102022, upload-time = "2025-10-14T10:21:32.809Z" }, - { url = "https://files.pythonhosted.org/packages/9e/24/b58a1bc0d834bf1acc4361e61233ee217169a42efbdc15a60296e13ce438/pydantic_core-2.41.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac", size = 1905495, upload-time = "2025-10-14T10:21:34.812Z" }, - { url = "https://files.pythonhosted.org/packages/fb/a4/71f759cc41b7043e8ecdaab81b985a9b6cad7cec077e0b92cff8b71ecf6b/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554", size = 1956131, upload-time = "2025-10-14T10:21:36.924Z" }, - { url = "https://files.pythonhosted.org/packages/b0/64/1e79ac7aa51f1eec7c4cda8cbe456d5d09f05fdd68b32776d72168d54275/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e", size = 2052236, upload-time = "2025-10-14T10:21:38.927Z" }, - { url = "https://files.pythonhosted.org/packages/e9/e3/a3ffc363bd4287b80f1d43dc1c28ba64831f8dfc237d6fec8f2661138d48/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616", size = 2223573, upload-time = "2025-10-14T10:21:41.574Z" }, - { url = "https://files.pythonhosted.org/packages/28/27/78814089b4d2e684a9088ede3790763c64693c3d1408ddc0a248bc789126/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af", size = 2342467, upload-time = "2025-10-14T10:21:44.018Z" }, - { url = "https://files.pythonhosted.org/packages/92/97/4de0e2a1159cb85ad737e03306717637842c88c7fd6d97973172fb183149/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12", size = 2063754, upload-time = "2025-10-14T10:21:46.466Z" }, - { url = "https://files.pythonhosted.org/packages/0f/50/8cb90ce4b9efcf7ae78130afeb99fd1c86125ccdf9906ef64b9d42f37c25/pydantic_core-2.41.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d", size = 2196754, upload-time = "2025-10-14T10:21:48.486Z" }, - { url = "https://files.pythonhosted.org/packages/34/3b/ccdc77af9cd5082723574a1cc1bcae7a6acacc829d7c0a06201f7886a109/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad", size = 2137115, upload-time = "2025-10-14T10:21:50.63Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ba/e7c7a02651a8f7c52dc2cff2b64a30c313e3b57c7d93703cecea76c09b71/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a", size = 2317400, upload-time = "2025-10-14T10:21:52.959Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ba/6c533a4ee8aec6b812c643c49bb3bd88d3f01e3cebe451bb85512d37f00f/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025", size = 2312070, upload-time = "2025-10-14T10:21:55.419Z" }, - { url = "https://files.pythonhosted.org/packages/22/ae/f10524fcc0ab8d7f96cf9a74c880243576fd3e72bd8ce4f81e43d22bcab7/pydantic_core-2.41.4-cp314-cp314-win32.whl", hash = "sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e", size = 1982277, upload-time = "2025-10-14T10:21:57.474Z" }, - { url = "https://files.pythonhosted.org/packages/b4/dc/e5aa27aea1ad4638f0c3fb41132f7eb583bd7420ee63204e2d4333a3bbf9/pydantic_core-2.41.4-cp314-cp314-win_amd64.whl", hash = "sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894", size = 2024608, upload-time = "2025-10-14T10:21:59.557Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/51d89cc2612bd147198e120a13f150afbf0bcb4615cddb049ab10b81b79e/pydantic_core-2.41.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d", size = 1967614, upload-time = "2025-10-14T10:22:01.847Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c2/472f2e31b95eff099961fa050c376ab7156a81da194f9edb9f710f68787b/pydantic_core-2.41.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da", size = 1876904, upload-time = "2025-10-14T10:22:04.062Z" }, - { url = "https://files.pythonhosted.org/packages/4a/07/ea8eeb91173807ecdae4f4a5f4b150a520085b35454350fc219ba79e66a3/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e", size = 1882538, upload-time = "2025-10-14T10:22:06.39Z" }, - { url = "https://files.pythonhosted.org/packages/1e/29/b53a9ca6cd366bfc928823679c6a76c7a4c69f8201c0ba7903ad18ebae2f/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa", size = 2041183, upload-time = "2025-10-14T10:22:08.812Z" }, - { url = "https://files.pythonhosted.org/packages/c7/3d/f8c1a371ceebcaf94d6dd2d77c6cf4b1c078e13a5837aee83f760b4f7cfd/pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d", size = 1993542, upload-time = "2025-10-14T10:22:11.332Z" }, - { url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897, upload-time = "2025-10-14T10:22:13.444Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, ] [[package]] @@ -539,6 +552,15 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pyotp" +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/b2/1d5994ba2acde054a443bd5e2d384175449c7d2b6d1a0614dbca3a63abfc/pyotp-2.9.0.tar.gz", hash = "sha256:346b6642e0dbdde3b4ff5a930b664ca82abfa116356ed48cc42c7d6590d36f63", size = 17763, upload-time = "2023-07-27T23:41:03.295Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/c0/c33c8792c3e50193ef55adb95c1c3c2786fe281123291c2dbf0eaab95a6f/pyotp-2.9.0-py3-none-any.whl", hash = "sha256:81c2e5865b8ac55e825b0358e496e1d9387c811e85bb40e71a3b29b288963612", size = 13376, upload-time = "2023-07-27T23:41:01.685Z" }, +] + [[package]] name = "pytest" version = "8.4.2" @@ -647,65 +669,78 @@ wheels = [ [[package]] name = "rpds-py" -version = "0.28.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/48/dc/95f074d43452b3ef5d06276696ece4b3b5d696e7c9ad7173c54b1390cd70/rpds_py-0.28.0.tar.gz", hash = "sha256:abd4df20485a0983e2ca334a216249b6186d6e3c1627e106651943dbdb791aea", size = 27419, upload-time = "2025-10-22T22:24:29.327Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/08/47/ffe8cd7a6a02833b10623bf765fbb57ce977e9a4318ca0e8cf97e9c3d2b3/rpds_py-0.28.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:dcdcb890b3ada98a03f9f2bb108489cdc7580176cb73b4f2d789e9a1dac1d472", size = 353830, upload-time = "2025-10-22T22:23:17.03Z" }, - { url = "https://files.pythonhosted.org/packages/f9/9f/890f36cbd83a58491d0d91ae0db1702639edb33fb48eeb356f80ecc6b000/rpds_py-0.28.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f274f56a926ba2dc02976ca5b11c32855cbd5925534e57cfe1fda64e04d1add2", size = 341819, upload-time = "2025-10-22T22:23:18.57Z" }, - { url = "https://files.pythonhosted.org/packages/09/e3/921eb109f682aa24fb76207698fbbcf9418738f35a40c21652c29053f23d/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fe0438ac4a29a520ea94c8c7f1754cdd8feb1bc490dfda1bfd990072363d527", size = 373127, upload-time = "2025-10-22T22:23:20.216Z" }, - { url = "https://files.pythonhosted.org/packages/23/13/bce4384d9f8f4989f1a9599c71b7a2d877462e5fd7175e1f69b398f729f4/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8a358a32dd3ae50e933347889b6af9a1bdf207ba5d1a3f34e1a38cd3540e6733", size = 382767, upload-time = "2025-10-22T22:23:21.787Z" }, - { url = "https://files.pythonhosted.org/packages/23/e1/579512b2d89a77c64ccef5a0bc46a6ef7f72ae0cf03d4b26dcd52e57ee0a/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e80848a71c78aa328fefaba9c244d588a342c8e03bda518447b624ea64d1ff56", size = 517585, upload-time = "2025-10-22T22:23:23.699Z" }, - { url = "https://files.pythonhosted.org/packages/62/3c/ca704b8d324a2591b0b0adcfcaadf9c862375b11f2f667ac03c61b4fd0a6/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f586db2e209d54fe177e58e0bc4946bea5fb0102f150b1b2f13de03e1f0976f8", size = 399828, upload-time = "2025-10-22T22:23:25.713Z" }, - { url = "https://files.pythonhosted.org/packages/da/37/e84283b9e897e3adc46b4c88bb3f6ec92a43bd4d2f7ef5b13459963b2e9c/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ae8ee156d6b586e4292491e885d41483136ab994e719a13458055bec14cf370", size = 375509, upload-time = "2025-10-22T22:23:27.32Z" }, - { url = "https://files.pythonhosted.org/packages/1a/c2/a980beab869d86258bf76ec42dec778ba98151f253a952b02fe36d72b29c/rpds_py-0.28.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a805e9b3973f7e27f7cab63a6b4f61d90f2e5557cff73b6e97cd5b8540276d3d", size = 392014, upload-time = "2025-10-22T22:23:29.332Z" }, - { url = "https://files.pythonhosted.org/packages/da/b5/b1d3c5f9d3fa5aeef74265f9c64de3c34a0d6d5cd3c81c8b17d5c8f10ed4/rpds_py-0.28.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5d3fd16b6dc89c73a4da0b4ac8b12a7ecc75b2864b95c9e5afed8003cb50a728", size = 402410, upload-time = "2025-10-22T22:23:31.14Z" }, - { url = "https://files.pythonhosted.org/packages/74/ae/cab05ff08dfcc052afc73dcb38cbc765ffc86f94e966f3924cd17492293c/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6796079e5d24fdaba6d49bda28e2c47347e89834678f2bc2c1b4fc1489c0fb01", size = 553593, upload-time = "2025-10-22T22:23:32.834Z" }, - { url = "https://files.pythonhosted.org/packages/70/80/50d5706ea2a9bfc9e9c5f401d91879e7c790c619969369800cde202da214/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:76500820c2af232435cbe215e3324c75b950a027134e044423f59f5b9a1ba515", size = 576925, upload-time = "2025-10-22T22:23:34.47Z" }, - { url = "https://files.pythonhosted.org/packages/ab/12/85a57d7a5855a3b188d024b099fd09c90db55d32a03626d0ed16352413ff/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bbdc5640900a7dbf9dd707fe6388972f5bbd883633eb68b76591044cfe346f7e", size = 542444, upload-time = "2025-10-22T22:23:36.093Z" }, - { url = "https://files.pythonhosted.org/packages/6c/65/10643fb50179509150eb94d558e8837c57ca8b9adc04bd07b98e57b48f8c/rpds_py-0.28.0-cp314-cp314-win32.whl", hash = "sha256:adc8aa88486857d2b35d75f0640b949759f79dc105f50aa2c27816b2e0dd749f", size = 207968, upload-time = "2025-10-22T22:23:37.638Z" }, - { url = "https://files.pythonhosted.org/packages/b4/84/0c11fe4d9aaea784ff4652499e365963222481ac647bcd0251c88af646eb/rpds_py-0.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:66e6fa8e075b58946e76a78e69e1a124a21d9a48a5b4766d15ba5b06869d1fa1", size = 218876, upload-time = "2025-10-22T22:23:39.179Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e0/3ab3b86ded7bb18478392dc3e835f7b754cd446f62f3fc96f4fe2aca78f6/rpds_py-0.28.0-cp314-cp314-win_arm64.whl", hash = "sha256:a6fe887c2c5c59413353b7c0caff25d0e566623501ccfff88957fa438a69377d", size = 212506, upload-time = "2025-10-22T22:23:40.755Z" }, - { url = "https://files.pythonhosted.org/packages/51/ec/d5681bb425226c3501eab50fc30e9d275de20c131869322c8a1729c7b61c/rpds_py-0.28.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7a69df082db13c7070f7b8b1f155fa9e687f1d6aefb7b0e3f7231653b79a067b", size = 355433, upload-time = "2025-10-22T22:23:42.259Z" }, - { url = "https://files.pythonhosted.org/packages/be/ec/568c5e689e1cfb1ea8b875cffea3649260955f677fdd7ddc6176902d04cd/rpds_py-0.28.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b1cde22f2c30ebb049a9e74c5374994157b9b70a16147d332f89c99c5960737a", size = 342601, upload-time = "2025-10-22T22:23:44.372Z" }, - { url = "https://files.pythonhosted.org/packages/32/fe/51ada84d1d2a1d9d8f2c902cfddd0133b4a5eb543196ab5161d1c07ed2ad/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5338742f6ba7a51012ea470bd4dc600a8c713c0c72adaa0977a1b1f4327d6592", size = 372039, upload-time = "2025-10-22T22:23:46.025Z" }, - { url = "https://files.pythonhosted.org/packages/07/c1/60144a2f2620abade1a78e0d91b298ac2d9b91bc08864493fa00451ef06e/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e1460ebde1bcf6d496d80b191d854adedcc619f84ff17dc1c6d550f58c9efbba", size = 382407, upload-time = "2025-10-22T22:23:48.098Z" }, - { url = "https://files.pythonhosted.org/packages/45/ed/091a7bbdcf4038a60a461df50bc4c82a7ed6d5d5e27649aab61771c17585/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e3eb248f2feba84c692579257a043a7699e28a77d86c77b032c1d9fbb3f0219c", size = 518172, upload-time = "2025-10-22T22:23:50.16Z" }, - { url = "https://files.pythonhosted.org/packages/54/dd/02cc90c2fd9c2ef8016fd7813bfacd1c3a1325633ec8f244c47b449fc868/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3bbba5def70b16cd1c1d7255666aad3b290fbf8d0fe7f9f91abafb73611a91", size = 399020, upload-time = "2025-10-22T22:23:51.81Z" }, - { url = "https://files.pythonhosted.org/packages/ab/81/5d98cc0329bbb911ccecd0b9e19fbf7f3a5de8094b4cda5e71013b2dd77e/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3114f4db69ac5a1f32e7e4d1cbbe7c8f9cf8217f78e6e002cedf2d54c2a548ed", size = 377451, upload-time = "2025-10-22T22:23:53.711Z" }, - { url = "https://files.pythonhosted.org/packages/b4/07/4d5bcd49e3dfed2d38e2dcb49ab6615f2ceb9f89f5a372c46dbdebb4e028/rpds_py-0.28.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4b0cb8a906b1a0196b863d460c0222fb8ad0f34041568da5620f9799b83ccf0b", size = 390355, upload-time = "2025-10-22T22:23:55.299Z" }, - { url = "https://files.pythonhosted.org/packages/3f/79/9f14ba9010fee74e4f40bf578735cfcbb91d2e642ffd1abe429bb0b96364/rpds_py-0.28.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cf681ac76a60b667106141e11a92a3330890257e6f559ca995fbb5265160b56e", size = 403146, upload-time = "2025-10-22T22:23:56.929Z" }, - { url = "https://files.pythonhosted.org/packages/39/4c/f08283a82ac141331a83a40652830edd3a4a92c34e07e2bbe00baaea2f5f/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1e8ee6413cfc677ce8898d9cde18cc3a60fc2ba756b0dec5b71eb6eb21c49fa1", size = 552656, upload-time = "2025-10-22T22:23:58.62Z" }, - { url = "https://files.pythonhosted.org/packages/61/47/d922fc0666f0dd8e40c33990d055f4cc6ecff6f502c2d01569dbed830f9b/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b3072b16904d0b5572a15eb9d31c1954e0d3227a585fc1351aa9878729099d6c", size = 576782, upload-time = "2025-10-22T22:24:00.312Z" }, - { url = "https://files.pythonhosted.org/packages/d3/0c/5bafdd8ccf6aa9d3bfc630cfece457ff5b581af24f46a9f3590f790e3df2/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b670c30fd87a6aec281c3c9896d3bae4b205fd75d79d06dc87c2503717e46092", size = 544671, upload-time = "2025-10-22T22:24:02.297Z" }, - { url = "https://files.pythonhosted.org/packages/2c/37/dcc5d8397caa924988693519069d0beea077a866128719351a4ad95e82fc/rpds_py-0.28.0-cp314-cp314t-win32.whl", hash = "sha256:8014045a15b4d2b3476f0a287fcc93d4f823472d7d1308d47884ecac9e612be3", size = 205749, upload-time = "2025-10-22T22:24:03.848Z" }, - { url = "https://files.pythonhosted.org/packages/d7/69/64d43b21a10d72b45939a28961216baeb721cc2a430f5f7c3bfa21659a53/rpds_py-0.28.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7a4e59c90d9c27c561eb3160323634a9ff50b04e4f7820600a2beb0ac90db578", size = 216233, upload-time = "2025-10-22T22:24:05.471Z" }, +version = "0.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/33/23b3b3419b6a3e0f559c7c0d2ca8fc1b9448382b25245033788785921332/rpds_py-0.29.0.tar.gz", hash = "sha256:fe55fe686908f50154d1dc599232016e50c243b438c3b7432f24e2895b0e5359", size = 69359, upload-time = "2025-11-16T14:50:39.532Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/b1/0b1474e7899371d9540d3bbb2a499a3427ae1fc39c998563fe9035a1073b/rpds_py-0.29.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:394d27e4453d3b4d82bb85665dc1fcf4b0badc30fc84282defed71643b50e1a1", size = 363731, upload-time = "2025-11-16T14:49:26.683Z" }, + { url = "https://files.pythonhosted.org/packages/28/12/3b7cf2068d0a334ed1d7b385a9c3c8509f4c2bcba3d4648ea71369de0881/rpds_py-0.29.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55d827b2ae95425d3be9bc9a5838b6c29d664924f98146557f7715e331d06df8", size = 354343, upload-time = "2025-11-16T14:49:28.24Z" }, + { url = "https://files.pythonhosted.org/packages/eb/73/5afcf8924bc02a749416eda64e17ac9c9b28f825f4737385295a0e99b0c1/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc31a07ed352e5462d3ee1b22e89285f4ce97d5266f6d1169da1142e78045626", size = 385406, upload-time = "2025-11-16T14:49:29.943Z" }, + { url = "https://files.pythonhosted.org/packages/c8/37/5db736730662508535221737a21563591b6f43c77f2e388951c42f143242/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c4695dd224212f6105db7ea62197144230b808d6b2bba52238906a2762f1d1e7", size = 396162, upload-time = "2025-11-16T14:49:31.833Z" }, + { url = "https://files.pythonhosted.org/packages/70/0d/491c1017d14f62ce7bac07c32768d209a50ec567d76d9f383b4cfad19b80/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcae1770b401167f8b9e1e3f566562e6966ffa9ce63639916248a9e25fa8a244", size = 517719, upload-time = "2025-11-16T14:49:33.804Z" }, + { url = "https://files.pythonhosted.org/packages/d7/25/b11132afcb17cd5d82db173f0c8dab270ffdfaba43e5ce7a591837ae9649/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:90f30d15f45048448b8da21c41703b31c61119c06c216a1bf8c245812a0f0c17", size = 409498, upload-time = "2025-11-16T14:49:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7d/e6543cedfb2e6403a1845710a5ab0e0ccf8fc288e0b5af9a70bfe2c12053/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44a91e0ab77bdc0004b43261a4b8cd6d6b451e8d443754cfda830002b5745b32", size = 382743, upload-time = "2025-11-16T14:49:36.704Z" }, + { url = "https://files.pythonhosted.org/packages/75/11/a4ebc9f654293ae9fefb83b2b6be7f3253e85ea42a5db2f77d50ad19aaeb/rpds_py-0.29.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:4aa195e5804d32c682e453b34474f411ca108e4291c6a0f824ebdc30a91c973c", size = 400317, upload-time = "2025-11-16T14:49:39.132Z" }, + { url = "https://files.pythonhosted.org/packages/52/18/97677a60a81c7f0e5f64e51fb3f8271c5c8fcabf3a2df18e97af53d7c2bf/rpds_py-0.29.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7971bdb7bf4ee0f7e6f67fa4c7fbc6019d9850cc977d126904392d363f6f8318", size = 416979, upload-time = "2025-11-16T14:49:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/28ab391a9968f6c746b2a2db181eaa4d16afaa859fedc9c2f682d19f7e18/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8ae33ad9ce580c7a47452c3b3f7d8a9095ef6208e0a0c7e4e2384f9fc5bf8212", size = 567288, upload-time = "2025-11-16T14:49:42.24Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d3/0c7afdcdb830eee94f5611b64e71354ffe6ac8df82d00c2faf2bfffd1d4e/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c661132ab2fb4eeede2ef69670fd60da5235209874d001a98f1542f31f2a8a94", size = 593157, upload-time = "2025-11-16T14:49:43.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ac/a0fcbc2feed4241cf26d32268c195eb88ddd4bd862adfc9d4b25edfba535/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb78b3a0d31ac1bde132c67015a809948db751cb4e92cdb3f0b242e430b6ed0d", size = 554741, upload-time = "2025-11-16T14:49:45.557Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f1/fcc24137c470df8588674a677f33719d5800ec053aaacd1de8a5d5d84d9e/rpds_py-0.29.0-cp314-cp314-win32.whl", hash = "sha256:f475f103488312e9bd4000bc890a95955a07b2d0b6e8884aef4be56132adbbf1", size = 215508, upload-time = "2025-11-16T14:49:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c7/1d169b2045512eac019918fc1021ea07c30e84a4343f9f344e3e0aa8c788/rpds_py-0.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:b9cf2359a4fca87cfb6801fae83a76aedf66ee1254a7a151f1341632acf67f1b", size = 228125, upload-time = "2025-11-16T14:49:49.064Z" }, + { url = "https://files.pythonhosted.org/packages/be/36/0cec88aaba70ec4a6e381c444b0d916738497d27f0c30406e3d9fcbd3bc2/rpds_py-0.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:9ba8028597e824854f0f1733d8b964e914ae3003b22a10c2c664cb6927e0feb9", size = 221992, upload-time = "2025-11-16T14:49:50.777Z" }, + { url = "https://files.pythonhosted.org/packages/b1/fa/a2e524631717c9c0eb5d90d30f648cfba6b731047821c994acacb618406c/rpds_py-0.29.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e71136fd0612556b35c575dc2726ae04a1669e6a6c378f2240312cf5d1a2ab10", size = 366425, upload-time = "2025-11-16T14:49:52.691Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/6d43ebe0746ff694a30233f63f454aed1677bd50ab7a59ff6b2bb5ac61f2/rpds_py-0.29.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:76fe96632d53f3bf0ea31ede2f53bbe3540cc2736d4aec3b3801b0458499ef3a", size = 355282, upload-time = "2025-11-16T14:49:54.292Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a7/52fd8270e0320b09eaf295766ae81dd175f65394687906709b3e75c71d06/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9459a33f077130dbb2c7c3cea72ee9932271fb3126404ba2a2661e4fe9eb7b79", size = 384968, upload-time = "2025-11-16T14:49:55.857Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/e6bc526b7a14e1ef80579a52c1d4ad39260a058a51d66c6039035d14db9d/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5c9546cfdd5d45e562cc0444b6dddc191e625c62e866bf567a2c69487c7ad28a", size = 394714, upload-time = "2025-11-16T14:49:57.343Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3f/f0ade3954e7db95c791e7eaf978aa7e08a756d2046e8bdd04d08146ed188/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12597d11d97b8f7e376c88929a6e17acb980e234547c92992f9f7c058f1a7310", size = 520136, upload-time = "2025-11-16T14:49:59.162Z" }, + { url = "https://files.pythonhosted.org/packages/87/b3/07122ead1b97009715ab9d4082be6d9bd9546099b2b03fae37c3116f72be/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28de03cf48b8a9e6ec10318f2197b83946ed91e2891f651a109611be4106ac4b", size = 409250, upload-time = "2025-11-16T14:50:00.698Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/dcbee61fd1dc892aedcb1b489ba661313101aa82ec84b1a015d4c63ebfda/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd7951c964069039acc9d67a8ff1f0a7f34845ae180ca542b17dc1456b1f1808", size = 384940, upload-time = "2025-11-16T14:50:02.312Z" }, + { url = "https://files.pythonhosted.org/packages/47/11/914ecb6f3574cf9bf8b38aced4063e0f787d6e1eb30b181a7efbc6c1da9a/rpds_py-0.29.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:c07d107b7316088f1ac0177a7661ca0c6670d443f6fe72e836069025e6266761", size = 399392, upload-time = "2025-11-16T14:50:03.829Z" }, + { url = "https://files.pythonhosted.org/packages/f5/fd/2f4bd9433f58f816434bb934313584caa47dbc6f03ce5484df8ac8980561/rpds_py-0.29.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1de2345af363d25696969befc0c1688a6cb5e8b1d32b515ef84fc245c6cddba3", size = 416796, upload-time = "2025-11-16T14:50:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/79/a5/449f0281af33efa29d5c71014399d74842342ae908d8cd38260320167692/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:00e56b12d2199ca96068057e1ae7f9998ab6e99cda82431afafd32f3ec98cca9", size = 566843, upload-time = "2025-11-16T14:50:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/ab/32/0a6a1ccee2e37fcb1b7ba9afde762b77182dbb57937352a729c6cd3cf2bb/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3919a3bbecee589300ed25000b6944174e07cd20db70552159207b3f4bbb45b8", size = 593956, upload-time = "2025-11-16T14:50:09.029Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3d/eb820f95dce4306f07a495ede02fb61bef36ea201d9137d4fcd5ab94ec1e/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7fa2ccc312bbd91e43aa5e0869e46bc03278a3dddb8d58833150a18b0f0283a", size = 557288, upload-time = "2025-11-16T14:50:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f8/b8ff786f40470462a252918e0836e0db903c28e88e3eec66bc4a7856ee5d/rpds_py-0.29.0-cp314-cp314t-win32.whl", hash = "sha256:97c817863ffc397f1e6a6e9d2d89fe5408c0a9922dac0329672fb0f35c867ea5", size = 211382, upload-time = "2025-11-16T14:50:12.827Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7f/1a65ae870bc9d0576aebb0c501ea5dccf1ae2178fe2821042150ebd2e707/rpds_py-0.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2023473f444752f0f82a58dfcbee040d0a1b3d1b3c2ec40e884bd25db6d117d2", size = 225919, upload-time = "2025-11-16T14:50:14.734Z" }, ] [[package]] name = "ruff" -version = "0.14.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/75/62/50b7727004dfe361104dfbf898c45a9a2fdfad8c72c04ae62900224d6ecf/ruff-0.14.3.tar.gz", hash = "sha256:4ff876d2ab2b161b6de0aa1f5bd714e8e9b4033dc122ee006925fbacc4f62153", size = 5558687, upload-time = "2025-10-31T00:26:26.878Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/8e/0c10ff1ea5d4360ab8bfca4cb2c9d979101a391f3e79d2616c9bf348cd26/ruff-0.14.3-py3-none-linux_armv6l.whl", hash = "sha256:876b21e6c824f519446715c1342b8e60f97f93264012de9d8d10314f8a79c371", size = 12535613, upload-time = "2025-10-31T00:25:44.302Z" }, - { url = "https://files.pythonhosted.org/packages/d3/c8/6724f4634c1daf52409fbf13fefda64aa9c8f81e44727a378b7b73dc590b/ruff-0.14.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b6fd8c79b457bedd2abf2702b9b472147cd860ed7855c73a5247fa55c9117654", size = 12855812, upload-time = "2025-10-31T00:25:47.793Z" }, - { url = "https://files.pythonhosted.org/packages/de/03/db1bce591d55fd5f8a08bb02517fa0b5097b2ccabd4ea1ee29aa72b67d96/ruff-0.14.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:71ff6edca490c308f083156938c0c1a66907151263c4abdcb588602c6e696a14", size = 11944026, upload-time = "2025-10-31T00:25:49.657Z" }, - { url = "https://files.pythonhosted.org/packages/0b/75/4f8dbd48e03272715d12c87dc4fcaaf21b913f0affa5f12a4e9c6f8a0582/ruff-0.14.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:786ee3ce6139772ff9272aaf43296d975c0217ee1b97538a98171bf0d21f87ed", size = 12356818, upload-time = "2025-10-31T00:25:51.949Z" }, - { url = "https://files.pythonhosted.org/packages/ec/9b/506ec5b140c11d44a9a4f284ea7c14ebf6f8b01e6e8917734a3325bff787/ruff-0.14.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cd6291d0061811c52b8e392f946889916757610d45d004e41140d81fb6cd5ddc", size = 12336745, upload-time = "2025-10-31T00:25:54.248Z" }, - { url = "https://files.pythonhosted.org/packages/c7/e1/c560d254048c147f35e7f8131d30bc1f63a008ac61595cf3078a3e93533d/ruff-0.14.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a497ec0c3d2c88561b6d90f9c29f5ae68221ac00d471f306fa21fa4264ce5fcd", size = 13101684, upload-time = "2025-10-31T00:25:56.253Z" }, - { url = "https://files.pythonhosted.org/packages/a5/32/e310133f8af5cd11f8cc30f52522a3ebccc5ea5bff4b492f94faceaca7a8/ruff-0.14.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e231e1be58fc568950a04fbe6887c8e4b85310e7889727e2b81db205c45059eb", size = 14535000, upload-time = "2025-10-31T00:25:58.397Z" }, - { url = "https://files.pythonhosted.org/packages/a2/a1/7b0470a22158c6d8501eabc5e9b6043c99bede40fa1994cadf6b5c2a61c7/ruff-0.14.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:469e35872a09c0e45fecf48dd960bfbce056b5db2d5e6b50eca329b4f853ae20", size = 14156450, upload-time = "2025-10-31T00:26:00.889Z" }, - { url = "https://files.pythonhosted.org/packages/0a/96/24bfd9d1a7f532b560dcee1a87096332e461354d3882124219bcaff65c09/ruff-0.14.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d6bc90307c469cb9d28b7cfad90aaa600b10d67c6e22026869f585e1e8a2db0", size = 13568414, upload-time = "2025-10-31T00:26:03.291Z" }, - { url = "https://files.pythonhosted.org/packages/a7/e7/138b883f0dfe4ad5b76b58bf4ae675f4d2176ac2b24bdd81b4d966b28c61/ruff-0.14.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2f8a0bbcffcfd895df39c9a4ecd59bb80dca03dc43f7fb63e647ed176b741e", size = 13315293, upload-time = "2025-10-31T00:26:05.708Z" }, - { url = "https://files.pythonhosted.org/packages/33/f4/c09bb898be97b2eb18476b7c950df8815ef14cf956074177e9fbd40b7719/ruff-0.14.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:678fdd7c7d2d94851597c23ee6336d25f9930b460b55f8598e011b57c74fd8c5", size = 13539444, upload-time = "2025-10-31T00:26:08.09Z" }, - { url = "https://files.pythonhosted.org/packages/9c/aa/b30a1db25fc6128b1dd6ff0741fa4abf969ded161599d07ca7edd0739cc0/ruff-0.14.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1ec1ac071e7e37e0221d2f2dbaf90897a988c531a8592a6a5959f0603a1ecf5e", size = 12252581, upload-time = "2025-10-31T00:26:10.297Z" }, - { url = "https://files.pythonhosted.org/packages/da/13/21096308f384d796ffe3f2960b17054110a9c3828d223ca540c2b7cc670b/ruff-0.14.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afcdc4b5335ef440d19e7df9e8ae2ad9f749352190e96d481dc501b753f0733e", size = 12307503, upload-time = "2025-10-31T00:26:12.646Z" }, - { url = "https://files.pythonhosted.org/packages/cb/cc/a350bac23f03b7dbcde3c81b154706e80c6f16b06ff1ce28ed07dc7b07b0/ruff-0.14.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7bfc42f81862749a7136267a343990f865e71fe2f99cf8d2958f684d23ce3dfa", size = 12675457, upload-time = "2025-10-31T00:26:15.044Z" }, - { url = "https://files.pythonhosted.org/packages/cb/76/46346029fa2f2078826bc88ef7167e8c198e58fe3126636e52f77488cbba/ruff-0.14.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a65e448cfd7e9c59fae8cf37f9221585d3354febaad9a07f29158af1528e165f", size = 13403980, upload-time = "2025-10-31T00:26:17.81Z" }, - { url = "https://files.pythonhosted.org/packages/9f/a4/35f1ef68c4e7b236d4a5204e3669efdeefaef21f0ff6a456792b3d8be438/ruff-0.14.3-py3-none-win32.whl", hash = "sha256:f3d91857d023ba93e14ed2d462ab62c3428f9bbf2b4fbac50a03ca66d31991f7", size = 12500045, upload-time = "2025-10-31T00:26:20.503Z" }, - { url = "https://files.pythonhosted.org/packages/03/15/51960ae340823c9859fb60c63301d977308735403e2134e17d1d2858c7fb/ruff-0.14.3-py3-none-win_amd64.whl", hash = "sha256:d7b7006ac0756306db212fd37116cce2bd307e1e109375e1c6c106002df0ae5f", size = 13594005, upload-time = "2025-10-31T00:26:22.533Z" }, - { url = "https://files.pythonhosted.org/packages/b7/73/4de6579bac8e979fca0a77e54dec1f1e011a0d268165eb8a9bc0982a6564/ruff-0.14.3-py3-none-win_arm64.whl", hash = "sha256:26eb477ede6d399d898791d01961e16b86f02bc2486d0d1a7a9bb2379d055dc1", size = 12590017, upload-time = "2025-10-31T00:26:24.52Z" }, +version = "0.14.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944, upload-time = "2025-11-13T19:58:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630, upload-time = "2025-11-13T19:57:54.894Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925, upload-time = "2025-11-13T19:57:59.181Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040, upload-time = "2025-11-13T19:58:02.056Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755, upload-time = "2025-11-13T19:58:05.172Z" }, + { url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641, upload-time = "2025-11-13T19:58:08.345Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854, upload-time = "2025-11-13T19:58:11.419Z" }, + { url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088, upload-time = "2025-11-13T19:58:14.551Z" }, + { url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717, upload-time = "2025-11-13T19:58:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812, upload-time = "2025-11-13T19:58:20.533Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656, upload-time = "2025-11-13T19:58:23.345Z" }, + { url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922, upload-time = "2025-11-13T19:58:26.537Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501, upload-time = "2025-11-13T19:58:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319, upload-time = "2025-11-13T19:58:32.923Z" }, + { url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209, upload-time = "2025-11-13T19:58:35.952Z" }, + { url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709, upload-time = "2025-11-13T19:58:39.002Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f1/7119e42aa1d3bf036ffc9478885c2e248812b7de9abea4eae89163d2929d/ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b", size = 12925808, upload-time = "2025-11-13T19:58:42.779Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9d/7c0a255d21e0912114784e4a96bf62af0618e2190cae468cd82b13625ad2/ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621", size = 14331546, upload-time = "2025-11-13T19:58:45.691Z" }, + { url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload-time = "2025-11-13T19:58:48.434Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.45.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/89/1561b3dc8e28bf7978d031893297e89be266f53650c87bb14a29406a9791/sentry_sdk-2.45.0.tar.gz", hash = "sha256:e9bbfe69d5f6742f48bad22452beffb525bbc5b797d817c7f1b1f7d210cdd271", size = 373631, upload-time = "2025-11-18T13:23:22.475Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/c6/039121a0355bc1b5bcceef0dabf211b021fd435d0ee5c46393717bb1c09f/sentry_sdk-2.45.0-py2.py3-none-any.whl", hash = "sha256:86c8ab05dc3e8666aece77a5c747b45b25aa1d5f35f06cde250608f495d50f23", size = 404791, upload-time = "2025-11-18T13:23:20.533Z" }, ] [[package]] @@ -737,27 +772,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.1a25" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/6b/e73bc3c1039ea72936158a08313155a49e5aa5e7db5205a149fe516a4660/ty-0.0.1a25.tar.gz", hash = "sha256:5550b24b9dd0e0f8b4b2c1f0fcc608a55d0421dd67b6c364bc7bf25762334511", size = 4403670, upload-time = "2025-10-29T19:40:23.647Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/3b/4457231238a2eeb04cba4ba7cc33d735be68ee46ca40a98ae30e187de864/ty-0.0.1a25-py3-none-linux_armv6l.whl", hash = "sha256:d35b2c1f94a014a22875d2745aa0432761d2a9a8eb7212630d5caf547daeef6d", size = 8878803, upload-time = "2025-10-29T19:39:42.243Z" }, - { url = "https://files.pythonhosted.org/packages/8a/fa/a328713dd310018fc7a381693d8588185baa2fdae913e01a6839187215df/ty-0.0.1a25-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:192edac94675a468bac7f6e04687a77a64698e4e1fe01f6a048bf9b6dde5b703", size = 8695667, upload-time = "2025-10-29T19:39:45.179Z" }, - { url = "https://files.pythonhosted.org/packages/22/e8/5707939118992ced2bf5385adc3ede7723c1b717b07ad14c495eea1e47b4/ty-0.0.1a25-py3-none-macosx_11_0_arm64.whl", hash = "sha256:949523621f336e01bc7d687b7bd08fe838edadbdb6563c2c057ed1d264e820cf", size = 8159012, upload-time = "2025-10-29T19:39:47.011Z" }, - { url = "https://files.pythonhosted.org/packages/eb/fb/ff313aa71602225cd78f1bce3017713d6d1b1c1e0fa8101ead4594a60d95/ty-0.0.1a25-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94f78f621458c05e59e890061021198197f29a7b51a33eda82bbb036e7ed73d7", size = 8433675, upload-time = "2025-10-29T19:39:48.443Z" }, - { url = "https://files.pythonhosted.org/packages/c0/8d/cc7e7fb57215a15b575a43ed042bdd92971871e0decec1b26d2e7d969465/ty-0.0.1a25-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d9656fca8062a2c6709c30d76d662c96d2e7dbfee8f70e55ec6b6afd67b5d447", size = 8668456, upload-time = "2025-10-29T19:39:50.412Z" }, - { url = "https://files.pythonhosted.org/packages/b8/6d/d7bf5909ed2dcdcbc1e2ca7eea80929893e2d188d9c36b3fcb2b36532ff6/ty-0.0.1a25-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9f3bbf523b49935bbd76e230408d858dce0d614f44f5807bbbd0954f64e0f01", size = 9023543, upload-time = "2025-10-29T19:39:52.292Z" }, - { url = "https://files.pythonhosted.org/packages/b4/b8/72bcefb4be32e5a84f0b21de2552f16cdb4cae3eb271ac891c8199c26b1a/ty-0.0.1a25-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f13ea9815f4a54a0a303ca7bf411b0650e3c2a24fc6c7889ffba2c94f5e97a6a", size = 9700013, upload-time = "2025-10-29T19:39:57.283Z" }, - { url = "https://files.pythonhosted.org/packages/90/0d/cf7e794b840cf6b0bbecb022e593c543f85abad27a582241cf2095048cb1/ty-0.0.1a25-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eab6e33ebe202a71a50c3d5a5580e3bc1a85cda3ffcdc48cec3f1c693b7a873b", size = 9372574, upload-time = "2025-10-29T19:40:04.532Z" }, - { url = "https://files.pythonhosted.org/packages/1e/71/2d35e7d51b48eabd330e2f7b7e0bce541cbd95950c4d2f780e85f3366af1/ty-0.0.1a25-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6b9a31da43424cdab483703a54a561b93aabba84630788505329fc5294a9c62", size = 9535726, upload-time = "2025-10-29T19:40:06.548Z" }, - { url = "https://files.pythonhosted.org/packages/57/d3/01ecc23bbd8f3e0dfbcf9172d06d84e88155c5f416f1491137e8066fd859/ty-0.0.1a25-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a90d897a7c1a5ae9b41a4c7b0a42262a06361476ad88d783dbedd7913edadbc", size = 9003380, upload-time = "2025-10-29T19:40:08.683Z" }, - { url = "https://files.pythonhosted.org/packages/de/f9/cde9380d8a1a6ca61baeb9aecb12cbec90d489aa929be55cd78ad5c2ccd9/ty-0.0.1a25-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:93c7e7ab2859af0f866d34d27f4ae70dd4fb95b847387f082de1197f9f34e068", size = 8401833, upload-time = "2025-10-29T19:40:10.627Z" }, - { url = "https://files.pythonhosted.org/packages/0b/39/0acf3625b0c495011795a391016b572f97a812aca1d67f7a76621fdb9ebf/ty-0.0.1a25-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4a247061bd32bae3865a236d7f8b6c9916c80995db30ae1600999010f90623a9", size = 8706761, upload-time = "2025-10-29T19:40:12.575Z" }, - { url = "https://files.pythonhosted.org/packages/25/73/7de1648f3563dd9d416d36ab5f1649bfd7b47a179135027f31d44b89a246/ty-0.0.1a25-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1711dd587eccf04fd50c494dc39babe38f4cb345bc3901bf1d8149cac570e979", size = 8792426, upload-time = "2025-10-29T19:40:14.553Z" }, - { url = "https://files.pythonhosted.org/packages/7d/8a/b6e761a65eac7acd10b2e452f49b2d8ae0ea163ca36bb6b18b2dadae251b/ty-0.0.1a25-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5f4c9b0cf7995e2e3de9bab4d066063dea92019f2f62673b7574e3612643dd35", size = 9103991, upload-time = "2025-10-29T19:40:16.332Z" }, - { url = "https://files.pythonhosted.org/packages/e4/25/9324ae947fcc4322470326cf8276a3fc2f08dc82adec1de79d963fdf7af5/ty-0.0.1a25-py3-none-win32.whl", hash = "sha256:168fc8aee396d617451acc44cd28baffa47359777342836060c27aa6f37e2445", size = 8387095, upload-time = "2025-10-29T19:40:18.368Z" }, - { url = "https://files.pythonhosted.org/packages/3b/2b/cb12cbc7db1ba310aa7b1de9b4e018576f653105993736c086ee67d2ec02/ty-0.0.1a25-py3-none-win_amd64.whl", hash = "sha256:a2fad3d8e92bb4d57a8872a6f56b1aef54539d36f23ebb01abe88ac4338efafb", size = 9059225, upload-time = "2025-10-29T19:40:20.278Z" }, - { url = "https://files.pythonhosted.org/packages/2f/c1/f6be8cdd0bf387c1d8ee9d14bb299b7b5d2c0532f550a6693216a32ec0c5/ty-0.0.1a25-py3-none-win_arm64.whl", hash = "sha256:dde2962d448ed87c48736e9a4bb13715a4cced705525e732b1c0dac1d4c66e3d", size = 8536832, upload-time = "2025-10-29T19:40:22.014Z" }, +version = "0.0.1a27" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/65/3592d7c73d80664378fc90d0a00c33449a99cbf13b984433c883815245f3/ty-0.0.1a27.tar.gz", hash = "sha256:d34fe04979f2c912700cbf0919e8f9b4eeaa10c4a2aff7450e5e4c90f998bc28", size = 4516059, upload-time = "2025-11-18T21:55:18.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/05/7945aa97356446fd53ed3ddc7ee02a88d8ad394217acd9428f472d6b109d/ty-0.0.1a27-py3-none-linux_armv6l.whl", hash = "sha256:3cbb735f5ecb3a7a5f5b82fb24da17912788c109086df4e97d454c8fb236fbc5", size = 9375047, upload-time = "2025-11-18T21:54:31.577Z" }, + { url = "https://files.pythonhosted.org/packages/69/4e/89b167a03de0e9ec329dc89bc02e8694768e4576337ef6c0699987681342/ty-0.0.1a27-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4a6367236dc456ba2416563301d498aef8c6f8959be88777ef7ba5ac1bf15f0b", size = 9169540, upload-time = "2025-11-18T21:54:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/38/07/e62009ab9cc242e1becb2bd992097c80a133fce0d4f055fba6576150d08a/ty-0.0.1a27-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8e93e231a1bcde964cdb062d2d5e549c24493fb1638eecae8fcc42b81e9463a4", size = 8711942, upload-time = "2025-11-18T21:54:36.3Z" }, + { url = "https://files.pythonhosted.org/packages/b5/43/f35716ec15406f13085db52e762a3cc663c651531a8124481d0ba602eca0/ty-0.0.1a27-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5b6a8166b60117da1179851a3d719cc798bf7e61f91b35d76242f0059e9ae1d", size = 8984208, upload-time = "2025-11-18T21:54:39.453Z" }, + { url = "https://files.pythonhosted.org/packages/2d/79/486a3374809523172379768de882c7a369861165802990177fe81489b85f/ty-0.0.1a27-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfbe8b0e831c072b79a078d6c126d7f4d48ca17f64a103de1b93aeda32265dc5", size = 9157209, upload-time = "2025-11-18T21:54:42.664Z" }, + { url = "https://files.pythonhosted.org/packages/ff/08/9a7c8efcb327197d7d347c548850ef4b54de1c254981b65e8cd0672dc327/ty-0.0.1a27-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:90e09678331552e7c25d7eb47868b0910dc5b9b212ae22c8ce71a52d6576ddbb", size = 9519207, upload-time = "2025-11-18T21:54:45.311Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9d/7b4680683e83204b9edec551bb91c21c789ebc586b949c5218157ee474b7/ty-0.0.1a27-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:88c03e4beeca79d85a5618921e44b3a6ea957e0453e08b1cdd418b51da645939", size = 10148794, upload-time = "2025-11-18T21:54:48.329Z" }, + { url = "https://files.pythonhosted.org/packages/89/21/8b961b0ab00c28223f06b33222427a8e31aa04f39d1b236acc93021c626c/ty-0.0.1a27-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ece5811322789fefe22fc088ed36c5879489cd39e913f9c1ff2a7678f089c61", size = 9900563, upload-time = "2025-11-18T21:54:51.214Z" }, + { url = "https://files.pythonhosted.org/packages/85/eb/95e1f0b426c2ea8d443aa923fcab509059c467bbe64a15baaf573fea1203/ty-0.0.1a27-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f2ccb4f0fddcd6e2017c268dfce2489e9a36cb82a5900afe6425835248b1086", size = 9926355, upload-time = "2025-11-18T21:54:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/f5/78/40e7f072049e63c414f2845df780be3a494d92198c87c2ffa65e63aecf3f/ty-0.0.1a27-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33450528312e41d003e96a1647780b2783ab7569bbc29c04fc76f2d1908061e3", size = 9480580, upload-time = "2025-11-18T21:54:56.617Z" }, + { url = "https://files.pythonhosted.org/packages/18/da/f4a2dfedab39096808ddf7475f35ceb750d9a9da840bee4afd47b871742f/ty-0.0.1a27-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a0a9ac635deaa2b15947701197ede40cdecd13f89f19351872d16f9ccd773fa1", size = 8957524, upload-time = "2025-11-18T21:54:59.085Z" }, + { url = "https://files.pythonhosted.org/packages/21/ea/26fee9a20cf77a157316fd3ab9c6db8ad5a0b20b2d38a43f3452622587ac/ty-0.0.1a27-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:797fb2cd49b6b9b3ac9f2f0e401fb02d3aa155badc05a8591d048d38d28f1e0c", size = 9201098, upload-time = "2025-11-18T21:55:01.845Z" }, + { url = "https://files.pythonhosted.org/packages/b0/53/e14591d1275108c9ae28f97ac5d4b93adcc2c8a4b1b9a880dfa9d07c15f8/ty-0.0.1a27-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7fe81679a0941f85e98187d444604e24b15bde0a85874957c945751756314d03", size = 9275470, upload-time = "2025-11-18T21:55:04.23Z" }, + { url = "https://files.pythonhosted.org/packages/37/44/e2c9acecac70bf06fb41de285e7be2433c2c9828f71e3bf0e886fc85c4fd/ty-0.0.1a27-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:355f651d0cdb85535a82bd9f0583f77b28e3fd7bba7b7da33dcee5a576eff28b", size = 9592394, upload-time = "2025-11-18T21:55:06.542Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a7/4636369731b24ed07c2b4c7805b8d990283d677180662c532d82e4ef1a36/ty-0.0.1a27-py3-none-win32.whl", hash = "sha256:61782e5f40e6df622093847b34c366634b75d53f839986f1bf4481672ad6cb55", size = 8783816, upload-time = "2025-11-18T21:55:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1d/b76487725628d9e81d9047dc0033a5e167e0d10f27893d04de67fe1a9763/ty-0.0.1a27-py3-none-win_amd64.whl", hash = "sha256:c682b238085d3191acddcf66ef22641562946b1bba2a7f316012d5b2a2f4de11", size = 9616833, upload-time = "2025-11-18T21:55:12.457Z" }, + { url = "https://files.pythonhosted.org/packages/3a/db/c7cd5276c8f336a3cf87992b75ba9d486a7cf54e753fcd42495b3bc56fb7/ty-0.0.1a27-py3-none-win_arm64.whl", hash = "sha256:e146dfa32cbb0ac6afb0cb65659e87e4e313715e68d76fe5ae0a4b3d5b912ce8", size = 9137796, upload-time = "2025-11-18T21:55:15.897Z" }, ] [[package]]