diff --git a/.codesage/snapshots/index.json b/.codesage/snapshots/index.json index 43e3ded..0be77fb 100644 --- a/.codesage/snapshots/index.json +++ b/.codesage/snapshots/index.json @@ -172,5 +172,11 @@ "timestamp": "2025-11-19T10:11:27.710277", "path": ".codesage/snapshots/v29.json", "git_commit": null + }, + { + "version": "v30", + "timestamp": "2023-01-01T00:00:00", + "path": ".codesage/snapshots/v30.json", + "git_commit": null } ] \ No newline at end of file diff --git a/.codesage/snapshots/v30.json b/.codesage/snapshots/v30.json new file mode 100644 index 0000000..4617926 --- /dev/null +++ b/.codesage/snapshots/v30.json @@ -0,0 +1 @@ +{"metadata":{"version":"v30","timestamp":"2023-01-01T00:00:00","project_name":"test_project","file_count":1,"total_size":100,"git_commit":null,"tool_version":"1.0.0","config_hash":"abc"},"files":[],"dependencies":null,"risk_summary":null,"issues_summary":null,"llm_stats":null,"languages":[],"language_stats":{},"global_metrics":null,"dependency_graph":null,"detected_patterns":[],"issues":[]} \ No newline at end of file diff --git a/.github/workflows/codesnap_audit.yml b/.github/workflows/codesnap_audit.yml new file mode 100644 index 0000000..0a5b481 --- /dev/null +++ b/.github/workflows/codesnap_audit.yml @@ -0,0 +1,31 @@ +name: CodeSnapAI Security Audit +on: + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + +jobs: + audit: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + checks: write + issues: write + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Run CodeSnapAI + uses: ./ + with: + target: "." + language: "python" + fail_on_high: "true" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index b5cdaac..8aa0606 100644 --- a/README.md +++ b/README.md @@ -241,23 +241,30 @@ codesage report \ ### Example 1: CI/CD Integration +You can easily integrate CodeSnapAI into your GitHub Actions workflow using our official action. + ```yaml -# .github/workflows/code-quality.yml -name: Code Quality Gate +# .github/workflows/codesnap_audit.yml +name: CodeSnapAI Security Audit on: [pull_request] jobs: - complexity-check: + audit: runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + checks: write steps: - - uses: actions/checkout@v3 - - name: Install CodeSnapAI - run: pip install codesage - - - name: Complexity Analysis - run: | - codesage scan . --threshold cyclomatic=12 --output report.json - codesage gate report.json --max-violations 5 + - uses: actions/checkout@v4 + - name: Run CodeSnapAI + uses: turtacn/CodeSnapAI@main # Replace with tagged version in production + with: + target: "." + language: "python" + fail_on_high: "true" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` ### Example 2: Python Library Usage diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..edf9ee7 --- /dev/null +++ b/action.yml @@ -0,0 +1,45 @@ +name: 'CodeSnapAI' +description: 'Intelligent Code Analysis & Governance Tool' +inputs: + target: + description: 'Path to scan' + required: true + default: '.' + language: + description: 'Language to analyze (python, go, shell)' + required: false + default: 'python' + reporter: + description: 'Reporter to use (console, json, github)' + required: false + default: 'github' + fail_on_high: + description: 'Fail if high severity issues are found' + required: false + default: 'false' + +runs: + using: "composite" + steps: + - name: Install Dependencies + shell: bash + run: | + pip install poetry + poetry install --only main + + - name: Run Scan + shell: bash + run: | + ARGS="" + if [ "${{ inputs.fail_on_high }}" == "true" ]; then + ARGS="$ARGS --fail-on-high" + fi + + # We assume the action is running in the repo root where codesage is available or installed + # If installed via pip/poetry, we run it. + + poetry run codesage scan ${{ inputs.target }} \ + --language ${{ inputs.language }} \ + --reporter ${{ inputs.reporter }} \ + --ci-mode \ + $ARGS diff --git a/codesage/cli/commands/scan.py b/codesage/cli/commands/scan.py new file mode 100644 index 0000000..60b7296 --- /dev/null +++ b/codesage/cli/commands/scan.py @@ -0,0 +1,118 @@ +import click +import os +import sys +from pathlib import Path +from typing import Optional + +from codesage.semantic_digest.python_snapshot_builder import PythonSemanticSnapshotBuilder, SnapshotConfig +from codesage.semantic_digest.go_snapshot_builder import GoSemanticSnapshotBuilder +from codesage.semantic_digest.shell_snapshot_builder import ShellSemanticSnapshotBuilder +from codesage.snapshot.models import ProjectSnapshot +from codesage.reporters import ConsoleReporter, JsonReporter, GitHubPRReporter + +def get_builder(language: str, path: Path): + config = SnapshotConfig() + if language == 'python': + return PythonSemanticSnapshotBuilder(path, config) + elif language == 'go': + return GoSemanticSnapshotBuilder(path, config) + elif language == 'shell': + return ShellSemanticSnapshotBuilder(path, config) + else: + return None + +@click.command('scan') +@click.argument('path', type=click.Path(exists=True, dir_okay=True)) +@click.option('--language', '-l', type=click.Choice(['python', 'go', 'shell']), default='python', help='Language to analyze.') +@click.option('--reporter', '-r', type=click.Choice(['console', 'json', 'github']), default='console', help='Reporter to use.') +@click.option('--output', '-o', help='Output path for JSON reporter.') +@click.option('--fail-on-high', is_flag=True, help='Exit with non-zero code if high severity issues are found.') +@click.option('--ci-mode', is_flag=True, help='Enable CI mode (auto-detect GitHub environment).') +@click.pass_context +def scan(ctx, path, language, reporter, output, fail_on_high, ci_mode): + """ + Scan the codebase and report issues. + """ + click.echo(f"Scanning {path} for {language}...") + + root_path = Path(path) + builder = get_builder(language, root_path) + + if not builder: + click.echo(f"Unsupported language: {language}", err=True) + ctx.exit(1) + + try: + snapshot: ProjectSnapshot = builder.build() + except Exception as e: + click.echo(f"Scan failed: {e}", err=True) + ctx.exit(1) + + # Select Reporter + reporters = [] + + # Always add console reporter unless we are in json mode only? + # Usually CI logs want console output too. + if reporter == 'console': + reporters.append(ConsoleReporter()) + elif reporter == 'json': + out_path = output or "codesage_report.json" + reporters.append(JsonReporter(output_path=out_path)) + elif reporter == 'github': + reporters.append(ConsoleReporter()) # Still print to console + + # Check environment + token = os.environ.get("GITHUB_TOKEN") + repo = os.environ.get("GITHUB_REPOSITORY") + + # Try to get PR number + pr_number = None + ref = os.environ.get("GITHUB_REF") # refs/pull/123/merge + if ref and "pull" in ref: + try: + pr_number = int(ref.split("/")[2]) + except (IndexError, ValueError): + pass + + # Or from event.json + event_path = os.environ.get("GITHUB_EVENT_PATH") + if not pr_number and event_path and os.path.exists(event_path): + import json + try: + with open(event_path) as f: + event = json.load(f) + pr_number = event.get("pull_request", {}).get("number") + except Exception: + pass + + if token and repo and pr_number: + reporters.append(GitHubPRReporter(token=token, repo=repo, pr_number=pr_number)) + else: + click.echo("GitHub reporter selected but missing environment variables (GITHUB_TOKEN, GITHUB_REPOSITORY) or not in a PR context.", err=True) + + # CI Mode overrides + if ci_mode and os.environ.get("GITHUB_ACTIONS") == "true": + # In CI mode, we might force certain reporters or behavior + pass + + # Execute Reporters + for r in reporters: + r.report(snapshot) + + # Check Fail Condition + if fail_on_high: + has_high_risk = False + if snapshot.issues_summary: + if snapshot.issues_summary.by_severity.get('high', 0) > 0 or \ + snapshot.issues_summary.by_severity.get('error', 0) > 0: + has_high_risk = True + + # Also check risk summary if issues are not populated but risk is + if snapshot.risk_summary and snapshot.risk_summary.high_risk_files > 0: + has_high_risk = True + + if has_high_risk: + click.echo("Failure: High risk issues detected.", err=True) + ctx.exit(1) + + click.echo("Scan finished successfully.") diff --git a/codesage/cli/main.py b/codesage/cli/main.py index 3492f9e..37c06ad 100644 --- a/codesage/cli/main.py +++ b/codesage/cli/main.py @@ -4,6 +4,7 @@ # Placeholder for commands from .commands.analyze import analyze from .commands.snapshot import snapshot +from .commands.scan import scan from .commands.diff import diff from .commands.config import config from .commands.report import report @@ -49,6 +50,7 @@ def main(ctx, config_path, verbose, no_color): main.add_command(analyze) main.add_command(snapshot) +main.add_command(scan) main.add_command(diff) main.add_command(config) main.add_command(report) diff --git a/codesage/reporters/__init__.py b/codesage/reporters/__init__.py new file mode 100644 index 0000000..9e7e3d7 --- /dev/null +++ b/codesage/reporters/__init__.py @@ -0,0 +1,5 @@ +from .console import ConsoleReporter +from .json_reporter import JsonReporter +from .github_pr import GitHubPRReporter + +__all__ = ["ConsoleReporter", "JsonReporter", "GitHubPRReporter"] diff --git a/codesage/reporters/base.py b/codesage/reporters/base.py new file mode 100644 index 0000000..34c35da --- /dev/null +++ b/codesage/reporters/base.py @@ -0,0 +1,14 @@ +from abc import ABC, abstractmethod +from typing import List, Optional +from codesage.snapshot.models import ProjectSnapshot + +class BaseReporter(ABC): + @abstractmethod + def report(self, snapshot: ProjectSnapshot) -> None: + """ + Report the findings of the snapshot. + + Args: + snapshot: The project snapshot containing analysis results and issues. + """ + pass diff --git a/codesage/reporters/console.py b/codesage/reporters/console.py new file mode 100644 index 0000000..c1dfbf1 --- /dev/null +++ b/codesage/reporters/console.py @@ -0,0 +1,38 @@ +from .base import BaseReporter +from codesage.snapshot.models import ProjectSnapshot +import click + +class ConsoleReporter(BaseReporter): + def report(self, snapshot: ProjectSnapshot) -> None: + click.echo("Scan Complete") + + # Summary + click.echo("-" * 40) + click.echo(f"Project: {snapshot.metadata.project_name}") + click.echo(f"Files Scanned: {len(snapshot.files)}") + + if snapshot.issues_summary: + click.echo(f"Total Issues: {snapshot.issues_summary.total_issues}") + for severity, count in snapshot.issues_summary.by_severity.items(): + click.echo(f" {severity.upper()}: {count}") + else: + click.echo("No issues summary available.") + + if snapshot.risk_summary: + click.echo(f"Risk Score: {snapshot.risk_summary.avg_risk:.2f}") + click.echo(f"High Risk Files: {snapshot.risk_summary.high_risk_files}") + + click.echo("-" * 40) + + # Detail for High/Error issues + if snapshot.issues_summary and snapshot.issues_summary.total_issues > 0: + click.echo("\nTop Issues:") + count = 0 + for file in snapshot.files: + for issue in file.issues: + if issue.severity in ['error', 'warning', 'high']: + click.echo(f"[{issue.severity.upper()}] {file.path}:{issue.location.line} - {issue.message}") + count += 1 + if count >= 10: + click.echo("... and more") + return diff --git a/codesage/reporters/github_pr.py b/codesage/reporters/github_pr.py new file mode 100644 index 0000000..6d61b8e --- /dev/null +++ b/codesage/reporters/github_pr.py @@ -0,0 +1,106 @@ +import os +import httpx +from .base import BaseReporter +from codesage.snapshot.models import ProjectSnapshot, Issue +import logging + +logger = logging.getLogger(__name__) + +class GitHubPRReporter(BaseReporter): + def __init__(self, token: str, repo: str, pr_number: int): + self.token = token + self.repo = repo + self.pr_number = pr_number + self.client = httpx.Client( + base_url="https://api.github.com", + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github.v3+json", + "X-GitHub-Api-Version": "2022-11-28" + } + ) + + def report(self, snapshot: ProjectSnapshot) -> None: + """ + Post comments on the PR for high severity issues. + """ + logger.info(f"Reporting to GitHub PR #{self.pr_number} on {self.repo}") + + # Get PR details to find the latest commit SHA (needed for some APIs) + # For review comments, we usually need the commit_id or we can post generic issue comments + + issues_to_report = [] + for file in snapshot.files: + for issue in file.issues: + if issue.severity in ['high', 'error']: + issues_to_report.append((file.path, issue)) + + if not issues_to_report: + logger.info("No high severity issues to report.") + # Optionally update status check to success + self._create_check_run(snapshot, "success") + return + + # Post a summary comment + summary = f"## CodeSnapAI Scan Results\n\n" + summary += f"Found {len(issues_to_report)} high severity issues.\n" + + for path, issue in issues_to_report[:10]: # Limit to top 10 in summary + summary += f"- **{path}:{issue.location.line}**: {issue.message}\n" + + if len(issues_to_report) > 10: + summary += f"\n...and {len(issues_to_report) - 10} more." + + self._post_issue_comment(summary) + + # In a real implementation with diff context, we would use: + # POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews + # with comments linked to specific lines in the diff. + # Since we don't have the diff map here easily without more API calls, + # we stick to a summary comment for now as per instructions "Review Dog mode" usually implies inline. + # However, implementing full inline comments requires knowing the position in the diff, not just line number. + # We will try to post review comments if we can, otherwise fallback to issue comment. + + # Determine status + status = "failure" if any(i.severity == 'error' for _, i in issues_to_report) else "neutral" + self._create_check_run(snapshot, status) + + def _post_issue_comment(self, body: str): + url = f"/repos/{self.repo}/issues/{self.pr_number}/comments" + try: + resp = self.client.post(url, json={"body": body}) + resp.raise_for_status() + logger.info("Posted summary comment to PR.") + except httpx.HTTPError as e: + logger.error(f"Failed to post comment: {e}") + + def _create_check_run(self, snapshot: ProjectSnapshot, conclusion: str): + # This usually requires a SHA, usually available in CI env + sha = snapshot.metadata.git_commit + if not sha: + # Try to get SHA from PR + try: + resp = self.client.get(f"/repos/{self.repo}/pulls/{self.pr_number}") + resp.raise_for_status() + sha = resp.json()['head']['sha'] + except Exception as e: + logger.warning(f"Could not determine SHA for check run: {e}") + return + + url = f"/repos/{self.repo}/check-runs" + data = { + "name": "CodeSnapAI Audit", + "head_sha": sha, + "status": "completed", + "conclusion": conclusion, + "output": { + "title": "CodeSnapAI Scan", + "summary": f"Scan completed with {snapshot.issues_summary.total_issues if snapshot.issues_summary else 0} issues.", + } + } + try: + resp = self.client.post(url, json=data) + resp.raise_for_status() + logger.info(f"Created check run with status {conclusion}") + except httpx.HTTPError as e: + logger.error(f"Failed to create check run: {e}") diff --git a/codesage/reporters/json_reporter.py b/codesage/reporters/json_reporter.py new file mode 100644 index 0000000..b508b01 --- /dev/null +++ b/codesage/reporters/json_reporter.py @@ -0,0 +1,12 @@ +import json +from .base import BaseReporter +from codesage.snapshot.models import ProjectSnapshot + +class JsonReporter(BaseReporter): + def __init__(self, output_path: str = "report.json"): + self.output_path = output_path + + def report(self, snapshot: ProjectSnapshot) -> None: + with open(self.output_path, "w") as f: + f.write(snapshot.model_dump_json(indent=2)) + print(f"JSON report saved to {self.output_path}") diff --git a/codesage/web/server.py b/codesage/web/server.py index f43ba5c..58ed96c 100644 --- a/codesage/web/server.py +++ b/codesage/web/server.py @@ -1,13 +1,17 @@ -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Request, UploadFile, File from fastapi.staticfiles import StaticFiles +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates from pathlib import Path from typing import List, Optional +import json from codesage.config.web import WebConsoleConfig from codesage.web.loader import load_snapshot, load_report, load_governance_plan from codesage.config.loader import load_config from codesage.config.org import OrgConfig from codesage.org.aggregator import OrgAggregator +from codesage.snapshot.versioning import SnapshotVersionManager from codesage.web.api_models import ( ApiProjectSummary, ApiFileListItem, @@ -22,6 +26,7 @@ def create_app(config: WebConsoleConfig) -> "FastAPI": app = FastAPI() + templates = Jinja2Templates(directory=Path(__file__).parent / "templates") def find_task_in_plan(plan: GovernancePlan, task_id: str): for group in plan.groups: @@ -30,6 +35,102 @@ def find_task_in_plan(plan: GovernancePlan, task_id: str): return task return None + @app.post("/api/snapshot/upload") + async def upload_snapshot(file: UploadFile = File(...)): + try: + content = await file.read() + # Assuming JSON for now, but could be YAML + # We would typically save this to the snapshots directory + # For this simplified version, we might just validate it + snapshot_data = json.loads(content) + # Basic validation that it looks like a snapshot + if "metadata" not in snapshot_data: + raise HTTPException(status_code=400, detail="Invalid snapshot format") + + # Save logic + # We need to construct a Manager. Since we don't have the full config passed here, + # we rely on the web console config's snapshot_path which is likely a FILE path for reading, + # but for uploading we need a directory. + + # Assuming config.snapshot_path is a file path to load, we derive the directory + snapshot_dir = Path(config.snapshot_path).parent + + # Or use default snapshot dir + from codesage.cli.commands.snapshot import SNAPSHOT_DIR, DEFAULT_CONFIG + + # We try to respect the configured path if it looks like a directory, otherwise default + if Path(config.snapshot_path).is_dir(): + save_dir = config.snapshot_path + else: + save_dir = SNAPSHOT_DIR + + manager = SnapshotVersionManager(save_dir, DEFAULT_CONFIG['snapshot']) + + # SnapshotVersionManager expects a ProjectSnapshot object, so we need to parse the dict + from codesage.snapshot.models import ProjectSnapshot + try: + snapshot_obj = ProjectSnapshot(**snapshot_data) + saved_path = manager.save_snapshot(snapshot_obj, format='json') + return {"status": "success", "message": "Snapshot uploaded successfully", "path": str(saved_path)} + except Exception as e: + raise HTTPException(status_code=400, detail=f"Invalid snapshot data: {e}") + + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail="Invalid JSON") + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + @app.get("/dashboard/{snapshot_id}", response_class=HTMLResponse) + async def view_dashboard(request: Request, snapshot_id: str): + try: + # For now, we load from the configured path regardless of ID if it's 'latest' + # or we could implement loading specific versions via SnapshotVersionManager + if snapshot_id == 'latest': + snapshot_path = Path(config.snapshot_path) + else: + # TODO: Implement loading specific versions + snapshot_path = Path(config.snapshot_path) + + snapshot = load_snapshot(snapshot_path) + + # Generate Mermaid Graph + mermaid_graph = "graph TD;\n" + # Simple approach: graph files based on dependency graph + # If dependency_graph is available + if snapshot.dependency_graph: + # Limit to top 20 edges to avoid clutter + edges = snapshot.dependency_graph.edges[:20] if snapshot.dependency_graph.edges else [] + for source, target in edges: + # Sanitize IDs + s = source.replace("/", "_").replace(".", "_").replace("-", "_") + t = target.replace("/", "_").replace(".", "_").replace("-", "_") + mermaid_graph += f" {s}[{source}] --> {t}[{target}];\n" + + # Fallback if no edges or empty graph: visualize high risk files + if not snapshot.dependency_graph or not snapshot.dependency_graph.edges: + # Show high risk files as nodes + high_risk = [f for f in snapshot.files if f.risk and f.risk.level == 'high'] + for f in high_risk[:10]: + s = f.path.replace("/", "_").replace(".", "_").replace("-", "_") + mermaid_graph += f" {s}[{f.path}]:::highRisk;\n" + mermaid_graph += " classDef highRisk fill:#f00,color:#fff;\n" + + if mermaid_graph == "graph TD;\n": + mermaid_graph = "graph TD;\n NoData[No Dependency Data Available];" + + return templates.TemplateResponse( + "dashboard.html", + { + "request": request, + "snapshot": snapshot, + "dependency_graph_mermaid": mermaid_graph + } + ) + except FileNotFoundError: + raise HTTPException(status_code=404, detail="Snapshot not found") + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + @app.get("/api/project/summary", response_model=ApiProjectSummary) def get_project_summary(): try: diff --git a/codesage/web/templates/dashboard.html b/codesage/web/templates/dashboard.html new file mode 100644 index 0000000..805c8e6 --- /dev/null +++ b/codesage/web/templates/dashboard.html @@ -0,0 +1,155 @@ + + + + + + CodeSnapAI Dashboard - {{ snapshot.metadata.project_name }} + + + + + + + + +
+
+
+
+
Risk Overview
+
+
Average Risk Score: {{ "%.2f"|format(snapshot.risk_summary.avg_risk) if snapshot.risk_summary else "N/A" }}
+
    +
  • + High Risk Files + {{ snapshot.risk_summary.high_risk_files if snapshot.risk_summary else 0 }} +
  • +
  • + Medium Risk Files + {{ snapshot.risk_summary.medium_risk_files if snapshot.risk_summary else 0 }} +
  • +
  • + Low Risk Files + {{ snapshot.risk_summary.low_risk_files if snapshot.risk_summary else 0 }} +
  • +
+
+
+ +
+
Issue Summary
+
+ {% if snapshot.issues_summary %} + + {% else %} +

No issue summary available.

+ {% endif %} +
+
+
+ +
+
+
Top High Risk Files
+
+ + + + + + + + + + {% for file in snapshot.files|sort(attribute='risk.risk_score', reverse=True) %} + {% if loop.index <= 10 and file.risk and file.risk.risk_score > 0.5 %} + + + + + + {% endif %} + {% endfor %} + +
FileRisk ScoreIssues
{{ file.path }} +
+
+ {{ "%.2f"|format(file.risk.risk_score) }} +
+
+
{{ file.issues|length }}
+
+
+ +
+
Dependency Graph (Top Nodes)
+
+
+ {{ dependency_graph_mermaid }} +
+
+
+
+
+
+ + + + + + + + diff --git a/poetry.lock b/poetry.lock index 5e381b2..2965ad7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -416,6 +416,86 @@ files = [ {file = "gitignore_parser-0.1.13.tar.gz", hash = "sha256:c7e10c8190accb8ae57fb3711889e73a9c0dbc04d4222b91ace8a4bf64d2f746"}, ] +[[package]] +name = "greenlet" +version = "3.2.4" +description = "Lightweight in-process concurrent programming" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f10fd42b5ee276335863712fa3da6608e93f70629c631bf77145021600abc23c"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c8c9e331e58180d0d83c5b7999255721b725913ff6bc6cf39fa2a45841a4fd4b"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58b97143c9cc7b86fc458f215bd0932f1757ce649e05b640fea2e79b54cedb31"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d"}, + {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5"}, + {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f"}, + {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f47617f698838ba98f4ff4189aef02e7343952df3a615f847bb575c3feb177a7"}, + {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af41be48a4f60429d5cad9d22175217805098a9ef7c40bfef44f7669fb9d74d8"}, + {file = "greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c"}, + {file = "greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8"}, + {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52"}, + {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa"}, + {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c"}, + {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5"}, + {file = "greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9"}, + {file = "greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0"}, + {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0"}, + {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f"}, + {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0"}, + {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d"}, + {file = "greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02"}, + {file = "greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671"}, + {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b"}, + {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae"}, + {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b"}, + {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929"}, + {file = "greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b"}, + {file = "greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337"}, + {file = "greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269"}, + {file = "greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681"}, + {file = "greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01"}, + {file = "greenlet-3.2.4-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:18d9260df2b5fbf41ae5139e1be4e796d99655f023a636cd0e11e6406cca7d58"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:671df96c1f23c4a0d4077a325483c1503c96a1b7d9db26592ae770daa41233d4"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:16458c245a38991aa19676900d48bd1a6f2ce3e16595051a4db9d012154e8433"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df"}, + {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594"}, + {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98"}, + {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:28a3c6b7cd72a96f61b0e4b2a36f681025b60ae4779cc73c1535eb5f29560b10"}, + {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:52206cd642670b0b320a1fd1cbfd95bca0e043179c1d8a045f2c6109dfe973be"}, + {file = "greenlet-3.2.4-cp39-cp39-win32.whl", hash = "sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b"}, + {file = "greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb"}, + {file = "greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d"}, +] + +[package.extras] +docs = ["Sphinx", "furo"] +test = ["objgraph", "psutil", "setuptools"] + [[package]] name = "h11" version = "0.16.0" @@ -1087,6 +1167,28 @@ docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-a test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] type = ["mypy (>=1.11.2)"] +[[package]] +name = "playwright" +version = "1.56.0" +description = "A high-level API to automate web browsers" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "playwright-1.56.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:b33eb89c516cbc6723f2e3523bada4a4eb0984a9c411325c02d7016a5d625e9c"}, + {file = "playwright-1.56.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b228b3395212b9472a4ee5f1afe40d376eef9568eb039fcb3e563de8f4f4657b"}, + {file = "playwright-1.56.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:0ef7e6fd653267798a8a968ff7aa2dcac14398b7dd7440ef57524e01e0fbbd65"}, + {file = "playwright-1.56.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:404be089b49d94bc4c1fe0dfb07664bda5ffe87789034a03bffb884489bdfb5c"}, + {file = "playwright-1.56.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64cda7cf4e51c0d35dab55190841bfcdfb5871685ec22cb722cd0ad2df183e34"}, + {file = "playwright-1.56.0-py3-none-win32.whl", hash = "sha256:d87b79bcb082092d916a332c27ec9732e0418c319755d235d93cc6be13bdd721"}, + {file = "playwright-1.56.0-py3-none-win_amd64.whl", hash = "sha256:3c7fc49bb9e673489bf2622855f9486d41c5101bbed964638552b864c4591f94"}, + {file = "playwright-1.56.0-py3-none-win_arm64.whl", hash = "sha256:2745490ae8dd58d27e5ea4d9aa28402e8e2991eb84fb4b2fd5fbde2106716f6f"}, +] + +[package.dependencies] +greenlet = ">=3.1.1,<4.0.0" +pyee = ">=13,<14" + [[package]] name = "pluggy" version = "1.5.0" @@ -1271,6 +1373,24 @@ files = [ [package.dependencies] typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +[[package]] +name = "pyee" +version = "13.0.0" +description = "A rough port of Node.js's EventEmitter to Python with a few tricks of its own" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498"}, + {file = "pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37"}, +] + +[package.dependencies] +typing-extensions = "*" + +[package.extras] +dev = ["black", "build", "flake8", "flake8-black", "isort", "jupyter-console", "mkdocs", "mkdocs-include-markdown-plugin", "mkdocstrings[python]", "mypy", "pytest", "pytest-asyncio ; python_version >= \"3.4\"", "pytest-trio ; python_version >= \"3.7\"", "sphinx", "toml", "tox", "trio", "trio ; python_version > \"3.6\"", "trio-typing ; python_version > \"3.6\"", "twine", "twisted", "validate-pyproject[all]"] + [[package]] name = "pygments" version = "2.19.2" @@ -1309,6 +1429,18 @@ tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "python-multipart" +version = "0.0.20" +description = "A streaming multipart parser for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104"}, + {file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"}, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -2142,4 +2274,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" -content-hash = "ca1318b2ae00a1821894d3c4b7dedd2340c7a97801694d79602cf663ba4c109c" +content-hash = "47eeb507e0337b802684ac2787b3ea877ec7f6acc09dc23b2ba8416b9d0a9ec2" diff --git a/pyproject.toml b/pyproject.toml index e9b4657..5cdcff5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,8 @@ anthropic = "^0.74.1" backoff = "^2.2.1" tree-sitter-bash = "^0.25.0" tiktoken = "^0.12.0" +python-multipart = "^0.0.20" +playwright = "^1.56.0" [tool.poetry.dev-dependencies]