diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bc80804 --- /dev/null +++ b/.gitignore @@ -0,0 +1,66 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +*.cover +.hypothesis/ + +# Type checking +.mypy_cache/ +.dmypy.json +dmypy.json +.pytype/ + +# Ruff +.ruff_cache/ + +# Pre-commit +.pre-commit-config.yaml.backup + +# Project specific +working/ +logs/ +*.log + +# Environment files +.env +.env.local diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..87934b1 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,72 @@ +# Modern pre-commit configuration for Python 3.12+ +# Install: pre-commit install +# Update hooks: pre-commit autoupdate +# Run manually: pre-commit run --all-files + +repos: + # Ruff - Fast Python linter and formatter (replaces Black, isort, flake8, pylint) + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.3 + hooks: + # Run the linter + - id: ruff-check + args: [--fix] + # Run the formatter + - id: ruff-format + + # mypy - Static type checking + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.18.2 + hooks: + - id: mypy + additional_dependencies: + - typer + - gitpython + - jinja2 + - pydantic + - claude-agent-sdk + args: [--ignore-missing-imports, --strict] + + # pyupgrade - Automatically upgrade syntax for newer Python + - repo: https://github.com/asottile/pyupgrade + rev: v3.21.0 + hooks: + - id: pyupgrade + args: [--py312-plus] + + # pre-commit-hooks - Collection of useful hooks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-added-large-files + args: [--maxkb=1000] + - id: check-merge-conflict + - id: check-case-conflict + - id: detect-private-key + + # Bandit - Security linting for Python + - repo: https://github.com/PyCQA/bandit + rev: 1.8.6 + hooks: + - id: bandit + args: [-c, pyproject.toml] + additional_dependencies: ["bandit[toml]"] + + # interrogate - Docstring coverage checker + - repo: https://github.com/econchick/interrogate + rev: 1.7.0 + hooks: + - id: interrogate + args: [--config=pyproject.toml] + pass_filenames: false + + # Commitizen - Conventional commits + - repo: https://github.com/commitizen-tools/commitizen + rev: v4.9.1 + hooks: + - id: commitizen + stages: [commit-msg] diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bacee8c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,577 @@ +# Agent Development Best Practices + +This document outlines the coding standards and best practices for developing agents in Prowler Studio. Following these guidelines ensures maintainable, scalable, and high-quality code. + +## Table of Contents + +- [Architecture Principles](#architecture-principles) +- [Code Organization](#code-organization) +- [Method Decomposition](#method-decomposition) +- [Type Safety](#type-safety) +- [Named Parameters](#named-parameters) +- [Return Types with Pydantic](#return-types-with-pydantic) +- [Constants Management](#constants-management) +- [File Structure](#file-structure) +- [Examples](#examples) + +--- + +## Architecture Principles + +### Base Agent Class + +All agents inherit from the `Agent` base class ([src/agents/base.py](src/agents/base.py)), which provides: + +- **`working_dir`**: Path to the working directory +- **`config`**: Agent-specific configuration from kwargs +- **`_process_agent_messages(client)`**: Shared method for processing Claude SDK responses + - Streams `TextBlock` content to console and logs + - Logs `ToolUseBlock` inputs at DEBUG level (`[TOOL CALL]`) + - Logs `ToolResultBlock` outputs at DEBUG level (`[TOOL RESULT]`) + +Agents must implement the abstract `run()` method. + +### Single Responsibility Principle (SRP) + +Each method should have **one clear purpose**. If a method does multiple things, break it down. + +**❌ Bad Example:** +```python +async def run(self): + # 127 lines doing: loading prompts, creating options, running agent, + # discovering checks, verifying, fixing, and returning results + ... +``` + +**✅ Good Example:** +```python +async def run(self): + """High-level orchestration.""" + prompt = self._load_implementation_prompt() + options = self._create_claude_options() + + async with ClaudeSDKClient(options=options) as client: + await client.query(prompt) + discovery_result = self._discover_check_info() + verification_result = await self._verify_and_fix_check(...) + + return CheckImplementationResult(...) +``` + +--- + +## Code Organization + +### 1. File Structure + +Organize agent code into separate, focused files: + +``` +src/agents/implementation/ +├── __init__.py # Public API exports +├── agent.py # Agent business logic +├── models.py # Pydantic models (data structures) +└── prompts/ # Jinja templates + ├── implement_check.jinja + └── fix_check.jinja +``` + +### 2. Separation of Concerns + +**Models should be separate from business logic:** + +- **models.py**: Data structures, validation, serialization +- **agent.py**: Business logic, orchestration, workflows +- **__init__.py**: Public API surface + +**Benefits:** +- ✅ Easier to test models independently +- ✅ Prevents circular imports +- ✅ Models can be reused across agents +- ✅ Cleaner code organization + +--- + +## Method Decomposition + +### Break Down Large Methods + +Keep methods **under 50 lines**. Extract logical sections into private methods. + +**✅ Good Method Decomposition:** + +```python +class ChecKreatorAgent(Agent): + # Setup methods + def _load_implementation_prompt(self) -> str: ... + def _load_fix_prompt(self, check_name: str, message: str) -> str: ... + def _create_claude_options(self) -> ClaudeAgentOptions: ... + + # Inherited from Agent base class: + # async def _process_agent_messages(self, client: ClaudeSDKClient) -> None: ... + + # Business logic methods + def _discover_check_info(self) -> CheckDiscoveryResult: ... + async def _verify_and_fix_check(self, ...) -> CheckVerificationResult: ... + + # Main orchestration (clean and readable) + async def run(self) -> CheckImplementationResult: ... +``` + +**Benefits:** +- ✅ Each method has a clear purpose +- ✅ Easier to test individual components +- ✅ Better code reusability +- ✅ Simpler to understand and maintain + +--- + +## Type Safety + +### Always Use Type Hints + +**Every variable, parameter, and return type must have a type hint.** + +**✅ Good Type Hints:** + +```python +def _load_fix_prompt(self, check_name: str, verification_message: str) -> str: + """Load the check fix prompt template.""" + fix_prompt_path: Path = Path(__file__).parent / "prompts" / "fix_check.jinja" + return load_prompt( + path=fix_prompt_path, + context={ + "check_name": check_name, + "verification_message": verification_message, + }, + ) + +async def run(self) -> CheckImplementationResult: + """Implement a Prowler check.""" + implement_check_prompt: str = self._load_implementation_prompt() + options: ClaudeAgentOptions = self._create_claude_options() + + discovery_result: CheckDiscoveryResult = self._discover_check_info() + verification_result: CheckVerificationResult = await self._verify_and_fix_check(...) + + return CheckImplementationResult(...) +``` + +**Benefits:** +- ✅ IDE autocomplete and IntelliSense +- ✅ Early error detection with type checkers (mypy, pyright) +- ✅ Self-documenting code +- ✅ Easier refactoring + +--- + +## Named Parameters + +### Always Use Named Parameters in Function Calls + +**Never rely on positional arguments** (except for single, obvious parameters like `Path()`). + +**❌ Bad Example:** +```python +result = self._load_fix_prompt(check_name, message) +await self._verify_and_fix_check(client, check_name, check_provider) +load_prompt(prompt_path, {"check_ticket": self.check_ticket}) +``` + +**✅ Good Example:** +```python +result = self._load_fix_prompt( + check_name=check_name, + verification_message=message +) + +await self._verify_and_fix_check( + client=client, + check_name=check_name, + check_provider=check_provider +) + +load_prompt( + path=prompt_path, + context={"check_ticket": self.check_ticket} +) +``` + +**Benefits:** +- ✅ Self-documenting code +- ✅ Prevents parameter order mistakes +- ✅ Easier to refactor (add/remove/reorder parameters) +- ✅ Clearer intent + +--- + +## Return Types with Pydantic + +### Never Return Tuples or Plain Dicts + +**Always use Pydantic models** for return types. This makes the API explicit and self-documenting. + +**❌ Bad Example:** +```python +def _discover_check_info(self) -> tuple[bool, str, str]: + """What are these three values? Must check definition!""" + return True, "my_check", "aws" + +# Usage - unclear what each value represents +success, name, provider = self._discover_check_info() +``` + +**✅ Good Example:** +```python +# models.py +class CheckDiscoveryResult(BaseModel): + """Result of discovering a check from repository changes.""" + success: bool = Field(description="Whether check discovery was successful") + check_name: str = Field(default="", description="Name of the discovered check") + check_provider: str = Field( + default="", description="Provider of the check (e.g., 'aws', 'azure')" + ) + +# agent.py +def _discover_check_info(self) -> CheckDiscoveryResult: + """Crystal clear return type.""" + return CheckDiscoveryResult( + success=True, + check_name="my_check", + check_provider="aws" + ) + +# Usage - explicit and clear +result = self._discover_check_info() +if result.success: + print(f"Found: {result.check_name} for {result.check_provider}") +``` + +**Benefits:** +- ✅ No need to check function definition to understand return structure +- ✅ IDE autocomplete on result fields +- ✅ Type validation at runtime +- ✅ Easy serialization/deserialization (`.model_dump()`, `.model_validate()`) +- ✅ Field descriptions serve as inline documentation +- ✅ Easier to add/modify fields without breaking callers + +### Pydantic Model Best Practices + +```python +from pydantic import BaseModel, Field + +class CheckImplementationResult(BaseModel): + """Always include a docstring.""" + + # Use Field() with descriptions + success: bool = Field(description="Whether implementation was successful") + check_name: str = Field(default="", description="Name of the implemented check") + message: str = Field(default="", description="Result message") + attempts: int = Field(default=0, description="Number of verification attempts") + + # Use Optional for nullable fields + error: str | None = Field(default=None, description="Error message if failed") + + # Provide sensible defaults when appropriate +``` + +--- + +## Constants Management + +### Replace All Magic Numbers with Named Constants + +**Magic numbers** make code hard to understand and maintain. Use class-level constants with descriptive names. + +**❌ Bad Example:** +```python +tools_server = create_sdk_mcp_server(name="utils", version="1.0.0", tools=[mkcheck]) + +max_attempts = 5 +while attempt < 5 and not success: + ... + +check_provider = check_path.parents[2].name # What does 2 mean? +``` + +**✅ Good Example:** +```python +class ChecKreatorAgent(Agent): + """Agent that implements Prowler checks from tickets.""" + + # MCP Server Configuration + MCP_SERVER_NAME: str = "utils" + MCP_SERVER_VERSION: str = "1.0.0" + + # Check Verification + MAX_CHECK_VERIFICATION_ATTEMPTS: int = 5 + + # Path Navigation + PROVIDER_PATH_LEVEL: int = 3 # Number of parent levels to reach provider + + def _create_claude_options(self) -> ClaudeAgentOptions: + tools_server = create_sdk_mcp_server( + name=self.MCP_SERVER_NAME, + version=self.MCP_SERVER_VERSION, + tools=[mkcheck], + ) + + async def _verify_and_fix_check(self, ...) -> CheckVerificationResult: + max_attempts: int = self.MAX_CHECK_VERIFICATION_ATTEMPTS + while attempt < max_attempts and not success: + ... + + def _discover_check_info(self) -> CheckDiscoveryResult: + check_provider = check_path.parents[self.PROVIDER_PATH_LEVEL - 1].name +``` + +**Benefits:** +- ✅ Self-documenting code +- ✅ Easy to modify values in one place +- ✅ No mysterious numbers scattered throughout code +- ✅ Clear intent and meaning + +### Constant Naming Convention + +- Use `UPPER_CASE_WITH_UNDERSCORES` for constants +- Group related constants together +- Add comments when the purpose isn't obvious +- Type hint constants for additional clarity + +--- + +## File Structure + +### Complete Agent Package Example + +``` +src/agents/implementation/ +├── __init__.py +├── agent.py +├── models.py +└── prompts/ + ├── implement_check.jinja + └── fix_check.jinja +``` + +**__init__.py** - Public API: +```python +"""Implementation agent for creating Prowler checks.""" + +from agents.implementation.agent import ChecKreatorAgent +from agents.implementation.models import ( + CheckDiscoveryResult, + CheckVerificationResult, + CheckImplementationResult, +) + +__all__ = [ + "ChecKreatorAgent", + "CheckDiscoveryResult", + "CheckVerificationResult", + "CheckImplementationResult", +] +``` + +**models.py** - Data structures: +```python +"""Pydantic models for ChecKreatorAgent results.""" + +from pydantic import BaseModel, Field + + +class CheckDiscoveryResult(BaseModel): + """Result of discovering a check from repository changes.""" + success: bool = Field(description="Whether check discovery was successful") + check_name: str = Field(default="", description="Name of the discovered check") + check_provider: str = Field( + default="", description="Provider of the check (e.g., 'aws', 'azure')" + ) +``` + +**agent.py** - Business logic: +```python +"""Implementation agent for creating Prowler checks.""" + +from pathlib import Path +from typing import Any + +from agents.base import Agent +from agents.implementation.models import ( + CheckDiscoveryResult, + CheckVerificationResult, + CheckImplementationResult, +) + + +class ChecKreatorAgent(Agent): + """Agent that implements Prowler checks from tickets.""" + + # Constants + MCP_SERVER_NAME: str = "utils" + MAX_CHECK_VERIFICATION_ATTEMPTS: int = 5 + + def __init__(self, working_dir: Path, check_ticket: str, prowler_repo: Repo, **kwargs): + super().__init__(working_dir, **kwargs) + self.check_ticket: str = check_ticket + self.prowler_repo: Repo = prowler_repo + + # Private helper methods + def _load_implementation_prompt(self) -> str: ... + def _discover_check_info(self) -> CheckDiscoveryResult: ... + + # Public interface + async def run(self) -> CheckImplementationResult: ... +``` + +--- + +## Examples + +### Complete Example: Before and After + +**❌ BEFORE (Poor Practices):** + +```python +class Agent: + def run(self): + # 127 lines of code + prompt_path = Path(__file__).parent / "prompts" / "implement_check.jinja" + prompt = load_prompt(prompt_path, {"check_ticket": self.ticket}) + + server = create_sdk_mcp_server("utils", "1.0.0", [mkcheck]) + + # ... lots of code ... + + max_attempts = 5 + attempt = 0 + success = False + message = "" + + while attempt < 5 and not success: + # ... more code ... + pass + + # What does this tuple contain? + return success, check_name, message, attempt +``` + +**✅ AFTER (Best Practices):** + +```python +# models.py +class CheckImplementationResult(BaseModel): + """Result of implementing a Prowler check.""" + success: bool = Field(description="Whether implementation was successful") + check_name: str = Field(default="", description="Name of the implemented check") + message: str = Field(default="", description="Result message") + attempts: int = Field(default=0, description="Number of verification attempts") + + +# agent.py +class ChecKreatorAgent(Agent): + """Agent that implements Prowler checks from tickets.""" + + # Constants + MCP_SERVER_NAME: str = "utils" + MCP_SERVER_VERSION: str = "1.0.0" + MAX_CHECK_VERIFICATION_ATTEMPTS: int = 5 + + def _load_implementation_prompt(self) -> str: + """Load the check implementation prompt template.""" + prompt_path: Path = Path(__file__).parent / "prompts" / "implement_check.jinja" + return load_prompt(path=prompt_path, context={"check_ticket": self.check_ticket}) + + def _create_claude_options(self) -> ClaudeAgentOptions: + """Create Claude agent options with tools and MCP servers.""" + tools_server: Any = create_sdk_mcp_server( + name=self.MCP_SERVER_NAME, + version=self.MCP_SERVER_VERSION, + tools=[mkcheck], + ) + return ClaudeAgentOptions( + allowed_tools=["Read", "Write", "Edit", "Bash", "Glob", "Grep"], + mcp_servers={"utils": tools_server}, + permission_mode="bypassPermissions", + cwd=str(self.working_dir), + ) + + async def _verify_and_fix_check( + self, client: ClaudeSDKClient, check_name: str, check_provider: str + ) -> CheckVerificationResult: + """Verify the check implementation and fix issues in a loop.""" + max_attempts: int = self.MAX_CHECK_VERIFICATION_ATTEMPTS + attempt: int = 0 + success: bool = False + message: str = "" + + while attempt < max_attempts and not success: + attempt += 1 + success, message = verify_check_loaded( + check_name=check_name, + provider=check_provider, + prowler_directory=Path(self.prowler_repo.working_dir), + ) + # ... handle failures ... + + return CheckVerificationResult( + success=success, + message=message, + attempts=attempt + ) + + async def run(self) -> CheckImplementationResult: + """Implement a Prowler check.""" + implement_check_prompt: str = self._load_implementation_prompt() + options: ClaudeAgentOptions = self._create_claude_options() + + async with ClaudeSDKClient(options=options) as client: + await client.query(implement_check_prompt) + + discovery_result: CheckDiscoveryResult = self._discover_check_info() + if not discovery_result.success: + return CheckImplementationResult( + success=False, + error="No check folders found in repository changes", + ) + + verification_result: CheckVerificationResult = await self._verify_and_fix_check( + client=client, + check_name=discovery_result.check_name, + check_provider=discovery_result.check_provider, + ) + + return CheckImplementationResult( + success=verification_result.success, + check_name=discovery_result.check_name, + message=verification_result.message, + attempts=verification_result.attempts, + ) +``` + +--- + +## Checklist for New Agents + +When creating a new agent, ensure: + +- [ ] **File Structure**: Separate models.py, agent.py, __init__.py +- [ ] **Type Hints**: All variables, parameters, and return types are typed +- [ ] **Named Parameters**: All function calls use named parameters +- [ ] **Pydantic Models**: No tuples or plain dicts for return types +- [ ] **Constants**: All magic numbers replaced with named constants +- [ ] **Method Size**: No method exceeds 50 lines +- [ ] **Single Responsibility**: Each method has one clear purpose +- [ ] **Docstrings**: All classes and methods have docstrings +- [ ] **Public API**: __init__.py exports only what's needed +- [ ] **Tests**: Unit tests for all public methods + +--- + +## Additional Resources + +- [PEP 484 - Type Hints](https://peps.python.org/pep-0484/) +- [Pydantic Documentation](https://docs.pydantic.dev/) +- [Python Style Guide (PEP 8)](https://peps.python.org/pep-0008/) + +**Remember: These practices exist to make our codebase maintainable and our team productive. Follow them consistently!** diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c70fe31 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright @ 2025 ProwlerPro Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e26d04a --- /dev/null +++ b/README.md @@ -0,0 +1,314 @@ +# Prowler Studio + +Extensible framework for automating Prowler security check development using Claude Code agents. + +## Overview + +Prowler Studio uses the Claude Agent SDK to automate the creation of security checks for Prowler. The architecture separates different tasks into independent agents that can be run sequentially, with built-in verification and error correction loops. + +## Features + +- **Agent-Based**: Each task (implementation, testing, PR creation) is a separate agent +- **Self-Correcting**: Automated verification and fix loops ensure checks load correctly +- **Claude Agent SDK**: Leverages Claude's latest agent capabilities with MCP server integration +- **Extensible**: Add new agents without modifying existing code +- **Reusable Tools**: Share utilities across all agents +- **Type-Safe**: Full Python type hints and strict mypy checking + +## Quick Start + +### Installation + +```bash +# Install dependencies +uv sync + +# Activate virtual environment +source .venv/bin/activate +``` + +### Usage + +Create a Prowler check from a local ticket file: + +```bash +prowler-studio feat/my_new_check --ticket check_ticket.md +``` + +Create a Prowler check from a Jira ticket: + +```bash +prowler-studio feat/my_new_check --jira-url https://mycompany.atlassian.net/browse/PROJ-123 +``` + +With custom working directory: + +```bash +prowler-studio feat/my_new_check -t check_ticket.md -w ./custom_work +``` + +Keep changes local (no push or PR creation): + +```bash +prowler-studio -b feat/my-check --ticket check_ticket.md --local +``` + +> **Note**: You must provide either `--ticket` or `--jira-url`, not both. + +### CLI Options + +| Option | Short | Description | +|--------|-------|-------------| +| `--branch` | `-b` | Branch name (default: `feat/-` or `feat/`) | +| `--ticket` | `-t` | Path to the markdown check ticket file | +| `--jira-url` | `-j` | Jira ticket URL (e.g., `https://mycompany.atlassian.net/browse/PROJ-123`) | +| `--working-dir` | `-w` | Path to the working directory (default: `./working`) | +| `--no-worktree` | | Legacy mode: work directly on main clone instead of using worktrees | +| `--cleanup-worktree` | | Remove worktree after successful PR creation | +| `--local` | | Keep changes local only (no push, no PR creation) | + +## Project Structure + +``` +prowler_studio/ +├── src/ +│ ├── core/ +│ │ ├── main.py # CLI entry point +│ │ └── exceptions.py # Custom exceptions +│ ├── agents/ +│ │ ├── base.py # Agent base class +│ │ ├── implementation/ # ChecKreatorAgent for check creation +│ │ │ ├── agent.py # Main agent implementation +│ │ │ ├── models.py # Data models +│ │ │ └── prompts/ # Jinja2 prompt templates +│ │ ├── testing/ # TestingAgent for test generation +│ │ │ ├── agent.py +│ │ │ ├── models.py +│ │ │ └── prompts/ +│ │ ├── review/ # ReviewAgent for code review +│ │ │ ├── agent.py +│ │ │ ├── models.py +│ │ │ └── prompts/ +│ │ ├── compliance_mapping/ # ComplianceMappingAgent +│ │ │ ├── agent.py +│ │ │ ├── models.py +│ │ │ └── prompts/ +│ │ └── pr_creation/ # PRCreationAgent for PR workflow +│ │ ├── agent.py +│ │ ├── models.py +│ │ └── prompts/ +│ ├── tools/ # Shared tools +│ │ ├── git.py # Git operations +│ │ ├── prowler.py # Prowler-specific tools +│ │ ├── skills.py # AI skills setup +│ │ ├── jira.py # Jira URL parsing +│ │ ├── jira_client.py # Jira API client +│ │ └── models.py # Tool data models +│ └── utils/ # Utilities +│ ├── prompts.py # Prompt loading utilities +│ └── logging.py # Logging utilities (agent output + tool calls) +└── pyproject.toml # Project configuration +``` + +## Architecture + +### Agents + +Agents are self-contained units that perform specific tasks. Each agent: +- Inherits from `Agent` base class in [src/agents/base.py](src/agents/base.py) +- Implements `async run()` method +- Returns a typed result object + +**Current Agents:** +- **ChecKreatorAgent** ([src/agents/implementation/agent.py](src/agents/implementation/agent.py)): Creates Prowler checks from tickets with automated verification +- **TestingAgent** ([src/agents/testing/agent.py](src/agents/testing/agent.py)): Generates and runs tests for checks with auto-fix loop +- **ReviewAgent** ([src/agents/review/agent.py](src/agents/review/agent.py)): Reviews check implementations for quality and best practices +- **ComplianceMappingAgent** ([src/agents/compliance_mapping/agent.py](src/agents/compliance_mapping/agent.py)): Analyzes checks and adds compliance framework mappings +- **PRCreationAgent** ([src/agents/pr_creation/agent.py](src/agents/pr_creation/agent.py)): Commits changes and creates pull requests + +### ChecKreatorAgent Flow + +The implementation agent follows this workflow: + +1. **Setup**: Load prompts and configure Claude Agent SDK with MCP tools +2. **Implementation**: Claude agent creates the check based on ticket requirements +3. **Discovery**: Automatically detect the created check from git changes +4. **Verification Loop** (up to 5 attempts): + - Run `prowler --list-checks` to verify check loads + - If verification fails, provide error feedback to Claude + - Claude fixes the issues and verification runs again +5. **Result**: Return success/failure with check details + +Key features: +- Uses Claude Agent SDK with custom MCP server for `mkcheck` tool +- Jinja2 templates for prompts in [src/agents/implementation/prompts/](src/agents/implementation/prompts/) +- Typed result models: `CheckImplementationResult`, `CheckDiscoveryResult`, `CheckVerificationResult` + +### Tools + +#### Git Tools ([src/tools/git.py](src/tools/git.py)) +- `prepare_repo_for_work()`: Stash changes, switch branches, pull updates + +#### Prowler Tools ([src/tools/prowler.py](src/tools/prowler.py)) +- `mkcheck` (MCP tool): Create check folder structure +- `install_prowler_dependencies()`: Install Prowler with poetry +- `verify_check_loaded()`: Verify check appears in `prowler --list-checks` + +#### Skills Tools ([src/tools/skills.py](src/tools/skills.py)) +- `setup_prowler_skills()`: Configure AI skills by running `skills/setup.sh --claude` + +#### Jira Tools ([src/tools/jira.py](src/tools/jira.py)) +- `parse_jira_url()`: Parse Jira ticket URL into components (site_url, project_key, issue_key) + +### Logging & Observability + +All workflow runs are logged to timestamped files in the `logs/` directory. + +**Log Levels:** +- **INFO** (console + file): Workflow progress, agent status, success/failure messages +- **DEBUG** (file only): Agent text output, tool calls with inputs/outputs + +**DEBUG-level tool call logging** captures all Claude agent tool usage: +``` +2025-02-02 10:30:45 | DEBUG | [TOOL CALL] Read (id=tool_abc123) +{ + "file_path": "/path/to/file.py" +} +2025-02-02 10:30:46 | DEBUG | [TOOL RESULT] Read [OK] +1: def example(): +2: return "hello" +``` + +This is useful for debugging agent behavior and understanding what tools were invoked during a workflow run. + +### Main CLI Orchestration + +The CLI in [src/core/main.py](src/core/main.py) orchestrates agent execution through five stages: + +```python +# 1. Prepare Prowler repository +repo = Repo.clone_from(PROWLER_REPO_URL, prowler_path) +prepare_repo_for_work(repo, branch_name) +setup_prowler_skills(prowler_path) +install_prowler_dependencies(prowler_path) + +# 2. Implementation stage - Create check from ticket +impl_agent = ChecKreatorAgent(working_dir=prowler_path, ...) +impl_result = asyncio.run(impl_agent.run()) + +# 3. Testing stage - Generate and run tests +test_agent = TestingAgent(working_dir=prowler_path, check_name=impl_result.check_name, ...) +test_result = asyncio.run(test_agent.run()) + +# 4. Review stage - Code review and quality checks +review_agent = ReviewAgent(working_dir=prowler_path, check_name=impl_result.check_name, ...) +review_result = asyncio.run(review_agent.run()) + +# 5. Compliance mapping stage - Add framework mappings +compliance_agent = ComplianceMappingAgent(working_dir=prowler_path, ...) +compliance_result = asyncio.run(compliance_agent.run()) + +# 6. PR creation stage - Commit and create pull request +pr_agent = PRCreationAgent(working_dir=prowler_path, branch_name=branch_name, ...) +pr_result = asyncio.run(pr_agent.run()) +``` + +## Adding a New Agent + +1. **Create agent structure:** +```bash +mkdir -p src/agents/testing +touch src/agents/testing/{__init__.py,agent.py,models.py} +mkdir src/agents/testing/prompts +``` + +2. **Implement the agent:** +```python +from pathlib import Path +from agents.base import Agent +from dataclasses import dataclass + +@dataclass +class TestingResult: + success: bool + tests_created: int + message: str = "" + +class TestingAgent(Agent): + def __init__(self, working_dir: Path, check_name: str, **kwargs): + super().__init__(working_dir, **kwargs) + self.check_name = check_name + + async def run(self) -> TestingResult: + # Agent implementation using Claude SDK + # Load prompts, configure Claude options, run agent + return TestingResult(success=True, tests_created=5) +``` + +3. **Add to main CLI:** +```python +from agents.testing.agent import TestingAgent + +# After ChecKreatorAgent completes +test_agent = TestingAgent( + working_dir=prowler_path, + check_name=result.check_name +) +test_result = asyncio.run(test_agent.run()) +``` + +## Development + +### Pre-commit Features + +The pre-commit hooks automatically run on `git commit` and include: +- **Ruff** - Fast Python linting and formatting +- **mypy** - Static type checking with strict mode +- **pyupgrade** - Automatic Python 3.12+ syntax upgrades +- **Bandit** - Security vulnerability scanning +- **interrogate** - Docstring coverage enforcement (80% minimum) +- **Commitizen** - Conventional commit message validation +- **Common hooks** - Trailing whitespace, YAML/TOML validation, etc. + +### Setup Pre-commit Hooks + +```bash +# Install dev dependencies (includes pre-commit and all tools) +uv sync --extra dev + +# Install pre-commit hooks +pre-commit install +pre-commit install --hook-type commit-msg +``` + +### Running Quality Checks Manually + +```bash +# Run all pre-commit hooks +pre-commit run --all-files + +# Run specific tools +ruff check src/ +mypy src/ +bandit -r src/ +interrogate src/ +``` + +## Dependencies + +- **typer**: CLI framework +- **gitpython**: Git repository operations +- **jinja2**: Template rendering for prompts +- **claude-agent-sdk**: Claude Agent SDK for AI agent orchestration +- **rich**: Terminal formatting and output + +## Configuration + +Configuration is managed through [pyproject.toml](pyproject.toml): +- Tool configurations (ruff, mypy, bandit, interrogate) +- Project metadata and dependencies +- Entry point: `prowler-studio` command + +## License + +[Apache License 2.0](LICENSE) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e0029af --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,130 @@ +[project] +name = "prowler-studio" +version = "0.1.0" +description = "Prowler Studio is a framework for automating Prowler security check development using Claude Code agents." +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "typer>=0.20.0", + "gitpython>=3.1.0", + "jinja2>=3.1.0", + "claude-agent-sdk>=0.1.49", + "httpx>=0.27.0", +] + +[project.optional-dependencies] +dev = [ + "pre-commit>=4.0.0", + "ruff>=0.8.0", + "mypy>=1.13.0", + "bandit>=1.8.0", + "interrogate>=1.7.0", + "pytest>=8.0.0", +] + +[project.scripts] +prowler-studio = "core.main:app" + + +[tool.uv] +package = true + +# Ruff configuration - Modern Python linter and formatter +[tool.ruff] +line-length = 88 +target-version = "py312" + +# Enable pycodestyle (E, W), Pyflakes (F), isort (I), and more +lint.select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # Pyflakes + "I", # isort + "N", # pep8-naming + "UP", # pyupgrade + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "SIM", # flake8-simplify + "TCH", # flake8-type-checking + "TID", # flake8-tidy-imports + "RUF", # Ruff-specific rules +] + +lint.ignore = [ + "E501", # Line too long (handled by formatter) +] + +# Exclude common directories +exclude = [ + ".git", + ".venv", + "__pycache__", + "build", + "dist", + "*.egg-info", + "working", +] + +# Allow autofix for all enabled rules +lint.fixable = ["ALL"] +lint.unfixable = [] + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401"] # Allow unused imports in __init__.py + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" + +# mypy configuration - Static type checking +[tool.mypy] +python_version = "3.12" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_any_unimported = false +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +check_untyped_defs = true +strict_equality = true + +# Ignore missing imports for third-party packages without type stubs +[[tool.mypy.overrides]] +module = [ + "git.*", + "typer.*", + "jinja2.*", +] +ignore_missing_imports = true + +# Bandit configuration - Security linting +[tool.bandit] +exclude_dirs = [".venv", "tests", "working"] +skips = ["B101"] # Skip assert_used (commonly used in tests) + +# Pytest configuration +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] + +# Interrogate configuration - Docstring coverage +[tool.interrogate] +ignore-init-method = true +ignore-init-module = false +ignore-magic = false +ignore-semiprivate = false +ignore-private = false +ignore-property-decorators = false +ignore-module = false +ignore-nested-functions = false +ignore-nested-classes = true +ignore-setters = false +fail-under = 80 +exclude = [".venv", "tests", "working"] +verbose = 1 +quiet = false +color = true diff --git a/src/agents/__init__.py b/src/agents/__init__.py new file mode 100644 index 0000000..81d5716 --- /dev/null +++ b/src/agents/__init__.py @@ -0,0 +1,17 @@ +"""Agent system for Prowler Studio.""" + +from agents.base import Agent +from agents.compliance_mapping.agent import ComplianceMappingAgent +from agents.implementation.agent import ChecKreatorAgent +from agents.pr_creation.agent import PRCreationAgent +from agents.review.agent import ReviewAgent +from agents.testing.agent import TestingAgent + +__all__ = [ + "Agent", + "ChecKreatorAgent", + "ComplianceMappingAgent", + "PRCreationAgent", + "ReviewAgent", + "TestingAgent", +] diff --git a/src/agents/base.py b/src/agents/base.py new file mode 100644 index 0000000..e28eb57 --- /dev/null +++ b/src/agents/base.py @@ -0,0 +1,104 @@ +"""Base agent class for all agents.""" + +from abc import ABC, abstractmethod +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from claude_agent_sdk import ClaudeSDKClient + +from claude_agent_sdk import ( + AssistantMessage, + ResultMessage, + TextBlock, + ToolResultBlock, + ToolUseBlock, +) + +from utils.logging import log_agent_output, log_tool_call + + +class Agent(ABC): + """ + Simple base class for agents. + + Each agent is just a self-contained unit that does one thing. + No complex lifecycle, no status tracking - just execute the task. + """ + + def __init__(self, working_dir: Path, **kwargs: Any) -> None: + """ + Initialize the agent. + + Args: + working_dir: Working directory for the agent + **kwargs: Agent-specific configuration + """ + self.working_dir = working_dir + self.config = kwargs + self._tool_names_by_id: dict[str, str] = {} + + @abstractmethod + async def run(self, **inputs: Any) -> Any: + """ + Run the agent. + + Args: + **inputs: Input data for the agent + + Returns: + Agent-specific result object + + Raises: + AgentError: If agent execution fails + """ + + async def _process_agent_messages( + self, client: "ClaudeSDKClient", *, capture_text: bool = False + ) -> str: + """ + Process and stream messages from the Claude agent. + + Handles TextBlock (prints + logs), ToolUseBlock (logs input at DEBUG), + and ToolResultBlock (logs output at DEBUG). + + Args: + client: Claude SDK client instance + capture_text: If True, return captured text output + + Returns: + Captured text if capture_text=True, empty string otherwise + """ + self._tool_names_by_id.clear() # Reset for new conversation + captured: list[str] = [] + + async for message in client.receive_response(): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(block.text, end="", flush=True) + log_agent_output(block.text) + if capture_text: + captured.append(block.text) + elif isinstance(block, ToolUseBlock): + self._tool_names_by_id[block.id] = block.name + log_tool_call( + tool_name=block.name, + tool_input=block.input, + tool_use_id=block.id, + ) + elif isinstance(block, ToolResultBlock): + tool_name = self._tool_names_by_id.get( + block.tool_use_id, "Unknown" + ) + log_tool_call( + tool_name=tool_name, + tool_output=block.content, + is_error=block.is_error or False, + tool_use_id=block.tool_use_id, + ) + elif isinstance(message, ResultMessage): + print() + break + + return "".join(captured) if capture_text else "" diff --git a/src/agents/compliance_mapping/__init__.py b/src/agents/compliance_mapping/__init__.py new file mode 100644 index 0000000..531e95b --- /dev/null +++ b/src/agents/compliance_mapping/__init__.py @@ -0,0 +1,10 @@ +"""Compliance mapping agent for Prowler checks.""" + +from agents.compliance_mapping.agent import ComplianceMappingAgent +from agents.compliance_mapping.models import ComplianceMapping, ComplianceMappingResult + +__all__ = [ + "ComplianceMapping", + "ComplianceMappingAgent", + "ComplianceMappingResult", +] diff --git a/src/agents/compliance_mapping/agent.py b/src/agents/compliance_mapping/agent.py new file mode 100644 index 0000000..f129343 --- /dev/null +++ b/src/agents/compliance_mapping/agent.py @@ -0,0 +1,144 @@ +"""Compliance mapping agent for suggesting and adding compliance framework mappings.""" + +from __future__ import annotations + +import logging +import re +from pathlib import Path +from typing import TYPE_CHECKING, Any, ClassVar + +if TYPE_CHECKING: + from git import Repo + +from claude_agent_sdk import ( + ClaudeAgentOptions, + ClaudeSDKClient, +) + +from agents.base import Agent +from agents.compliance_mapping.models import ComplianceMappingResult +from utils.prompts import load_prompt + + +class ComplianceMappingAgent(Agent): + """Agent that analyzes checks and adds compliance framework mappings.""" + + ALLOWED_TOOLS: ClassVar[list[str]] = [ + "Read", + "Edit", + "Glob", + "Grep", + "Bash", + ] + COMPLIANCE_FILE_PATTERN: ClassVar[str] = r"^prowler/compliance/{provider}/.*\.json$" + + def __init__( + self, + working_dir: Path, + check_name: str, + check_provider: str, + prowler_repo: Repo, + check_ticket: str | None = None, + **kwargs: Any, + ) -> None: + super().__init__(working_dir, **kwargs) + self.check_name: str = check_name + self.check_provider: str = check_provider + self.prowler_repo: Repo = prowler_repo + self.check_ticket: str | None = check_ticket + self._files_modified: list[str] = [] + + async def run(self, **inputs: Any) -> ComplianceMappingResult: + """ + Analyze the check and add compliance framework mappings. + + Returns: + ComplianceMappingResult with mapping information + """ + logging.info("[bold cyan]Running compliance mapping agent...[/bold cyan]") + + # Load prompt and create options + mapping_prompt: str = self._load_mapping_prompt() + options: ClaudeAgentOptions = self._create_claude_options() + + # Track files before running + initial_modified: set[str] = self._get_modified_compliance_files() + + async with ClaudeSDKClient(options=options) as client: + logging.info("[yellow]Analyzing compliance mappings...[/yellow]") + await client.query(mapping_prompt) + await self._process_agent_messages(client=client) + + # Check which files were modified + final_modified: set[str] = self._get_modified_compliance_files() + new_modifications: set[str] = final_modified - initial_modified + self._files_modified = sorted(new_modifications) + + changes_made: bool = len(self._files_modified) > 0 + + if changes_made: + logging.info( + f"[green]✓ Added compliance mappings to {len(self._files_modified)} file(s)[/green]" + ) + for file_path in self._files_modified: + logging.info(f" - {file_path}") + else: + logging.warning("No compliance mappings were added") + + return ComplianceMappingResult( + success=True, + check_name=self.check_name, + check_provider=self.check_provider, + mappings_added=len(self._files_modified), + files_modified=self._files_modified, + changes_made=changes_made, + message=f"Compliance mapping completed - {len(self._files_modified)} file(s) modified", + ) + + def _load_mapping_prompt(self) -> str: + """Load the compliance mapping prompt template.""" + prompt_path: Path = ( + Path(__file__).parent / "prompts" / "analyze_compliance.jinja" + ) + # Extract service from check name (first part before underscore) + service: str = self.check_name.split("_")[0] + return load_prompt( + path=prompt_path, + context={ + "check_name": self.check_name, + "check_provider": self.check_provider, + "service": service, + "check_ticket": self.check_ticket, + }, + ) + + def _create_claude_options(self) -> ClaudeAgentOptions: + """Create Claude agent options with tools.""" + return ClaudeAgentOptions( + allowed_tools=self.ALLOWED_TOOLS, + permission_mode="bypassPermissions", + cwd=str(self.working_dir), + ) + + def _get_modified_compliance_files(self) -> set[str]: + """Get the set of modified compliance JSON files.""" + try: + diff = self.prowler_repo.git.diff("--name-only") + if not diff: + return set() + + modified_files: list[str] = diff.split("\n") + compliance_files: set[str] = set() + + # Filter for compliance files for this provider + compliance_pattern = re.compile( + self.COMPLIANCE_FILE_PATTERN.format(provider=self.check_provider) + ) + + for file_path in modified_files: + if compliance_pattern.match(file_path): + compliance_files.add(file_path) + + return compliance_files + except Exception: + return set() diff --git a/src/agents/compliance_mapping/models.py b/src/agents/compliance_mapping/models.py new file mode 100644 index 0000000..6cd7202 --- /dev/null +++ b/src/agents/compliance_mapping/models.py @@ -0,0 +1,49 @@ +"""Pydantic models for ComplianceMappingAgent results.""" + +from typing import Literal + +from pydantic import BaseModel, Field + + +class ComplianceMapping(BaseModel): + """A suggested compliance framework mapping.""" + + framework_id: str = Field( + description="Compliance framework ID (e.g., 'cis_4.0_aws')" + ) + framework_name: str = Field( + description="Human-readable framework name (e.g., 'CIS Amazon Web Services Foundations Benchmark v4.0')" + ) + requirement_id: str = Field( + description="Requirement ID within the framework (e.g., '2.1.2')" + ) + requirement_name: str = Field(description="Human-readable requirement name") + confidence: Literal["high", "medium", "low"] = Field( + description="Confidence level of the mapping suggestion" + ) + reason: str = Field(description="Explanation of why this mapping is suggested") + + +class ComplianceMappingResult(BaseModel): + """Result of compliance mapping analysis.""" + + success: bool = Field( + description="Whether the compliance mapping completed successfully" + ) + check_name: str = Field(default="", description="Name of the check") + check_provider: str = Field(default="", description="Provider of the check") + suggested_mappings: list[ComplianceMapping] = Field( + default_factory=list, description="List of suggested compliance mappings" + ) + mappings_added: int = Field( + default=0, description="Number of mappings added to compliance files" + ) + files_modified: list[str] = Field( + default_factory=list, + description="List of compliance JSON files that were updated", + ) + changes_made: bool = Field( + default=False, description="Whether any compliance files were modified" + ) + message: str = Field(default="", description="Result message") + error: str | None = Field(default=None, description="Error message if failed") diff --git a/src/agents/compliance_mapping/prompts/analyze_compliance.jinja b/src/agents/compliance_mapping/prompts/analyze_compliance.jinja new file mode 100644 index 0000000..8726d75 --- /dev/null +++ b/src/agents/compliance_mapping/prompts/analyze_compliance.jinja @@ -0,0 +1,151 @@ +# Compliance Mapping Analysis + +You are analyzing a newly created Prowler security check to identify and add appropriate compliance framework mappings. + +## Check Information + +- **Check Name**: `{{ check_name }}` +- **Provider**: `{{ check_provider }}` +- **Service**: `{{ service }}` + +{% if check_ticket %} +## Original Ticket/Requirements + +The check was created based on this ticket/requirements. This may contain hints about specific compliance frameworks or requirements that the check should map to: + + +{{ check_ticket }} + +{% endif %} + +## Your Task + +Analyze the check and add it to relevant compliance framework requirements. The check ID should be added to the `Checks` array of matching requirements in the compliance JSON files. + +## Step 1: Read Check Metadata + +First, understand what the check does by reading its metadata: + +```bash +cat prowler/providers/{{ check_provider }}/services/{{ service }}/{{ check_name }}/metadata.json +``` + +Pay attention to: +- **CheckTitle**: What the check validates +- **Description**: Detailed explanation +- **Risk**: Security implications +- **Severity**: How critical this is +- **ResourceType**: What resources are examined + +## Step 2: List Available Compliance Frameworks + +Find all compliance frameworks for this provider: + +```bash +ls prowler/compliance/{{ check_provider }}/ +``` + +This will show files like: +- `cis_4.0_aws.json` +- `nist_csf_2.0_aws.json` +- `pci_dss_4.0_aws.json` +- etc. + +## Step 3: Find Similar Existing Checks + +Search for other checks from the same service that already have compliance mappings: + +```bash +grep -l "{{ service }}_" prowler/compliance/{{ check_provider }}/*.json | head -5 +``` + +Then examine one of those files to see how similar checks are mapped: + +```bash +grep -B5 -A5 "{{ service }}_" prowler/compliance/{{ check_provider }}/cis_*.json | head -50 +``` + +## Step 4: Analyze Compliance Files + +For each relevant compliance framework, read the file and identify requirements that match the check's purpose. + +**Compliance File Structure:** +```json +{ + "Framework": "CIS", + "Provider": "aws", + "Version": "4.0", + "Requirements": [ + { + "Id": "1.3", + "Description": "Ensure credentials unused for 45 days or greater are disabled", + "Attributes": [...], + "Checks": ["iam_user_accesskey_unused", "iam_user_console_access_unused"] + } + ] +} +``` + +## Step 5: Identify Matching Requirements + +Based on the check's metadata, identify requirements where this check should be added. Consider: + +1. **Direct matches**: The requirement description directly mentions what the check validates +2. **Category alignment**: The check's security category (encryption, access control, logging, etc.) matches the requirement's focus +3. **Resource type matches**: The requirement covers the same resource type + +**Confidence Levels:** +- **High**: Check directly implements or validates what the requirement describes +- **Medium**: Check partially addresses the requirement or is strongly related +- **Low**: Check is tangentially related but may be relevant + +## Step 6: Update Compliance Files + +For each matching requirement, add the check ID to the `Checks` array. + +**Important Guidelines:** +1. **Alphabetical order**: Keep the `Checks` array sorted alphabetically +2. **No duplicates**: Don't add the check if it's already present +3. **JSON validity**: Ensure the file remains valid JSON after editing +4. **Conservative approach**: Only add high and medium confidence mappings + +**Example Edit:** +If adding `{{ check_name }}` to requirement "2.1.1" in `cis_4.0_aws.json`: + +Before: +```json +"Checks": ["s3_bucket_secure_transport_policy", "s3_bucket_ssl_requests_only"] +``` + +After (maintaining alphabetical order): +```json +"Checks": ["{{ check_name }}", "s3_bucket_secure_transport_policy", "s3_bucket_ssl_requests_only"] +``` + +## Step 7: Report Results + +After analyzing and updating files, report: + +1. **Mappings Found**: List each mapping with: + - Framework ID (filename without .json) + - Framework name + - Requirement ID + - Requirement description (brief) + - Confidence level + - Reason for mapping + +2. **Files Modified**: List all compliance JSON files that were updated + +3. **Summary**: Total number of mappings added + +## Important Notes + +- **Focus on major frameworks first**: CIS, NIST, PCI-DSS, SOC2, HIPAA, ISO27001 +- **Don't force mappings**: If the check doesn't clearly fit a requirement, don't add it +- **Verify existing patterns**: Look at how similar checks in the same service are mapped +- **Read before writing**: Always read the file content before making edits +- **One check, many frameworks**: A check can (and often should) map to multiple frameworks + +## Begin Analysis + +Now analyze the check `{{ check_name }}` and identify appropriate compliance mappings. Start by reading the check metadata. diff --git a/src/agents/implementation/__init__.py b/src/agents/implementation/__init__.py new file mode 100644 index 0000000..83815a2 --- /dev/null +++ b/src/agents/implementation/__init__.py @@ -0,0 +1,15 @@ +"""Implementation agent for creating Prowler checks.""" + +from agents.implementation.agent import ChecKreatorAgent +from agents.implementation.models import ( + CheckDiscoveryResult, + CheckImplementationResult, + CheckVerificationResult, +) + +__all__ = [ + "ChecKreatorAgent", + "CheckDiscoveryResult", + "CheckImplementationResult", + "CheckVerificationResult", +] diff --git a/src/agents/implementation/agent.py b/src/agents/implementation/agent.py new file mode 100644 index 0000000..33360eb --- /dev/null +++ b/src/agents/implementation/agent.py @@ -0,0 +1,293 @@ +"""Implementation agent for creating Prowler checks.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING, Any, ClassVar + +if TYPE_CHECKING: + from git import Repo + + from tools.models import CheckVerificationStatus + +from claude_agent_sdk import ( + ClaudeAgentOptions, + ClaudeSDKClient, + create_sdk_mcp_server, +) + +from agents.base import Agent +from agents.implementation.models import ( + CheckDiscoveryResult, + CheckImplementationResult, + CheckVerificationResult, +) +from tools.prowler import mkcheck, verify_check_loaded +from utils.prompts import load_prompt + + +class ChecKreatorAgent(Agent): + """Agent that implements Prowler checks from tickets.""" + + # MCP Server Configuration + MCP_SERVER_NAME: ClassVar[str] = "utils" + MCP_SERVER_VERSION: ClassVar[str] = "1.0.0" + + # Check Verification + MAX_CHECK_VERIFICATION_ATTEMPTS: ClassVar[int] = 5 + + # File name constants + INIT_FILE: ClassVar[str] = "__init__.py" + + ALLOWED_TOOLS: ClassVar[list[str]] = [ + "Read", + "Write", + "Edit", + "Bash", + "Glob", + "Grep", + "mcp__utils__mkcheck", + ] + + def __init__( + self, + working_dir: Path, + check_ticket: str | None, + prowler_repo: Repo, + **kwargs: Any, + ) -> None: + super().__init__(working_dir, **kwargs) + self.check_ticket: str | None = check_ticket + self.prowler_repo: Repo = prowler_repo + + async def run(self) -> CheckImplementationResult: # type: ignore[override] + """ + Implement a Prowler check. + + Returns: + CheckImplementationResult with implementation information + """ + logging.info("[bold cyan]Running implementation agent...[/bold cyan]") + + # Load prompt and create options + implement_check_prompt: str = self._load_implementation_prompt() + options: ClaudeAgentOptions = self._create_claude_options() + + # Run Claude agent + async with ClaudeSDKClient(options=options) as client: + # Initial implementation + await client.query(implement_check_prompt) + await self._process_agent_messages(client=client) + + # Discover the newly created check + discovery_result: CheckDiscoveryResult = self._discover_check_info() + if not discovery_result.success: + return CheckImplementationResult( + success=False, + error="No check folders found in repository changes", + ) + + # Verify and fix the check + verification_result: CheckVerificationResult = ( + await self._verify_and_fix_check( + client=client, + check_name=discovery_result.check_name, + check_provider=discovery_result.check_provider, + ) + ) + + return CheckImplementationResult( + success=verification_result.success, + check_name=discovery_result.check_name, + check_provider=discovery_result.check_provider, + message=verification_result.message, + attempts=verification_result.attempts, + ) + + def _load_implementation_prompt(self) -> str: + """ + Load the check implementation prompt template. + + Returns: + Rendered prompt string + """ + prompt_path: Path = Path(__file__).parent / "prompts" / "implement_check.jinja" + return load_prompt( + path=prompt_path, + context={ + "check_ticket": self.check_ticket, + }, + ) + + def _load_fix_prompt(self, check_name: str, verification_message: str) -> str: + """ + Load the check fix prompt template. + + Args: + check_name: Name of the check to fix + verification_message: Error message from verification + + Returns: + Rendered prompt string + """ + fix_prompt_path: Path = Path(__file__).parent / "prompts" / "fix_check.jinja" + return load_prompt( + path=fix_prompt_path, + context={ + "check_name": check_name, + "verification_message": verification_message, + }, + ) + + def _create_claude_options(self) -> ClaudeAgentOptions: + """ + Create Claude agent options with tools and MCP servers. + + Returns: + Configured ClaudeAgentOptions instance + """ + tools_server: Any = create_sdk_mcp_server( + name=self.MCP_SERVER_NAME, + version=self.MCP_SERVER_VERSION, + tools=[mkcheck], + ) + + mcp_servers: dict[str, Any] = {"utils": tools_server} + + return ClaudeAgentOptions( + allowed_tools=self.ALLOWED_TOOLS, + mcp_servers=mcp_servers, + permission_mode="bypassPermissions", + cwd=str(self.working_dir), + ) + + def _discover_check_info(self) -> CheckDiscoveryResult: + """ + Discover the newly created check from repository changes. + + Returns: + CheckDiscoveryResult with discovery information + """ + check_folders: list[Path] = self._get_new_check_folders() + + if not check_folders: + logging.error("Could not find check name in repository changes") + return CheckDiscoveryResult(success=False) + + if len(check_folders) > 1: + logging.warning( + "Multiple check folders found in repository changes. Selecting the first one..." + ) + + check_path: Path = check_folders[0] + check_name: str = check_path.name + check_provider: str = check_path.parents[2].name + + logging.info(f"[cyan]Found check: {check_name}[/cyan]") + return CheckDiscoveryResult( + success=True, check_name=check_name, check_provider=check_provider + ) + + async def _verify_and_fix_check( + self, client: ClaudeSDKClient, check_name: str, check_provider: str + ) -> CheckVerificationResult: + """ + Verify the check implementation and fix issues in a loop. + + Args: + client: Claude SDK client instance + check_name: Name of the check + check_provider: Provider of the check (e.g., 'aws', 'azure') + + Returns: + CheckVerificationResult with verification information + """ + max_attempts: int = self.MAX_CHECK_VERIFICATION_ATTEMPTS + attempt: int = 0 + success: bool = False + message: str = "" + + while attempt < max_attempts and not success: + attempt += 1 + logging.info( + f"[yellow]Verifying check implementation (attempt {attempt}/{max_attempts})...[/yellow]" + ) + + verification_status: CheckVerificationStatus = verify_check_loaded( + check_name=check_name, + provider=check_provider, + prowler_directory=Path(self.prowler_repo.working_dir), + ) + + success = verification_status.success + message = verification_status.message + + logging.info(message) + + if not success: + fix_prompt: str = self._load_fix_prompt( + check_name=check_name, verification_message=message + ) + + logging.info( + f"[yellow]Check verification failed. Requesting fixes (attempt {attempt}/{max_attempts})...[/yellow]" + ) + + await client.query(fix_prompt) + await self._process_agent_messages(client=client) + + if not success: + logging.error( + f"Failed to create a valid check after {max_attempts} attempts" + ) + + return CheckVerificationResult( + success=success, message=message, attempts=attempt + ) + + def _get_new_check_folders(self) -> list[Path]: + """ + Get list of new check folder paths from git untracked changes. + + Looks for untracked directories that contain both: + - {check_name}.py + - {check_name}.metadata.json + + Returns: + List of Path objects pointing to check folders + """ + # Get untracked files + untracked: list[str] = self.prowler_repo.untracked_files + + # Group files by directory + files_by_dir: dict[str, list[str]] = {} + for file_path in untracked: + dir_name: str = str(Path(file_path).parent) + filename: str = Path(file_path).name + if dir_name not in files_by_dir: + files_by_dir[dir_name] = [] + files_by_dir[dir_name].append(filename) + + # Find directories with both .py and .metadata.json files + check_folders: list[Path] = [] + for dir_name, filenames in files_by_dir.items(): + # Extract base names (without extensions) + py_files: set[str] = { + filename.replace(".py", "") + for filename in filenames + if filename.endswith(".py") and filename != self.INIT_FILE + } + json_files: set[str] = { + filename.replace(".metadata.json", "") + for filename in filenames + if filename.endswith(".metadata.json") + } + + # Find common base names (indicates a check folder) + common_names: set[str] = py_files & json_files + + if common_names: + check_folders.append(Path(dir_name)) + + return check_folders diff --git a/src/agents/implementation/models.py b/src/agents/implementation/models.py new file mode 100644 index 0000000..f5737e7 --- /dev/null +++ b/src/agents/implementation/models.py @@ -0,0 +1,34 @@ +"""Pydantic models for ChecKreatorAgent results.""" + +from pydantic import BaseModel, Field + + +class CheckDiscoveryResult(BaseModel): + """Result of discovering a check from repository changes.""" + + success: bool = Field(description="Whether check discovery was successful") + check_name: str = Field(default="", description="Name of the discovered check") + check_provider: str = Field( + default="", description="Provider of the check (e.g., 'aws', 'azure')" + ) + + +class CheckVerificationResult(BaseModel): + """Result of verifying and fixing a check implementation.""" + + success: bool = Field(description="Whether verification succeeded") + message: str = Field(description="Verification or error message") + attempts: int = Field(description="Number of verification attempts made") + + +class CheckImplementationResult(BaseModel): + """Result of implementing a Prowler check.""" + + success: bool = Field(description="Whether implementation was successful") + check_name: str = Field(default="", description="Name of the implemented check") + check_provider: str = Field( + default="", description="Provider of the check (e.g., 'aws', 'azure')" + ) + message: str = Field(default="", description="Result message") + attempts: int = Field(default=0, description="Number of verification attempts") + error: str | None = Field(default=None, description="Error message if failed") diff --git a/src/agents/implementation/prompts/fix_check.jinja b/src/agents/implementation/prompts/fix_check.jinja new file mode 100644 index 0000000..8d24cd9 --- /dev/null +++ b/src/agents/implementation/prompts/fix_check.jinja @@ -0,0 +1,13 @@ +# Fix Prowler Check Implementation + +The check '{{ check_name }}' was created but failed verification: + +## Verification Error + +{{ verification_message }} + +## Instructions + +1. Locate and read the check files for '{{ check_name }}' +2. Identify and fix any issues +3. Ensure the check follows Prowler's development guidelines from `docs/developer-guide/checks.mdx` diff --git a/src/agents/implementation/prompts/implement_check.jinja b/src/agents/implementation/prompts/implement_check.jinja new file mode 100644 index 0000000..3b97b7a --- /dev/null +++ b/src/agents/implementation/prompts/implement_check.jinja @@ -0,0 +1,98 @@ +# Prowler Check Implementation Guide + +You are tasked with implementing a new security check for Prowler, an open-source security platform that performs security assessments on different providers (AWS, Azure, GCP, Kubernetes, Microsoft365, GitHub, etc.). + +## Understanding Prowler Check Structure + +A Prowler check is a detection rule that is used to identify a misconfiguration or vulnerability in a cloud resource. + +Before implementing, you need to understand how Prowler checks are structured: + +1. **Check Folder Structure**: Each check is a folder located in `prowler/providers/{provider}/services/{service}/{service}_{check_name}/` +2. **Check Files**: In the check folder, you will find the following files: + - `{service}_{check_name}.metadata.json`: The metadata file for the check. + - `{service}_{check_name}.py`: The code file for the check. + - `__init__.py`: Empty file to make the folder a valid Python package. + +**NOTE**: You have access to the tool `mkcheck` to create the check folder and files, but everything empty. + +## Implementation Steps + +### Step 1: Analyze the Check Ticket +- Read and understand the vulnerability description in `check_ticket.md` +- Identify the cloud provider (AWS, Azure, GCP, or Kubernetes) +- Identify the service being checked (e.g., ArtifactRegistry, IAM, S3, etc.) +- Understand what security misconfiguration needs to be detected + +### Step 2: Research Existing Checks +- Navigate to the Prowler repository and locate similar checks for the same provider and service +- Study the structure and implementation patterns +- Look for existing service client implementations in `prowler/providers/{provider}/services/{service}/` +- Review how similar checks: + - Use the service client to fetch data + - Process the data + - Create findings + - Handle exceptions + +**IMPORTANT CONSIDERATION**: There are some patterns that are from older checks that are not used anymore. To get the most up to date information, you should view `docs/developer-guide/checks.mdx` for the latest information. +Some of the key points to consider are: +- All classes and methods should have a `docstring` +- Metadata format is in process of being updated, so most of the checked information could have an incorrect format. Visit `docs/developer-guide/check-metadata-guidelines.mdx` for the latest information. + +### Step 3: Identify the Service Client +- Check if a service client already exists for this service in `prowler/providers/{provider}/services/{service}/` +- If the service client exists, understand its methods and what data it fetches +- If methods are missing, you may need to extend the service client + +### Step 4: Design the Check Logic +Based on the audit steps in the check ticket, if audit steps are not provided, you should figure out the best way to audit the service based on the documentation and other available information. +- Map each audit step to Python code +- Identify what API calls or service client methods are needed +- Plan how to detect the vulnerability (what conditions to check) +- Determine what information to include in findings + +### Step 5: Implement the Check +Create the check file with: +- Proper imports from Prowler's base classes +- Check metadata (title, description, severity, risk, etc.) +- The `execute()` method that: + - Iterates through resources from the service client + - Applies the security logic + - Creates PASS or FAIL findings + - Includes proper error handling + +### Metadata Best Practices + +#### CheckTitle Format + +The CheckTitle should describe the **security benefit** of the feature, not just its state. + +**Pattern**: "[Feature] protects/ensures/prevents [Target] from [Threat/Risk]" + +| ❌ Bad (describes state) | ✅ Good (describes benefit) | +|--------------------------|----------------------------| +| "MFA is enabled for IAM users" | "MFA protects IAM users from credential theft" | +| "Encryption is enabled for S3 buckets" | "S3 bucket encryption protects data at rest" | +| "Logging is enabled for CloudTrail" | "CloudTrail logging ensures audit trail for API activity" | +| "ZAP is enabled for Microsoft Teams" | "Zero-hour auto purge (ZAP) protects Microsoft Teams from malware and phishing" | + +**Guidelines:** +- Use active verbs: protects, ensures, prevents, detects, monitors +- Mention the security benefit (what threat is mitigated) +- Keep it concise but descriptive (under 100 characters) + +## References + +For any doubts, you can refer to following files in Prowler repository: +- `prowler/AGENTS.md` - Prowler Core guidelines +- `docs/developer-guide/checks.mdx` - Prowler Check development guidelines + +## Check Ticket + +{% if check_ticket %} +This is the new check ticket that you need to implement: + +{{ check_ticket }} +{% else %} +**ERROR**: No check ticket content provided. A check ticket is required to implement a new check. +{% endif %} diff --git a/src/agents/pr_creation/__init__.py b/src/agents/pr_creation/__init__.py new file mode 100644 index 0000000..c64a363 --- /dev/null +++ b/src/agents/pr_creation/__init__.py @@ -0,0 +1,6 @@ +"""PR creation agent module.""" + +from agents.pr_creation.agent import PRCreationAgent +from agents.pr_creation.models import PRCreationResult + +__all__ = ["PRCreationAgent", "PRCreationResult"] diff --git a/src/agents/pr_creation/agent.py b/src/agents/pr_creation/agent.py new file mode 100644 index 0000000..2faaca7 --- /dev/null +++ b/src/agents/pr_creation/agent.py @@ -0,0 +1,131 @@ +"""PR creation agent for committing and creating pull requests.""" + +from __future__ import annotations + +import contextlib +import logging +import re +from pathlib import Path +from typing import TYPE_CHECKING, Any, ClassVar + +if TYPE_CHECKING: + from git import Repo + +from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient + +from agents.base import Agent +from agents.pr_creation.models import PRCreationResult +from utils.prompts import load_prompt + + +class PRCreationAgent(Agent): + """Agent that commits changes and creates pull requests.""" + + ALLOWED_TOOLS: ClassVar[list[str]] = [ + "Read", + "Bash", + "Glob", + "Grep", + ] + GITHUB_PR_URL_PATTERN: ClassVar[str] = r"https://github\.com/[^/]+/[^/]+/pull/(\d+)" + + def __init__( + self, + working_dir: Path, + check_name: str, + check_provider: str, + branch_name: str, + prowler_repo: Repo, + source_url: str | None = None, + check_ticket: str | None = None, + **kwargs: Any, + ) -> None: + super().__init__(working_dir, **kwargs) + self.check_name: str = check_name + self.check_provider: str = check_provider + self.branch_name: str = branch_name + self.prowler_repo: Repo = prowler_repo + self.source_url: str | None = source_url + self.check_ticket: str | None = check_ticket + self._pr_url: str = "" + self._pr_number: int = 0 + self._commit_sha: str = "" + + async def run(self, **inputs: Any) -> PRCreationResult: + """ + Commit changes and create a pull request. + + Returns: + PRCreationResult with PR information + """ + logging.info("[bold cyan]Running PR creation agent...[/bold cyan]") + + # Load prompt and create options + pr_prompt: str = self._load_pr_prompt() + options: ClaudeAgentOptions = self._create_claude_options() + + async with ClaudeSDKClient(options=options) as client: + logging.info("[yellow]Creating commit and pull request...[/yellow]") + await client.query(pr_prompt) + response_text: str = await self._process_agent_messages( + client, capture_text=True + ) + + # Extract PR information from response + self._extract_pr_info(response_text) + + # Get commit SHA from repo (may fail if no commits yet) + with contextlib.suppress(Exception): + self._commit_sha = self.prowler_repo.head.commit.hexsha + + if self._pr_url: + logging.info(f"[green]✓ PR created: {self._pr_url}[/green]") + return PRCreationResult( + success=True, + check_name=self.check_name, + commit_sha=self._commit_sha, + pr_url=self._pr_url, + pr_number=self._pr_number, + message=f"PR #{self._pr_number} created successfully", + ) + else: + logging.error("Failed to create PR") + return PRCreationResult( + success=False, + check_name=self.check_name, + commit_sha=self._commit_sha, + message="PR creation failed - check output for details", + error="Could not extract PR URL from response", + ) + + def _load_pr_prompt(self) -> str: + """Load the PR creation prompt template.""" + prompt_path: Path = Path(__file__).parent / "prompts" / "pr_description.jinja" + # Extract service from check name (first part before underscore) + service: str = self.check_name.split("_")[0] + return load_prompt( + path=prompt_path, + context={ + "check_name": self.check_name, + "check_provider": self.check_provider, + "branch_name": self.branch_name, + "source_url": self.source_url, + "check_ticket": self.check_ticket, + "service": service, + }, + ) + + def _create_claude_options(self) -> ClaudeAgentOptions: + """Create Claude agent options with tools.""" + return ClaudeAgentOptions( + allowed_tools=self.ALLOWED_TOOLS, + permission_mode="bypassPermissions", + cwd=str(self.working_dir), + ) + + def _extract_pr_info(self, response_text: str) -> None: + """Extract PR URL and number from response text.""" + match = re.search(self.GITHUB_PR_URL_PATTERN, response_text) + if match: + self._pr_url = match.group(0) + self._pr_number = int(match.group(1)) diff --git a/src/agents/pr_creation/models.py b/src/agents/pr_creation/models.py new file mode 100644 index 0000000..5ef45aa --- /dev/null +++ b/src/agents/pr_creation/models.py @@ -0,0 +1,15 @@ +"""Pydantic models for PRCreationAgent results.""" + +from pydantic import BaseModel, Field + + +class PRCreationResult(BaseModel): + """Result of creating a pull request.""" + + success: bool = Field(description="Whether PR creation was successful") + check_name: str = Field(default="", description="Name of the check in the PR") + commit_sha: str = Field(default="", description="SHA of the commit") + pr_url: str = Field(default="", description="URL of the created PR") + pr_number: int = Field(default=0, description="Number of the created PR") + message: str = Field(default="", description="Result message") + error: str | None = Field(default=None, description="Error message if failed") diff --git a/src/agents/pr_creation/prompts/pr_description.jinja b/src/agents/pr_creation/prompts/pr_description.jinja new file mode 100644 index 0000000..d0db923 --- /dev/null +++ b/src/agents/pr_creation/prompts/pr_description.jinja @@ -0,0 +1,222 @@ +# Create Pull Request for Prowler Check + +You are creating a pull request for a newly implemented Prowler security check following the project's PR conventions. + +## Check Information + +- **Check Name**: `{{ check_name }}` +- **Provider**: `{{ check_provider }}` +- **Branch Name**: `{{ branch_name }}` + +{% if check_ticket %} +## Original Ticket/Requirements + +The check was created based on this ticket/requirements: + + +{{ check_ticket }} + +{% endif %} + +## PR Creation Steps + +### 1. Read Check Metadata + +**CRITICAL**: Before creating the PR, you MUST read the check's metadata to understand what it does: + +```bash +cat prowler/providers/{{ check_provider }}/services/{{ service }}/{{ check_name }}/metadata.json +``` + +Also read the check implementation to understand the logic: +```bash +cat prowler/providers/{{ check_provider }}/services/{{ service }}/{{ check_name }}/{{ check_name }}.py +``` + +### 2. Analyze Changes + +Understand all changes that will be included: +```bash +git status +git diff --name-only +``` + +### 3. Stage All Changes + +Stage all files related to the check: +```bash +git add . +``` + +### 4. Create Commit + +Create a commit following conventional commit format: + +```bash +git commit -m "$(cat <<'EOF' +feat({{ check_provider }}): add {{ check_name }} security check + +Add new security check {{ check_name }} for {{ check_provider }} provider. +Includes check implementation, metadata, and unit tests. +EOF +)" +``` + +### 5. Push to Remote + +Push the branch with upstream tracking: +```bash +git push -u origin {{ branch_name }} +``` + +### 6. Create Pull Request with Rich Context + +**IMPORTANT**: Write a meaningful Context and Description based on: +1. The check's metadata.json (Risk, Description, Remediation fields) +2. The check's implementation logic +3. The original ticket/requirements (if provided above) + +The Context section should explain: +- What security gap or risk this check addresses (2-3 sentences) +- Why this check is important and what could happen without it +- Who might exploit this misconfiguration (attackers, malicious insiders, etc.) + +The Description section should explain: +- What the check specifically validates (resources, configurations) +- How it determines pass/fail conditions +- What remediation is recommended + +**Good Example** (from PR #7658): +```markdown +### Context + +Automatic email forwarding rules are commonly abused by attackers and malicious +insiders to exfiltrate sensitive information from an organization. These rules +can be created through multiple channels and may go unnoticed if not explicitly blocked. + +### Description + +These checks ensure that all forms of automatic mail forwarding are blocked using +a combination of Transport Rules and Anti-Spam outbound policies. Disabling +forwarding to remote domains helps prevent unauthorized data exfiltration via email. +``` + +**Bad Example** (too generic): +```markdown +### Context + +Adds a new security check `check_name` for the aws provider. + +### Description + +This check detects security misconfigurations. +``` + +Create the PR using `gh pr create`: + +```bash +gh pr create --draft --title "feat({{ check_provider }}): add {{ check_name }} security check" --body "$(cat <<'EOF' +### Context + +[Write 2-3 sentences explaining the security risk this check addresses. +Base this on the Risk field from metadata.json and the check logic.] + +### Description + +[Write 2-3 sentences explaining what the check validates and how. +Include what resources are examined and pass/fail conditions.] + +### Steps to review + +1. Review the check implementation at `prowler/providers/{{ check_provider }}/services/{{ service }}/{{ check_name }}/` +2. Review the metadata file for correct severity, remediation, and compliance mappings +3. Review compliance framework mappings in `prowler/compliance/{{ check_provider }}/` to ensure the check is correctly mapped to relevant requirements +4. Run the check tests: `poetry run pytest tests/providers/{{ check_provider }}/services/{{ service }}/{{ check_name }}/ -v` +5. **Run the check against a real environment** (if possible): + ```bash + prowler {{ check_provider }} --check {{ check_name }} + ``` + +{% if source_url %} +### Related Issues + +{% if 'github.com' in source_url %} +Closes {{ source_url }} +{% else %} +{{ source_url }} +{% endif %} +{% endif %} + +### Checklist + +
+ +Community Checklist + +- [x] This feature/issue is listed in [here](https://github.com/prowler-cloud/prowler/issues?q=sort%3Aupdated-desc+is%3Aissue+is%3Aopen) or roadmap.prowler.com +- [x] Is it assigned to me, if not, request it via the issue/feature in [here](https://github.com/prowler-cloud/prowler/issues?q=sort%3Aupdated-desc+is%3Aissue+is%3Aopen) or [Prowler Community Slack](goto.prowler.com/slack) + +
+ +- [x] Review if the code is being covered by tests. +- [x] Review if code is being documented following this specification https://github.com/google/styleguide/blob/gh-pages/pyguide.md#38-comments-and-docstrings +- [ ] Review if backport is needed. +- [ ] Review if is needed to change the [Readme.md](https://github.com/prowler-cloud/prowler/blob/master/README.md) +- [x] Ensure new entries are added to [CHANGELOG.md](https://github.com/prowler-cloud/prowler/blob/master/prowler/CHANGELOG.md), if applicable. + +#### SDK/CLI +- Are there new checks included in this PR? **Yes** + - If so, do we need to update permissions for the provider? Please review this carefully. + +### License + +By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. +EOF +)" +``` + +### 8. Add Changelog Entry + +After the PR is created, add a changelog entry to `prowler/CHANGELOG.md`. + +**Format** (keepachangelog.com style): +- Entry goes under `### 🚀 Added` in the UNRELEASED section +- Format: `- \`{check_name}\` check for {provider} provider [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX)` +- No period at the end +- Do NOT start with redundant verbs (section header already says "Added") + +**Steps:** +1. Read `prowler/CHANGELOG.md` to find the UNRELEASED section +2. Add the entry under `### 🚀 Added` (create section if missing) +3. Amend the commit to include the changelog: `git add prowler/CHANGELOG.md && git commit --amend --no-edit` +4. Force push: `git push --force-with-lease` + +**Example entry:** +```markdown +- `{{ check_name }}` check for {{ check_provider }} provider [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) +``` + +### 9. Report Results + +After creating the PR and updating the changelog, report: +- The PR URL (e.g., https://github.com/prowler-cloud/prowler/pull/123) +- The PR number +- The commit SHA + +## Important Notes + +- **Title format**: Must follow conventional commits: `feat(): add security check` +- **Never skip pre-commit hooks** unless explicitly told to +- **SDK/CLI section**: Since this is a new check, mark "Yes" for new checks question +- **Permissions review**: The reviewer should check if provider permissions need updating +- **NEVER add Co-Authored-By trailers**: Do NOT include any "Co-Authored-By" lines in commit messages (e.g., no "Co-Authored-By: Claude" or similar) + +## Before Creating PR Checklist + +Verify these before running `gh pr create`: +1. ✅ All changes are staged +2. ✅ Commit message follows conventional format +3. ✅ Branch is pushed to origin +4. ✅ Tests pass locally (already verified in previous steps) + +Now execute the PR creation steps in order. diff --git a/src/agents/review/__init__.py b/src/agents/review/__init__.py new file mode 100644 index 0000000..2cb7d01 --- /dev/null +++ b/src/agents/review/__init__.py @@ -0,0 +1,6 @@ +"""Review agent module.""" + +from agents.review.agent import ReviewAgent +from agents.review.models import ReviewIssue, ReviewResult + +__all__ = ["ReviewAgent", "ReviewIssue", "ReviewResult"] diff --git a/src/agents/review/agent.py b/src/agents/review/agent.py new file mode 100644 index 0000000..eb6e82f --- /dev/null +++ b/src/agents/review/agent.py @@ -0,0 +1,124 @@ +"""Review agent for code review of Prowler checks.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING, Any, ClassVar + +if TYPE_CHECKING: + from git import Repo + +from claude_agent_sdk import ( + ClaudeAgentOptions, + ClaudeSDKClient, +) + +from agents.base import Agent +from agents.review.models import ReviewResult +from utils.prompts import load_prompt + + +class ReviewAgent(Agent): + """Agent that reviews Prowler check implementations.""" + + ALLOWED_TOOLS: ClassVar[list[str]] = [ + "Read", + "Write", + "Edit", + "Bash", + "Glob", + "Grep", + "WebFetch", + ] + + def __init__( + self, + working_dir: Path, + check_name: str, + check_provider: str, + prowler_repo: Repo, + **kwargs: Any, + ) -> None: + super().__init__(working_dir, **kwargs) + self.check_name: str = check_name + self.check_provider: str = check_provider + self.prowler_repo: Repo = prowler_repo + + async def run(self, **inputs: Any) -> ReviewResult: + """ + Review a Prowler check implementation. + + Returns: + ReviewResult with review information + """ + logging.info("[bold cyan]Running review agent...[/bold cyan]") + + # Load prompt and create options + review_prompt: str = self._load_review_prompt() + options: ClaudeAgentOptions = self._create_claude_options() + + # Track if changes were made by checking git status before/after + initial_dirty: bool = self.prowler_repo.is_dirty() + initial_untracked: set[str] = set(self.prowler_repo.untracked_files) + + async with ClaudeSDKClient(options=options) as client: + logging.info("[yellow]Reviewing check implementation...[/yellow]") + await client.query(review_prompt) + await self._process_agent_messages(client=client) + + # Check if changes were made + final_dirty: bool = self.prowler_repo.is_dirty() + final_untracked: set[str] = set(self.prowler_repo.untracked_files) + + changes_made: bool = ( + final_dirty != initial_dirty + or final_untracked != initial_untracked + or self._check_modified_files() + ) + + if changes_made: + logging.warning("Review made changes - re-testing recommended") + else: + logging.info("[green]✓ Review complete - no changes needed[/green]") + + return ReviewResult( + success=True, + check_name=self.check_name, + changes_made=changes_made, + message="Review completed" + (" with changes" if changes_made else ""), + ) + + def _load_review_prompt(self) -> str: + """Load the review prompt template.""" + prompt_path: Path = Path(__file__).parent / "prompts" / "review_check.jinja" + return load_prompt( + path=prompt_path, + context={ + "check_name": self.check_name, + "check_provider": self.check_provider, + "prowler_repo": str(self.working_dir), + }, + ) + + def _create_claude_options(self) -> ClaudeAgentOptions: + """Create Claude agent options with tools.""" + return ClaudeAgentOptions( + allowed_tools=self.ALLOWED_TOOLS, + permission_mode="bypassPermissions", + cwd=str(self.working_dir), + ) + + def _check_modified_files(self) -> bool: + """Check if there are any modified files related to the check.""" + try: + diff = self.prowler_repo.git.diff("--name-only") + if diff: + modified_files: list[str] = diff.split("\n") + # Check if any modified file is related to our check + for file_path in modified_files: + if self.check_name in file_path: + return True + return False + except Exception: + return False diff --git a/src/agents/review/models.py b/src/agents/review/models.py new file mode 100644 index 0000000..00c02d5 --- /dev/null +++ b/src/agents/review/models.py @@ -0,0 +1,34 @@ +"""Pydantic models for ReviewAgent results.""" + +from typing import Literal + +from pydantic import BaseModel, Field + + +class ReviewIssue(BaseModel): + """A single issue found during code review.""" + + severity: Literal["critical", "major", "minor"] = Field( + description="Severity level of the issue" + ) + category: str = Field( + description="Issue category: agents_md, developer_guide, metadata, security" + ) + file_path: str = Field(description="Path to the file with the issue") + description: str = Field(description="Description of the issue") + fixed: bool = Field(default=False, description="Whether the issue was auto-fixed") + + +class ReviewResult(BaseModel): + """Result of reviewing a Prowler check.""" + + success: bool = Field(description="Whether review passed (no critical issues)") + check_name: str = Field(default="", description="Name of the reviewed check") + issues_found: list[ReviewIssue] = Field( + default_factory=list, description="List of issues found" + ) + issues_fixed: int = Field(default=0, description="Number of issues auto-fixed") + changes_made: bool = Field( + default=False, description="Whether changes were made (triggers re-test)" + ) + message: str = Field(default="", description="Result message") diff --git a/src/agents/review/prompts/review_check.jinja b/src/agents/review/prompts/review_check.jinja new file mode 100644 index 0000000..160bb06 --- /dev/null +++ b/src/agents/review/prompts/review_check.jinja @@ -0,0 +1,202 @@ +# Review Prowler Check Implementation + +You are an expert Prowler code reviewer. Review the newly implemented check and its tests for compliance with Prowler standards. + +## Check Information + +- **Check Name**: `{{ check_name }}` +- **Provider**: `{{ check_provider }}` +- **Prowler Repository**: `{{ prowler_repo }}` + +## Review Dimensions + +You must systematically evaluate the check across **four critical dimensions**: + +### 1. AGENTS.md Compliance + +- Read `prowler/AGENTS.md` for component-specific requirements +- Verify the check follows documented patterns and conventions +- Flag any deviations from architectural decisions + +### 2. Prowler Developer Guide Compliance + +- Review against `docs/developer-guide/checks.mdx` +- For metadata, validate against the official schema (see Metadata Validation section below) +- Verify coding patterns, file structure, and naming conventions + +### Metadata Validation (MANDATORY) + +You MUST validate the `{{ check_name }}.metadata.json` file against the official Prowler metadata schema from https://docs.prowler.com/developer-guide/checks#example-metadata-file + +#### Required Fields and Validation Rules: + +| Field | Type | Validation Rules | +|-------|------|------------------| +| **Provider** | string | Must be lowercase, match folder name (e.g., `aws`, `azure`, `gcp`, `kubernetes`) | +| **CheckID** | string | Must match check folder name, Python file name, and JSON file name exactly | +| **CheckTitle** | string | Max 150 chars. Must describe PASS case security benefit using active verbs (protects, ensures, prevents, detects, monitors). NEVER use "X is enabled for Y" pattern. | +| **CheckType** | array | AWS Security Hub format only: `namespace/category/classifier` | +| **ServiceName** | string | Lowercase, must match service folder name | +| **SubServiceName** | string | DEPRECATED - must be empty string `""` | +| **ResourceIdTemplate** | string | DEPRECATED - must be empty string `""` | +| **Severity** | string | One of: `critical`, `high`, `medium`, `low`, `informational` (lowercase only) | +| **ResourceType** | string | Security Hub types, CloudFormation types (without `::`), or `Other` | +| **ResourceGroup** | string | One of: `compute`, `container`, `serverless`, `database`, `storage`, `network`, `IAM`, `messaging`, `security`, `monitoring`, `api_gateway`, `ai_ml`, `governance`, `collaboration`, `devops`, `analytics` | +| **Description** | string | Max 400 chars. Natural language explanation of what the finding means | +| **Risk** | string | Max 400 chars. Clear explanation of cybersecurity risks | +| **RelatedUrl** | string | DEPRECATED - must be empty string `""` | +| **AdditionalURLs** | array | List of valid, non-repeated official documentation URLs | +| **Remediation** | object | Must include `Code` (CLI, NativeIaC, Terraform, Other) and `Recommendation` (Text, URL) | +| **Categories** | array | Functional groupings for execution filtering | +| **DependsOn** | array | List of check IDs representing technical dependencies | +| **RelatedTo** | array | List of conceptually related check IDs | +| **Notes** | string | Additional information not covered by other fields | + +#### CheckTitle Format Rules (CRITICAL): + +- **MUST** describe the security benefit, not just state +- **MUST** use active verbs: protects, ensures, prevents, detects, monitors, enforces, restricts +- **NEVER** use patterns like: + - "X is enabled for Y" → Rewrite as "X protects Y from Z" + - "X is configured" → Rewrite as "X ensures/prevents..." + - "X has Y" → Rewrite as "X enforces/monitors..." +- **Examples**: + - ❌ "CloudTrail logging is enabled for S3 buckets" + - ✅ "CloudTrail monitors S3 bucket access for security auditing" + - ❌ "MFA is enabled for IAM users" + - ✅ "MFA protects IAM user accounts from unauthorized access" + +#### Severity Classification Guide: + +- **critical**: Immediate security breach risk, data exposure, or system compromise +- **high**: Significant security weakness that could lead to breach +- **medium**: Security misconfiguration with moderate impact +- **low**: Minor security improvement opportunity +- **informational**: Best practice recommendation, no direct security impact + +### 3. Security Assessment + +Conduct a security-focused review looking for: +- Potential injection vulnerabilities +- Insecure data handling +- Improper error handling that could leak information +- Input validation gaps +- Ensure the check cannot be bypassed + +### 4. Code Quality + +- All classes and methods have docstrings +- Proper type hints +- No hardcoded values that should be configurable +- Efficient resource iteration +- Proper exception handling + +## Review Process + +### Step 0: Discover All Modified Files + +First, identify ALL files that have been created or modified: + +```bash +git status --porcelain +git diff --name-only HEAD +``` + +Categorize the files: +- **Check files** (PRIMARY FOCUS): Files in `prowler/providers/{{ check_provider }}/services/{service}/{{ check_name }}/` +- **Test files**: Files in `tests/providers/{{ check_provider }}/services/{service}/{{ check_name }}/` +- **Service files** (if modified): Files like `{service}_service.py`, `{service}_client.py`, models +- **Other files**: Any other modified files + +Review ALL files, but prioritize check files for the detailed review. + +### Step 1: Read the check files + +- `prowler/providers/{{ check_provider }}/services/{service}/{{ check_name }}/{{ check_name }}.py` +- `prowler/providers/{{ check_provider }}/services/{service}/{{ check_name }}/{{ check_name }}.metadata.json` + +### Step 2: Read the test files + +- `tests/providers/{{ check_provider }}/services/{service}/{{ check_name }}/{{ check_name }}_test.py` + +### Step 3: Review any modified service files + +If service files were modified (discovered in Step 0), review them for: +- Proper docstrings and type hints +- Consistent with existing service patterns +- Quality-focused review (assume changes are intentional; don't validate impact on other checks) + +### Step 4: Validate Metadata Against Official Schema + +This step is MANDATORY. Validate EVERY field in the metadata file: + +1. **Read the metadata file**: `{{ check_name }}.metadata.json` +2. **Check each required field** against the Metadata Validation rules above +3. **Verify field values**: + - Provider matches `{{ check_provider }}` + - CheckID matches `{{ check_name }}` + - ServiceName matches the service folder + - Severity is valid lowercase value + - ResourceGroup is from the allowed list + - CheckTitle follows the active verb pattern (CRITICAL) + - Description ≤ 400 chars + - Risk ≤ 400 chars + - Deprecated fields are empty strings +4. **Cross-reference with docs** if needed: + - Read `docs/developer-guide/checks.mdx` + - Read `docs/developer-guide/check-metadata-guidelines.mdx` + +### Step 5: Identify issues and categorize by severity + +- **Critical**: Security vulnerabilities, breaking issues +- **Major**: Guideline violations, missing required elements +- **Minor**: Style improvements, suggestions + +### Step 6: Auto-fix Critical and Major issues + +Apply fixes directly to the code + +## Output Format + +After your review, report: + +1. **Summary**: High-level assessment +2. **Issues Found**: List each issue with severity, category, file, and description +3. **Fixes Applied**: List of changes made +4. **Remaining Issues**: Any issues that couldn't be auto-fixed + +## Auto-Fix Rules + +You MUST automatically fix: +- Missing docstrings +- Improper imports +- Type hint issues + +**Metadata Auto-Fixes (MANDATORY):** +- Missing required metadata fields (add with appropriate values) +- Incorrect field types (e.g., string instead of array) +- Invalid severity values (convert to lowercase valid option) +- CheckTitle format violations: + - Rewrite "X is enabled for Y" → "X protects/ensures Y from Z" + - Rewrite "X is configured" → "X ensures/prevents..." + - Ensure active verbs are used +- Deprecated fields with values (clear SubServiceName, ResourceIdTemplate, RelatedUrl to empty strings) +- CheckTitle exceeding 150 characters (shorten while preserving meaning) +- Description/Risk exceeding 400 characters (shorten while preserving meaning) +- Invalid ResourceGroup values (map to closest valid option) +- Invalid Severity values (map to appropriate level) + +Report but do NOT fix: +- Architectural changes +- Logic changes that might alter behavior +- Anything that requires clarification +- Severity level choices (report if questionable, but don't change without clear justification) + +## Important Notes + +- Focus on issues that meaningfully impact code quality, security, or maintainability +- Distinguish between hard requirements (must fix) and suggestions (nice to have) +- Provide specific, actionable feedback +- Remember that Prowler is a security tool trusted by thousands of organizations + +Now review the `{{ check_name }}` check and apply necessary fixes. diff --git a/src/agents/testing/__init__.py b/src/agents/testing/__init__.py new file mode 100644 index 0000000..c39c30d --- /dev/null +++ b/src/agents/testing/__init__.py @@ -0,0 +1,6 @@ +"""Testing agent module.""" + +from agents.testing.agent import TestingAgent +from agents.testing.models import TestingResult + +__all__ = ["TestingAgent", "TestingResult"] diff --git a/src/agents/testing/agent.py b/src/agents/testing/agent.py new file mode 100644 index 0000000..1a95911 --- /dev/null +++ b/src/agents/testing/agent.py @@ -0,0 +1,208 @@ +"""Testing agent for generating and running Prowler check tests.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING, Any, ClassVar + +if TYPE_CHECKING: + from git import Repo + +from claude_agent_sdk import ( + ClaudeAgentOptions, + ClaudeSDKClient, +) + +from agents.base import Agent +from agents.testing.models import TestingResult +from tools.prowler import run_pytest +from utils.prompts import load_prompt + + +class TestingAgent(Agent): + """Agent that generates and runs tests for Prowler checks.""" + + MAX_TEST_FIX_ATTEMPTS: int = 5 + ALLOWED_TOOLS: ClassVar[list[str]] = [ + "Read", + "Write", + "Edit", + "Bash", + "Glob", + "Grep", + ] + + def __init__( + self, + working_dir: Path, + check_name: str, + check_provider: str, + prowler_repo: Repo, + check_ticket: str | None = None, + **kwargs: Any, + ) -> None: + super().__init__(working_dir, **kwargs) + self.check_name: str = check_name + self.check_provider: str = check_provider + self.prowler_repo: Repo = prowler_repo + self.check_ticket: str | None = check_ticket + + async def run(self, **inputs: Any) -> TestingResult: + """ + Generate and run tests for a Prowler check. + + Returns: + TestingResult with testing information + """ + logging.info("[bold cyan]Running testing agent...[/bold cyan]") + + service: str = self.check_name.split("_")[0] + test_file_path: str = self._build_test_file_path(service) + options: ClaudeAgentOptions = self._create_claude_options() + + async with ClaudeSDKClient(options=options) as client: + await self._generate_tests(client) + success: bool = await self._run_test_and_fix_loop( + client=client, + service=service, + test_file_path=test_file_path, + ) + + return TestingResult( + success=success, + check_name=self.check_name, + test_file_path=test_file_path, + changes_made=True, + ) + + def _build_test_file_path(self, service: str) -> str: + """Build the test file path for the check.""" + return ( + f"tests/providers/{self.check_provider}/services/{service}/" + f"{self.check_name}/{self.check_name}_test.py" + ) + + async def _generate_tests(self, client: ClaudeSDKClient) -> None: + """Generate tests using Claude agent.""" + logging.info("[yellow]Generating tests...[/yellow]") + generate_prompt: str = self._load_generate_prompt() + await client.query(generate_prompt) + await self._process_agent_messages(client=client) + + async def _run_test_and_fix_loop( + self, + client: ClaudeSDKClient, + service: str, + test_file_path: str, + ) -> bool: + """ + Run tests and attempt fixes until success or max attempts reached. + + This method runs the check-specific tests first, then service-wide tests. + If any tests fail, it attempts to fix them using the Claude agent. + + Returns: + True if tests pass, False otherwise. + """ + prowler_directory: Path = Path(self.prowler_repo.working_dir) + attempt: int = 0 + success: bool = False + + while attempt < self.MAX_TEST_FIX_ATTEMPTS and not success: + attempt += 1 + logging.info( + f"[yellow]Running tests (attempt {attempt}/{self.MAX_TEST_FIX_ATTEMPTS})...[/yellow]" + ) + + # Run check-specific tests first + check_test_result = run_pytest( + test_path=Path(test_file_path), + prowler_directory=prowler_directory, + ) + + if not check_test_result.success: + logging.info("[yellow]Check tests failed, attempting fix...[/yellow]") + await self._attempt_fix( + client, test_file_path, check_test_result.error_output, attempt + ) + continue + + # Check tests passed, now run service-wide tests + service_test_path: str = ( + f"tests/providers/{self.check_provider}/services/{service}/" + ) + service_test_result = run_pytest( + test_path=Path(service_test_path), + prowler_directory=prowler_directory, + ) + + if service_test_result.success: + success = True + logging.info( + f"[green]✓ All tests passed for {self.check_name} and service {service}[/green]" + ) + else: + logging.info("[yellow]Service tests failed, attempting fix...[/yellow]") + await self._attempt_fix( + client, test_file_path, service_test_result.error_output, attempt + ) + + if not success: + logging.error(f"Tests failed after {self.MAX_TEST_FIX_ATTEMPTS} attempts") + + return success + + async def _attempt_fix( + self, + client: ClaudeSDKClient, + test_file_path: str, + error_output: str, + attempt: int, + ) -> None: + """Attempt to fix failing tests.""" + fix_prompt: str = self._load_fix_prompt( + test_file_path=test_file_path, + error_output=error_output, + attempt=attempt, + ) + await client.query(fix_prompt) + await self._process_agent_messages(client=client) + + def _load_generate_prompt(self) -> str: + """Load the test generation prompt template.""" + prompt_path: Path = Path(__file__).parent / "prompts" / "generate_tests.jinja" + return load_prompt( + path=prompt_path, + context={ + "check_name": self.check_name, + "check_provider": self.check_provider, + "prowler_repo": str(self.working_dir), + "check_ticket": self.check_ticket, + }, + ) + + def _load_fix_prompt( + self, test_file_path: str, error_output: str, attempt: int + ) -> str: + """Load the test fix prompt template.""" + prompt_path: Path = Path(__file__).parent / "prompts" / "fix_tests.jinja" + return load_prompt( + path=prompt_path, + context={ + "check_name": self.check_name, + "check_provider": self.check_provider, + "test_file_path": test_file_path, + "error_output": error_output, + "attempt": attempt, + "max_attempts": self.MAX_TEST_FIX_ATTEMPTS, + }, + ) + + def _create_claude_options(self) -> ClaudeAgentOptions: + """Create Claude agent options with tools.""" + return ClaudeAgentOptions( + allowed_tools=self.ALLOWED_TOOLS, + permission_mode="bypassPermissions", + cwd=str(self.working_dir), + ) diff --git a/src/agents/testing/models.py b/src/agents/testing/models.py new file mode 100644 index 0000000..1628b2a --- /dev/null +++ b/src/agents/testing/models.py @@ -0,0 +1,15 @@ +"""Pydantic models for TestingAgent results.""" + +from pydantic import BaseModel, Field + + +class TestingResult(BaseModel): + """Result of testing a Prowler check.""" + + success: bool = Field(description="Whether all tests passed") + check_name: str = Field(default="", description="Name of the tested check") + test_file_path: str = Field(default="", description="Path to the test file") + changes_made: bool = Field( + default=False, description="Whether changes were made to tests" + ) + error: str | None = Field(default=None, description="Error message if failed") diff --git a/src/agents/testing/prompts/fix_tests.jinja b/src/agents/testing/prompts/fix_tests.jinja new file mode 100644 index 0000000..89f1bda --- /dev/null +++ b/src/agents/testing/prompts/fix_tests.jinja @@ -0,0 +1,58 @@ +# Fix Failing Tests for Prowler Check + +The tests for check '{{ check_name }}' are failing. You need to analyze and fix them. + +## Test Failure Information + +**Check Name**: `{{ check_name }}` +**Provider**: `{{ check_provider }}` +**Test File**: `{{ test_file_path }}` +**Attempt**: {{ attempt }} of {{ max_attempts }} + +## Error Output + +``` +{{ error_output }} +``` + +## Instructions + +1. **Analyze the error** - Understand what's causing the test failure +2. **Read the test file** at `{{ test_file_path }}` +3. **Read the check implementation** to understand expected behavior +4. **Fix the issue** - Common issues include: + - Incorrect mock setup + - Missing imports inside mock.patch context + - Wrong assertion values + - Incorrect service client mocking path + - Missing resource setup in moto (for AWS) +5. **Ensure the fix doesn't break other tests** + +## Common Fixes + +### Import Issues +```python +# WRONG - Import outside mock context +from prowler.providers.aws.services.{service}.{check}.{check} import {check} + +# CORRECT - Import inside mock context +with mock.patch(...): + from prowler.providers.aws.services.{service}.{check}.{check} import {check} +``` + +### Mock Path Issues +```python +# Ensure the mock path matches exactly: +"prowler.providers.{{ check_provider }}.services.{service}.{{ check_name }}.{{ check_name }}.{service}_client" +``` + +### Assertion Issues +- Verify `status_extended` matches the actual message from the check +- Verify `resource_id` and `resource_name` match what's created in setup + +## References + +- **Testing Skill**: `skills/prowler-test-sdk/SKILL.md` +- **Check Implementation**: `prowler/providers/{{ check_provider }}/services/{service}/{{ check_name }}/{{ check_name }}.py` + +Please fix the failing tests and ensure they pass. diff --git a/src/agents/testing/prompts/generate_tests.jinja b/src/agents/testing/prompts/generate_tests.jinja new file mode 100644 index 0000000..a80dc8f --- /dev/null +++ b/src/agents/testing/prompts/generate_tests.jinja @@ -0,0 +1,85 @@ +# Generate Unit Tests for Prowler Check + +You are tasked with generating unit tests for a newly implemented Prowler security check. + +## Check Information + +- **Check Name**: `{{ check_name }}` +- **Provider**: `{{ check_provider }}` +- **Prowler Repository**: `{{ prowler_repo }}` +{% if check_ticket %} +- **Check Ticket**: {{ check_ticket }} +{% endif %} + +## Testing Guidelines + +### Provider-Specific Mocking Rules + +| Provider | Mocking Approach | Decorator | +|----------|------------------|-----------| +| **AWS** | `moto` library | `@mock_aws` | +| **Azure, GCP, K8s, others** | `MagicMock` | None | + +**CRITICAL**: NEVER use moto for non-AWS providers. NEVER use MagicMock for AWS. + +### Required Test Scenarios + +Every check MUST have tests for: + +1. **`test_no_resources`**: When no resources exist, verify `len(results) == 0` +2. **`test_{check_name}_pass`**: When resource is compliant, verify `status == "PASS"` +3. **`test_{check_name}_fail`**: When resource is non-compliant, verify `status == "FAIL"` + +### Test File Structure + +Tests should be placed at: +``` +tests/providers/{{ check_provider }}/services/{service}/{{ check_name }}/{{ check_name }}_test.py +``` + +## Implementation Steps + +1. **Read the check implementation** at `prowler/providers/{{ check_provider }}/services/{service}/{{ check_name }}/{{ check_name }}.py` +2. **Identify the service** from the check name (first part before underscore) +3. **Find existing tests** for the same service to understand patterns +4. **Read the testing skill** at `skills/prowler-test-sdk/SKILL.md` for complete patterns +{% if check_provider == "aws" %} +5. **Use the AWS test template** at `skills/prowler-test-sdk/assets/aws_test.py` +{% else %} +5. **Use the {{ check_provider | upper }} test template** at `skills/prowler-test-sdk/assets/{{ check_provider }}_test.py` (or Azure pattern for {{ check_provider }}) +{% endif %} +6. **Create the test file** with all required scenarios +7. **Ensure imports are inside mock.patch context** (critical for proper mocking) + +## Assertions to Include + +```python +# Always verify these +assert result[0].status == "PASS" # or "FAIL" +assert result[0].status_extended == "Expected message..." +assert result[0].resource_id == expected_id +{% if check_provider == "aws" %} +assert result[0].region == "us-east-1" +{% elif check_provider == "azure" %} +assert result[0].subscription == AZURE_SUBSCRIPTION_ID +{% elif check_provider == "gcp" %} +assert result[0].project_id == GCP_PROJECT_ID +{% endif %} +``` + +## References + +- **Testing Documentation**: `docs/developer-guide/unit-testing.mdx` +- **Testing Skill**: `skills/prowler-test-sdk/SKILL.md` +- **Provider Fixtures**: +{% if check_provider == "aws" %} + - `tests/providers/aws/utils.py` - `AWS_REGION_US_EAST_1`, `set_mocked_aws_provider` +{% elif check_provider == "azure" %} + - `tests/providers/azure/azure_fixtures.py` - `AZURE_SUBSCRIPTION_ID`, `set_mocked_azure_provider` +{% elif check_provider == "gcp" %} + - `tests/providers/gcp/gcp_fixtures.py` - `GCP_PROJECT_ID`, `set_mocked_gcp_provider` +{% elif check_provider == "kubernetes" %} + - `tests/providers/kubernetes/kubernetes_fixtures.py` - `set_mocked_kubernetes_provider` +{% endif %} + +Now generate comprehensive unit tests for the `{{ check_name }}` check following these guidelines. diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/exceptions.py b/src/core/exceptions.py new file mode 100644 index 0000000..49fd6d0 --- /dev/null +++ b/src/core/exceptions.py @@ -0,0 +1,17 @@ +"""Core exceptions for Prowler Studio.""" + + +class ProwlerStudioError(Exception): + """Base exception for all Prowler Studio errors.""" + + +class AgentError(ProwlerStudioError): + """Base exception for agent-related errors.""" + + +class ToolError(ProwlerStudioError): + """Base exception for tool-related errors.""" + + +class ConfigurationError(ProwlerStudioError): + """Base exception for configuration-related errors.""" diff --git a/src/core/main.py b/src/core/main.py new file mode 100644 index 0000000..4bd8ae5 --- /dev/null +++ b/src/core/main.py @@ -0,0 +1,499 @@ +"""Main CLI for Prowler Studio.""" + +import asyncio +import logging +import time +from pathlib import Path +from typing import TYPE_CHECKING, Annotated + +import typer +from git import GitError, InvalidGitRepositoryError, Repo +from rich.console import Console + +from agents.compliance_mapping.agent import ComplianceMappingAgent +from agents.implementation.agent import ChecKreatorAgent +from agents.pr_creation.agent import PRCreationAgent +from agents.review.agent import ReviewAgent +from agents.testing.agent import TestingAgent +from tools.git import ( + create_worktree, + ensure_main_repo_exists, + generate_branch_name, + get_worktree_name, + prepare_repo_for_work, + remove_worktree, + rename_branch, + update_main_repo, +) +from tools.github_client import GitHubClient, GitHubClientError, GitHubIssueContent +from tools.github_issue import GitHubIssueInfo, parse_github_issue_url +from tools.jira import parse_jira_url +from tools.jira_client import JiraClient, JiraClientError, JiraTicketContent +from tools.prowler import ProwlerToolError, install_prowler_dependencies +from tools.skills import setup_prowler_skills +from utils.logging import setup_logging + +_console = Console() + +if TYPE_CHECKING: + from agents.compliance_mapping.models import ComplianceMappingResult + from agents.implementation.models import CheckImplementationResult + from agents.pr_creation.models import PRCreationResult + from agents.review.models import ReviewResult + from agents.testing.models import TestingResult + +app = typer.Typer() + +PROWLER_REPO_URL = "git@github.com:prowler-cloud/prowler.git" + + +@app.command() +def create_check( + branch_name: Annotated[ + str | None, + typer.Option( + "--branch", + "-b", + help="Branch name (default: feat/- or feat/)", + ), + ] = None, + ticket_file: Annotated[ + Path | None, + typer.Option( + "--ticket", + "-t", + help="Path to the markdown check ticket file", + ), + ] = None, + jira_url: Annotated[ + str | None, + typer.Option( + "--jira-url", + "-j", + help="Jira ticket URL (e.g., https://mycompany.atlassian.net/browse/PROJ-123)", + ), + ] = None, + github_url: Annotated[ + str | None, + typer.Option( + "--github-url", + "-g", + help="GitHub issue URL (e.g., https://github.com/owner/repo/issues/123)", + ), + ] = None, + working_dir: Annotated[ + Path, + typer.Option( + "--working-dir", + "-w", + help="Path to the working directory (default: ./working)", + ), + ] = Path("./working"), + no_worktree: Annotated[ + bool, + typer.Option( + "--no-worktree", + help="Legacy mode: work directly on main clone instead of using worktrees", + ), + ] = False, + cleanup_worktree: Annotated[ + bool, + typer.Option( + "--cleanup-worktree", + help="Remove worktree after successful PR creation", + ), + ] = False, + local: Annotated[ + bool, + typer.Option( + "--local", + help="Keep changes local only (no push, no PR creation)", + ), + ] = False, +) -> None: + """ + Create a Prowler check from a markdown ticket, Jira URL, or GitHub issue URL. + + This will: + 1. Clone/prepare the Prowler repository + 2. Run the implementation agent to create the check + 3. Verify the check is loaded correctly + + You must provide exactly one of: --ticket, --jira-url, or --github-url. + """ + # Validate input: must provide exactly one source + input_sources = [ticket_file, jira_url, github_url] + provided = sum(1 for s in input_sources if s) + if provided == 0: + _console.print( + "[red]✗ Must provide --ticket, --jira-url, or --github-url[/red]" + ) + raise typer.Exit(code=1) + if provided > 1: + _console.print("[red]✗ Cannot provide multiple input sources[/red]") + raise typer.Exit(code=1) + + if local and cleanup_worktree: + _console.print( + "[red]✗ Cannot use --local with --cleanup-worktree (changes would be lost)[/red]" + ) + raise typer.Exit(code=1) + + # Validate file path if provided + if ticket_file: + ticket_file = ticket_file.resolve() + if not ticket_file.exists(): + _console.print(f"[red]✗ Ticket file not found: {ticket_file}[/red]") + raise typer.Exit(code=1) + if not ticket_file.is_file(): + _console.print(f"[red]✗ Path is not a file: {ticket_file}[/red]") + raise typer.Exit(code=1) + + # Parse Jira URL if provided (validation only, fetch after logger is initialized) + jira_issue_key: str | None = None + jira_ticket_content: JiraTicketContent | None = None + jira_info = None + if jira_url: + try: + jira_info = parse_jira_url(jira_url) + jira_issue_key = jira_info.issue_key + except ValueError as e: + _console.print(f"[red]✗ {e}[/red]") + raise typer.Exit(code=1) from e + + # Parse GitHub URL if provided (validation only, fetch after logger is initialized) + github_issue_key: str | None = None + github_issue_content: GitHubIssueContent | None = None + github_info: GitHubIssueInfo | None = None + if github_url: + try: + github_info = parse_github_issue_url(github_url) + github_issue_key = f"{github_info.repo}-{github_info.issue_number}" + except ValueError as e: + _console.print(f"[red]✗ {e}[/red]") + raise typer.Exit(code=1) from e + + # Setup working directory + working_dir = working_dir.resolve() + working_dir.mkdir(parents=True, exist_ok=True) + + # Initialize logging + issue_key = jira_issue_key or github_issue_key + log_file = setup_logging(base_dir=working_dir, ticket=issue_key) + logging.info("") + logging.info("=" * 60) + logging.info("STAGE: Prowler Studio - Check Creation") + logging.info("=" * 60) + logging.info(f"[cyan]Log file: {log_file}[/cyan]") + + # Fetch Jira ticket content (now that logger is available) + if jira_info and jira_issue_key: + try: + logging.info(f"[cyan]Jira ticket: {jira_issue_key}[/cyan]") + logging.info("[bold]Fetching Jira ticket content...[/bold]") + jira_client = JiraClient(site_url=jira_info.site_url) + jira_ticket_content = jira_client.fetch_ticket(jira_issue_key) + logging.info(f"[green]✓ Fetched: {jira_ticket_content.summary}[/green]") + except JiraClientError as e: + logging.error(f"Failed to fetch Jira ticket: {e}") + raise typer.Exit(code=1) from e + + # Fetch GitHub issue content (now that logger is available) + if github_info and github_issue_key: + try: + logging.info(f"[cyan]GitHub issue: {github_issue_key}[/cyan]") + logging.info("[bold]Fetching GitHub issue content...[/bold]") + github_client = GitHubClient() + github_issue_content = github_client.fetch_issue( + owner=github_info.owner, + repo=github_info.repo, + issue_number=github_info.issue_number, + ) + logging.info(f"[green]✓ Fetched: {github_issue_content.title}[/green]") + except GitHubClientError as e: + logging.error(f"Failed to fetch GitHub issue: {e}") + raise typer.Exit(code=1) from e + + # Clone/prepare Prowler repository + main_repo_path = working_dir / "prowler" + worktree_path: Path | None = None + main_repo: Repo | None = None + + try: + main_repo = ensure_main_repo_exists(working_dir, PROWLER_REPO_URL) + except InvalidGitRepositoryError as e: + logging.error("Directory exists but is not a valid git repository") + raise typer.Exit(code=1) from e + except GitError as e: + logging.error(f"Git error: {e}") + raise typer.Exit(code=1) from e + + # Determine branch name (use temp branch if not provided) + temp_branch_name: str | None = None + current_timestamp = int(time.time()) + if branch_name is None: + temp_branch_name = f"feat/new-check-{current_timestamp}" + effective_branch = temp_branch_name + else: + effective_branch = branch_name + + # Setup repository: worktree mode (default) or legacy mode + logging.info("[bold]Preparing repository...[/bold]") + + if not no_worktree: + # Worktree mode: create isolated worktree for this work + update_main_repo(main_repo) + worktree_name = get_worktree_name(issue_key, ticket_file, current_timestamp) + worktree_path = working_dir / "worktrees" / worktree_name + repo = create_worktree(main_repo, worktree_path, effective_branch) + prowler_repo_path = worktree_path + logging.info(f"[cyan]Worktree: {worktree_path}[/cyan]") + else: + # Legacy mode: work directly on main clone + prepare_repo_for_work(main_repo, effective_branch) + repo = main_repo + prowler_repo_path = main_repo_path + + # Setup AI skills for Claude (non-blocking on failure) + skills_result = setup_prowler_skills(prowler_directory=prowler_repo_path) + if not skills_result.success: + logging.warning(f"Skills setup incomplete: {skills_result.message}") + logging.info("[yellow] Continuing without full skills integration...[/yellow]") + + # Install Prowler dependencies + try: + install_prowler_dependencies(prowler_repo_path) + except ProwlerToolError as e: + logging.error(f"Failed to install Prowler dependencies: {e}") + raise typer.Exit(code=1) from e + + try: + # Get ticket content from file, Jira, or GitHub + check_ticket_content: str | None = None + if ticket_file: + check_ticket_content = ticket_file.read_text() + elif jira_ticket_content: + check_ticket_content = jira_ticket_content.to_markdown() + elif github_issue_content: + check_ticket_content = github_issue_content.to_markdown() + + # Stage 1: Check Implementation + logging.info("") + logging.info("=" * 60) + logging.info("STAGE: Stage 1: Check Implementation") + logging.info("=" * 60) + implementation_agent: ChecKreatorAgent = ChecKreatorAgent( + working_dir=prowler_repo_path, + check_ticket=check_ticket_content, + prowler_repo=repo, + ) + + impl_result: CheckImplementationResult = asyncio.run(implementation_agent.run()) + + if not impl_result.success: + logging.error("Check implementation failed verification") + if impl_result.error: + logging.error(f"Error: {impl_result.error}") + raise typer.Exit(code=1) + + logging.info("[green]✓ Check implementation completed[/green]") + logging.info(f" Check name: {impl_result.check_name}") + logging.info(f" Provider: {impl_result.check_provider}") + + # Rename branch if we used a temporary name + final_branch_name: str + if temp_branch_name is not None: + # User didn't provide --branch, rename temp branch to final name + final_branch_name = generate_branch_name(impl_result.check_name, issue_key) + rename_branch(repo, temp_branch_name, final_branch_name) + logging.info(f"[green]✓ Branch renamed to: {final_branch_name}[/green]") + else: + # User provided explicit --branch name, use it as-is + final_branch_name = branch_name # type: ignore[assignment] + + # Stage 2: Testing + logging.info("") + logging.info("=" * 60) + logging.info("STAGE: Stage 2: Testing") + logging.info("=" * 60) + testing_agent: TestingAgent = TestingAgent( + working_dir=prowler_repo_path, + check_name=impl_result.check_name, + check_provider=impl_result.check_provider, + prowler_repo=repo, + check_ticket=check_ticket_content, + ) + + test_result: TestingResult = asyncio.run(testing_agent.run()) + + if not test_result.success: + logging.error("Testing failed") + if test_result.error: + logging.error(f"Error: {test_result.error}") + raise typer.Exit(code=1) + + logging.info("[green]✓ Testing completed[/green]") + logging.info(f" Test file: {test_result.test_file_path}") + + # Stage 3: Compliance Mapping + logging.info("") + logging.info("=" * 60) + logging.info("STAGE: Stage 3: Compliance Mapping") + logging.info("=" * 60) + compliance_agent: ComplianceMappingAgent = ComplianceMappingAgent( + working_dir=prowler_repo_path, + check_name=impl_result.check_name, + check_provider=impl_result.check_provider, + prowler_repo=repo, + check_ticket=check_ticket_content, + ) + + compliance_result: ComplianceMappingResult = asyncio.run(compliance_agent.run()) + + if not compliance_result.success: + logging.error("Compliance mapping failed") + if compliance_result.error: + logging.error(f"Error: {compliance_result.error}") + raise typer.Exit(code=1) + + logging.info("[green]✓ Compliance mapping completed[/green]") + if compliance_result.changes_made: + logging.info(f" Files modified: {compliance_result.mappings_added}") + + # Stage 4: Review + logging.info("") + logging.info("=" * 60) + logging.info("STAGE: Stage 4: Code Review") + logging.info("=" * 60) + review_agent: ReviewAgent = ReviewAgent( + working_dir=prowler_repo_path, + check_name=impl_result.check_name, + check_provider=impl_result.check_provider, + prowler_repo=repo, + ) + + review_result: ReviewResult = asyncio.run(review_agent.run()) + + if not review_result.success: + logging.error("Review failed") + raise typer.Exit(code=1) + + logging.info("[green]✓ Review completed[/green]") + + # Stage 5: Re-test if review made changes + if review_result.changes_made: + logging.info("") + logging.info("=" * 60) + logging.info("STAGE: Stage 5: Re-testing (review made changes)") + logging.info("=" * 60) + retest_result: TestingResult = asyncio.run(testing_agent.run()) + + if not retest_result.success: + logging.error("Re-testing failed after review changes") + if retest_result.error: + logging.error(f"Error: {retest_result.error}") + raise typer.Exit(code=1) + + logging.info("[green]✓ Re-testing completed[/green]") + + # Stage 6: PR Creation (unless --local) + pr_result: PRCreationResult | None = None + if not local: + logging.info("") + logging.info("=" * 60) + logging.info("STAGE: Stage 6: PR Creation") + logging.info("=" * 60) + source_url = jira_url or github_url + pr_agent: PRCreationAgent = PRCreationAgent( + working_dir=prowler_repo_path, + check_name=impl_result.check_name, + check_provider=impl_result.check_provider, + branch_name=final_branch_name, + prowler_repo=repo, + source_url=source_url, + check_ticket=check_ticket_content, + ) + + pr_result = asyncio.run(pr_agent.run()) + else: + logging.info("") + logging.info("=" * 60) + logging.info("STAGE: Stage 6: PR Creation (skipped - local mode)") + logging.info("=" * 60) + logging.info("[yellow]Local mode: skipping push and PR creation[/yellow]") + + # Display final results + logging.info("") + logging.info("=" * 60) + logging.info("STAGE: Final Results") + logging.info("=" * 60) + + if local: + # Local mode: no push or PR creation + logging.info( + "[green]✓ Workflow completed successfully (local mode)![/green]" + ) + logging.info(f" Check name: {impl_result.check_name}") + logging.info(f" Provider: {impl_result.check_provider}") + logging.info(f" Branch: {final_branch_name}") + logging.info(f" Working directory: {prowler_repo_path}") + logging.info("") + logging.info("[cyan]When ready to push and create PR:[/cyan]") + logging.info(f" cd {prowler_repo_path}") + logging.info(f" git push -u origin {final_branch_name}") + logging.info(" gh pr create") + + logging.info("=" * 60) + logging.info("WORKFLOW COMPLETE") + logging.info("=" * 60) + elif pr_result and pr_result.success: + logging.info("[green]✓ Workflow completed successfully![/green]") + logging.info(f" Check name: {impl_result.check_name}") + logging.info(f" Provider: {impl_result.check_provider}") + logging.info(f" Branch: {final_branch_name}") + logging.info(f" PR: {pr_result.pr_url}") + logging.info(f" Commit: {pr_result.commit_sha[:8]}") + + # Cleanup worktree if requested + if cleanup_worktree and not no_worktree and worktree_path and main_repo: + logging.info("[yellow]Cleaning up worktree...[/yellow]") + remove_worktree(main_repo, worktree_path) + logging.info("[green]✓ Worktree removed[/green]") + + logging.info("=" * 60) + logging.info("WORKFLOW COMPLETE") + logging.info("=" * 60) + else: + logging.warning("Workflow completed but PR creation failed") + logging.info(f" Check name: {impl_result.check_name}") + logging.info(f" Provider: {impl_result.check_provider}") + logging.info(f" Branch: {final_branch_name}") + if pr_result and pr_result.error: + logging.info(f" PR Error: {pr_result.error}") + logging.info("[yellow]You can create the PR manually with:[/yellow]") + logging.info(f" cd {prowler_repo_path}") + logging.info(f" git push -u origin {final_branch_name}") + logging.info(" gh pr create") + logging.info("=" * 60) + logging.info("WORKFLOW COMPLETE") + logging.info("=" * 60) + + except typer.Exit: + logging.info("=" * 60) + logging.info("WORKFLOW COMPLETE") + logging.info("=" * 60) + raise + except Exception as e: + logging.error(f"Error: {e}") + logging.info("=" * 60) + logging.info("WORKFLOW COMPLETE") + logging.info("=" * 60) + raise typer.Exit(code=1) from e + + +if __name__ == "__main__": + try: + app() + except Exception as e: + _console.print(f"[red]✗ Unexpected error: {e}[/red]") + raise typer.Exit(code=1) from e diff --git a/src/tools/__init__.py b/src/tools/__init__.py new file mode 100644 index 0000000..8d3c7a6 --- /dev/null +++ b/src/tools/__init__.py @@ -0,0 +1,27 @@ +"""Shared tools for Prowler Studio.""" + +from tools.git import prepare_repo_for_work +from tools.github_client import GitHubClient, GitHubClientError, GitHubIssueContent +from tools.github_issue import GitHubIssueInfo, parse_github_issue_url +from tools.prowler import ( + ProwlerToolError, + install_prowler_dependencies, + mkcheck, + verify_check_loaded, +) +from tools.skills import SkillsSetupError, setup_prowler_skills + +__all__ = [ + "GitHubClient", + "GitHubClientError", + "GitHubIssueContent", + "GitHubIssueInfo", + "ProwlerToolError", + "SkillsSetupError", + "install_prowler_dependencies", + "mkcheck", + "parse_github_issue_url", + "prepare_repo_for_work", + "setup_prowler_skills", + "verify_check_loaded", +] diff --git a/src/tools/git.py b/src/tools/git.py new file mode 100644 index 0000000..95cdc3f --- /dev/null +++ b/src/tools/git.py @@ -0,0 +1,330 @@ +"""Git repository tools.""" + +from pathlib import Path + +from git import Repo +from git.exc import GitCommandError +from rich import print + +# Constants +DEFAULT_BRANCH: str = "master" + + +def ensure_main_repo_exists(working_dir: Path, repo_url: str) -> Repo: + """ + Clone repo if not exists, return Repo object. + + Args: + working_dir: Working directory containing the repo + repo_url: Git URL to clone from + + Returns: + Repo object for the main repository + + Raises: + GitCommandError: If clone fails + """ + repo_path = working_dir / "prowler" + + if repo_path.exists(): + print(f"[yellow]Using existing Prowler repository at {repo_path}[/yellow]") + return Repo(repo_path) + else: + print("[bold]Cloning Prowler repository...[/bold]") + return Repo.clone_from(url=repo_url, to_path=repo_path) + + +def update_main_repo(repo: Repo, base_branch: str = DEFAULT_BRANCH) -> None: + """ + Fetch and update main branch before creating worktree. + + Args: + repo: The main Repo object + base_branch: Branch to update (default: master) + """ + print(f"[yellow]Updating {base_branch} branch...[/yellow]") + + # Fetch latest from origin + origin = repo.remotes.origin + origin.fetch() + + # Update local base branch to match remote + repo.git.checkout(base_branch) + repo.git.pull("origin", base_branch) + + print(f"[green]✓ Updated {base_branch} to latest[/green]") + + +def create_worktree( + main_repo: Repo, + worktree_path: Path, + branch_name: str, + base_branch: str = DEFAULT_BRANCH, +) -> Repo: + """ + Create new worktree with a new branch from base. + + Args: + main_repo: The main Repo object + worktree_path: Path where worktree will be created + branch_name: Name of the new branch to create + base_branch: Branch to base the new branch on (default: master) + + Returns: + Repo object for the new worktree + + Raises: + GitCommandError: If worktree creation fails + """ + # Ensure parent directory exists + worktree_path.parent.mkdir(parents=True, exist_ok=True) + + print(f"[yellow]Creating worktree at {worktree_path}...[/yellow]") + + # Create worktree with new branch based on base_branch + main_repo.git.worktree("add", "-b", branch_name, str(worktree_path), base_branch) + + print(f"[green]✓ Created worktree with branch '{branch_name}'[/green]") + + return Repo(worktree_path) + + +def remove_worktree(main_repo: Repo, worktree_path: Path) -> None: + """ + Remove worktree and its branch. + + Args: + main_repo: The main Repo object + worktree_path: Path to the worktree to remove + """ + print(f"[yellow]Removing worktree at {worktree_path}...[/yellow]") + + # Get the branch name before removing worktree + try: + worktree_repo = Repo(worktree_path) + branch_name = worktree_repo.active_branch.name + except Exception: + branch_name = None + + # Remove the worktree + main_repo.git.worktree("remove", str(worktree_path), "--force") + print("[green]✓ Removed worktree[/green]") + + # Optionally delete the branch if it exists + if branch_name: + try: + main_repo.git.branch("-D", branch_name) + print(f"[green]✓ Deleted branch '{branch_name}'[/green]") + except GitCommandError: + pass # Branch may already be deleted or not exist + + +def get_worktree_name( + ticket_key: str | None, + ticket_file: Path | None, + timestamp: int, +) -> str: + """ + Generate worktree directory name. + + Returns name based on available info: + - With Jira: 'prowler-123-1706450000' + - With file: '-1706450000' (e.g., 's3-bucket-check-1706450000') + - Neither: 'new-check-1706450000' + + Args: + ticket_key: Optional Jira ticket key (e.g., 'PROWLER-123') + ticket_file: Optional path to ticket file + timestamp: Unix timestamp + + Returns: + Worktree directory name + """ + if ticket_key: + # Convert PROWLER-123 to prowler-123 + return f"{ticket_key.lower()}-{timestamp}" + elif ticket_file: + # Use filename without extension + filename = ticket_file.stem.replace("_", "-") + return f"{filename}-{timestamp}" + else: + return f"new-check-{timestamp}" + + +def commit_changes( + repo: Repo, + message: str, + paths: list[str] | None = None, +) -> str: + """ + Stage and commit changes to the repository. + + Args: + repo: The git.Repo object + message: Commit message + paths: Optional list of paths to stage (stages all if None) + + Returns: + The commit SHA + + Raises: + GitCommandError: If commit fails + """ + try: + # Stage changes + if paths: + for path in paths: + repo.git.add(path) + else: + repo.git.add(".") + + # Check if there's anything to commit + if not repo.is_dirty(index=True): + print("[yellow]No changes to commit[/yellow]") + return repo.head.commit.hexsha + + # Create commit + commit = repo.index.commit(message) + print(f"[green]✓ Committed: {commit.hexsha[:8]} - {message}[/green]") + return commit.hexsha + + except GitCommandError as e: + print(f"[red]✗ Commit failed: {e}[/red]") + raise + + +def push_to_remote( + repo: Repo, + branch_name: str, + remote_name: str = "origin", +) -> None: + """ + Push branch to remote with upstream tracking. + + Args: + repo: The git.Repo object + branch_name: Name of the branch to push + remote_name: Name of the remote (default: origin) + + Raises: + GitCommandError: If push fails + """ + try: + print(f"[yellow]Pushing to {remote_name}/{branch_name}...[/yellow]") + repo.git.push("-u", remote_name, branch_name) + print(f"[green]✓ Pushed to {remote_name}/{branch_name}[/green]") + + except GitCommandError as e: + print(f"[red]✗ Push failed: {e}[/red]") + raise + + +def prepare_repo_for_work(repo: Repo, new_branch_name: str) -> None: + """ + Prepare a git repository for work. For that is needed to: + - Stash any existing changes + - Switch to principal branch (master/main) + - Pull latest changes + - Create a new branch for the work + + For not losing any changes, the changes will be stashed and applied after the work is done. + + Args: + repo: The git.Repo object + new_branch_name: Name of the new branch to create for work + + """ + try: + print("[bold]Preparing repository for work...[/bold]") + + # Step 1: Stash any uncommitted changes + if repo.is_dirty() or repo.untracked_files: + print("[yellow]⚠ Found uncommitted changes, stashing them...[/yellow]") + repo.git.stash("save", "Automated stash before branch preparation") + print("[green]✓ Changes stashed[/green]") + else: + print("No uncommitted changes to stash") + + # Step 2: Checkout principal branch + current_branch: str = repo.active_branch.name + if current_branch != DEFAULT_BRANCH: + print(f"\nSwitching from '{current_branch}' to '{DEFAULT_BRANCH}'...") + repo.git.checkout(DEFAULT_BRANCH) + print(f"[green]✓ Checked out '{DEFAULT_BRANCH}'[/green]") + else: + print(f"\nAlready on '{DEFAULT_BRANCH}'") + + # Step 3: Pull latest changes + print(f"\nPulling latest changes from origin/{DEFAULT_BRANCH}...") + origin = repo.remotes.origin + origin.pull() + print("[green]✓ Repository updated with latest changes[/green]") + + # Show latest commit + commit_message = repo.head.commit.message + if isinstance(commit_message, bytes): + commit_message = commit_message.decode("utf-8") + print( + f"Latest commit: {repo.head.commit.hexsha[:8]} - {commit_message.strip()}" + ) + + # Step 4: Create new branch for work + print(f"\nCreating new branch '{new_branch_name}'...") + + # Check if branch already exists + if new_branch_name in [branch.name for branch in repo.branches]: + print(f"[yellow]⚠ Branch '{new_branch_name}' already exists[/yellow]") + # Checkout existing branch + repo.git.checkout(new_branch_name) + print(f"[green]✓ Checked out existing branch '{new_branch_name}'[/green]") + else: + # Create and checkout new branch + new_branch = repo.create_head(new_branch_name) + new_branch.checkout() + print( + f"[green]✓ Created and checked out new branch '{new_branch_name}'[/green]" + ) + + print("[bold green]✓ Repository ready for work![/bold green]") + except Exception as e: + print(f"[bold red]✗ Error preparing repository: {e}[/bold red]") + raise e + + +def rename_branch(repo: Repo, old_name: str, new_name: str) -> None: + """ + Rename the current branch. + + Args: + repo: The git.Repo object + old_name: Current branch name + new_name: New branch name + + Raises: + GitCommandError: If rename fails + """ + try: + repo.git.branch("-m", old_name, new_name) + print(f"[green]✓ Branch renamed: {old_name} → {new_name}[/green]") + except GitCommandError as e: + print(f"[red]✗ Failed to rename branch: {e}[/red]") + raise + + +def generate_branch_name(check_name: str, ticket_key: str | None = None) -> str: + """ + Generate branch name from check name and optional ticket key. + + Args: + check_name: The check name (e.g., s3_bucket_public_access) + ticket_key: Optional Jira ticket key (e.g., PROWLER-707) + + Returns: + Branch name in format: + - feat/- if ticket provided + - feat/ if no ticket + """ + check_slug = check_name.replace("_", "-") + if ticket_key: + return f"feat/{ticket_key.lower()}-{check_slug}" + return f"feat/{check_slug}" diff --git a/src/tools/github_client.py b/src/tools/github_client.py new file mode 100644 index 0000000..3ba5731 --- /dev/null +++ b/src/tools/github_client.py @@ -0,0 +1,152 @@ +"""GitHub REST API client for fetching issue content.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Any + +import httpx + + +class GitHubClientError(Exception): + """Exception raised for GitHub API errors.""" + + +@dataclass +class GitHubIssueContent: + """Content fetched from a GitHub issue.""" + + number: int + title: str + body: str + state: str + labels: list[str] + url: str | None = None + + def to_markdown(self) -> str: + """ + Convert the issue content to a Markdown string. + + Returns: + Markdown-formatted issue content + """ + sections: list[str] = [ + f"# Issue #{self.number}: {self.title}", + "", + f"**State:** {self.state}", + ] + + if self.url: + sections.append(f"**URL:** {self.url}") + + if self.labels: + sections.append(f"**Labels:** {', '.join(self.labels)}") + + sections.extend(["", "## Description", "", self.body or "(No description)", ""]) + + return "\n".join(sections) + + +class GitHubClient: + """ + Client for fetching GitHub issues via REST API. + + Uses Bearer token authentication. + Tokens can be created at: https://github.com/settings/tokens + """ + + API_BASE_URL = "https://api.github.com" + + def __init__(self, token: str | None = None) -> None: + """ + Initialize the GitHub client. + + Args: + token: GitHub personal access token (falls back to GITHUB_TOKEN env var). + Optional for public repos (60 req/hr unauthenticated vs 5000 authenticated). + """ + self.token: str | None = token or os.getenv("GITHUB_TOKEN") + + def fetch_issue( + self, owner: str, repo: str, issue_number: int + ) -> GitHubIssueContent: + """ + Fetch a GitHub issue by owner, repo, and number. + + Args: + owner: Repository owner (e.g., 'prowler-cloud') + repo: Repository name (e.g., 'prowler') + issue_number: Issue number + + Returns: + GitHubIssueContent with issue details + + Raises: + GitHubClientError: If the API request fails + """ + url: str = f"{self.API_BASE_URL}/repos/{owner}/{repo}/issues/{issue_number}" + headers: dict[str, str] = { + "Accept": "application/vnd.github.v3+json", + } + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + + try: + with httpx.Client() as client: + response = client.get( + url, + headers=headers, + timeout=30.0, + ) + response.raise_for_status() + data: dict[str, Any] = response.json() + except httpx.HTTPStatusError as e: + if e.response.status_code == 401: + raise GitHubClientError( + "Authentication failed. Check your GITHUB_TOKEN." + ) from e + elif e.response.status_code == 403: + if "rate limit" in e.response.text.lower(): + msg = "GitHub API rate limit exceeded." + if not self.token: + msg += " Set GITHUB_TOKEN for higher limits." + raise GitHubClientError(msg) from e + raise GitHubClientError(f"Access forbidden: {e.response.text}") from e + elif e.response.status_code == 404: + raise GitHubClientError( + f"Issue {owner}/{repo}#{issue_number} not found." + ) from e + else: + raise GitHubClientError( + f"GitHub API error: {e.response.status_code} - {e.response.text}" + ) from e + except httpx.RequestError as e: + raise GitHubClientError(f"Request failed: {e}") from e + + return self._parse_response(data) + + def _parse_response(self, data: dict[str, Any]) -> GitHubIssueContent: + """ + Parse the GitHub API response into GitHubIssueContent. + + Args: + data: Raw API response data + + Returns: + Parsed GitHubIssueContent + """ + labels: list[str] = [ + label.get("name", "") + for label in data.get("labels", []) + if label.get("name") + ] + + return GitHubIssueContent( + number=data.get("number", 0), + title=data.get("title", ""), + body=data.get("body", "") or "", + state=data.get("state", ""), + labels=labels, + url=data.get("html_url"), + ) diff --git a/src/tools/github_issue.py b/src/tools/github_issue.py new file mode 100644 index 0000000..966e584 --- /dev/null +++ b/src/tools/github_issue.py @@ -0,0 +1,46 @@ +"""GitHub issue URL utilities.""" + +import re +from dataclasses import dataclass + + +@dataclass +class GitHubIssueInfo: + """Parsed GitHub issue information.""" + + owner: str + repo: str + issue_number: int + + @property + def url(self) -> str: + """Return the full GitHub issue URL.""" + return f"https://github.com/{self.owner}/{self.repo}/issues/{self.issue_number}" + + +def parse_github_issue_url(url: str) -> GitHubIssueInfo: + """ + Parse a GitHub issue URL into components. + + Args: + url: Full GitHub issue URL (e.g., https://github.com/owner/repo/issues/123) + + Returns: + GitHubIssueInfo with parsed components + + Raises: + ValueError: If URL format is invalid + """ + pattern = r"^https?://github\.com/([^/]+)/([^/]+)/issues/(\d+)/?(?:[#?].*)?$" + match = re.match(pattern, url) + if not match: + raise ValueError( + f"Invalid GitHub issue URL format: {url}. " + "Expected format: https://github.com/owner/repo/issues/123" + ) + + return GitHubIssueInfo( + owner=match.group(1), + repo=match.group(2), + issue_number=int(match.group(3)), + ) diff --git a/src/tools/jira.py b/src/tools/jira.py new file mode 100644 index 0000000..920f8e0 --- /dev/null +++ b/src/tools/jira.py @@ -0,0 +1,46 @@ +"""Jira URL utilities.""" + +import re +from dataclasses import dataclass + + +@dataclass +class JiraTicketInfo: + """Parsed Jira ticket information.""" + + site_url: str + project_key: str + issue_key: str + + +def parse_jira_url(url: str) -> JiraTicketInfo: + """ + Parse a Jira ticket URL into components. + + Args: + url: Full Jira ticket URL (e.g., https://mycompany.atlassian.net/browse/PROJ-123) + + Returns: + JiraTicketInfo with parsed components + + Raises: + ValueError: If URL format is invalid + """ + # Pattern: https://site.atlassian.net/browse/PROJ-123 + pattern = r"^(https?://[^/]+)/browse/([A-Z][A-Z0-9]*-\d+)$" + match = re.match(pattern, url) + if not match: + raise ValueError( + f"Invalid Jira URL format: {url}. " + "Expected format: https://site.atlassian.net/browse/PROJ-123" + ) + + site_url = match.group(1) + issue_key = match.group(2) + project_key = issue_key.split("-")[0] + + return JiraTicketInfo( + site_url=site_url, + project_key=project_key, + issue_key=issue_key, + ) diff --git a/src/tools/jira_client.py b/src/tools/jira_client.py new file mode 100644 index 0000000..66fbe8d --- /dev/null +++ b/src/tools/jira_client.py @@ -0,0 +1,304 @@ +"""Jira REST API client for fetching ticket content.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Any + +import httpx + + +class JiraClientError(Exception): + """Exception raised for Jira API errors.""" + + +@dataclass +class JiraTicketContent: + """Content fetched from a Jira ticket.""" + + key: str + summary: str + description: str + acceptance_criteria: str | None = None + status: str | None = None + issue_type: str | None = None + + def to_markdown(self) -> str: + """ + Convert the ticket content to a Markdown string. + + Returns: + Markdown-formatted ticket content + """ + sections: list[str] = [ + f"# {self.key}: {self.summary}", + "", + ] + + if self.issue_type: + sections.append(f"**Type:** {self.issue_type}") + if self.status: + sections.append(f"**Status:** {self.status}") + if self.issue_type or self.status: + sections.append("") + + if self.description: + sections.extend(["## Description", "", self.description, ""]) + + if self.acceptance_criteria: + sections.extend( + ["## Acceptance Criteria", "", self.acceptance_criteria, ""] + ) + + return "\n".join(sections) + + +class JiraClient: + """ + Client for fetching Jira tickets via REST API v3. + + Uses Basic Auth with email + API token. + API tokens can be created at: https://id.atlassian.com/manage-profile/security/api-tokens + """ + + def __init__( + self, + site_url: str | None = None, + email: str | None = None, + api_token: str | None = None, + ) -> None: + """ + Initialize the Jira client. + + Args: + site_url: Jira site URL (falls back to JIRA_SITE_URL env var) + email: Jira account email (falls back to JIRA_EMAIL env var) + api_token: Jira API token (falls back to JIRA_API_TOKEN env var) + + Raises: + JiraClientError: If credentials or site URL are missing + """ + self.site_url: str | None = site_url or os.getenv("JIRA_SITE_URL") + self.email: str | None = ( + email or os.getenv("JIRA_EMAIL") or os.getenv("JIRA_USER_EMAIL") + ) + self.api_token: str | None = api_token or os.getenv("JIRA_API_TOKEN") + + if not self.site_url: + raise JiraClientError( + "Jira site URL required. Set JIRA_SITE_URL environment variable " + "or pass site_url parameter." + ) + self.site_url = self.site_url.rstrip("/") + + if not self.email or not self.api_token: + raise JiraClientError( + "Jira credentials required. Set JIRA_EMAIL and JIRA_API_TOKEN " + "environment variables or pass email and api_token parameters." + ) + + def fetch_ticket(self, issue_key: str) -> JiraTicketContent: + """ + Fetch a Jira ticket by its key. + + Args: + issue_key: The issue key (e.g., PROJ-123) + + Returns: + JiraTicketContent with ticket details + + Raises: + JiraClientError: If the API request fails + """ + url: str = f"{self.site_url}/rest/api/3/issue/{issue_key}" + # Credentials are validated in __init__, so these are guaranteed to be str + assert self.email is not None and self.api_token is not None + auth: tuple[str, str] = (self.email, self.api_token) + + try: + with httpx.Client() as client: + response = client.get( + url, + auth=auth, + headers={"Accept": "application/json"}, + timeout=30.0, + ) + response.raise_for_status() + data: dict[str, Any] = response.json() + except httpx.HTTPStatusError as e: + if e.response.status_code == 401: + raise JiraClientError( + "Authentication failed. Check your JIRA_EMAIL and JIRA_API_TOKEN." + ) from e + elif e.response.status_code == 404: + raise JiraClientError(f"Issue {issue_key} not found.") from e + else: + raise JiraClientError( + f"Jira API error: {e.response.status_code} - {e.response.text}" + ) from e + except httpx.RequestError as e: + raise JiraClientError(f"Request failed: {e}") from e + + return self._parse_response(data) + + def _parse_response(self, data: dict[str, Any]) -> JiraTicketContent: + """ + Parse the Jira API response into JiraTicketContent. + + Args: + data: Raw API response data + + Returns: + Parsed JiraTicketContent + """ + fields: dict[str, Any] = data.get("fields", {}) + + # Extract basic fields + key: str = data.get("key", "") + summary: str = fields.get("summary", "") + + # Extract description (ADF format -> plain text) + description: str = self._parse_adf_content(fields.get("description")) + + # Extract status and issue type + status: str | None = None + if status_obj := fields.get("status"): + status = status_obj.get("name") + + issue_type: str | None = None + if issue_type_obj := fields.get("issuetype"): + issue_type = issue_type_obj.get("name") + + # Try to extract acceptance criteria from custom fields + acceptance_criteria: str | None = self._extract_acceptance_criteria(fields) + + return JiraTicketContent( + key=key, + summary=summary, + description=description, + acceptance_criteria=acceptance_criteria, + status=status, + issue_type=issue_type, + ) + + def _parse_adf_content(self, adf: dict[str, Any] | None) -> str: + """ + Parse Atlassian Document Format (ADF) content to plain text. + + Args: + adf: ADF content structure or None + + Returns: + Plain text representation + """ + if not adf: + return "" + + if isinstance(adf, str): + return adf + + result: list[str] = [] + self._extract_text_from_adf(adf, result) + return "\n".join(result) + + def _extract_text_from_adf(self, node: dict[str, Any], result: list[str]) -> None: + """ + Recursively extract text from ADF nodes. + + Args: + node: ADF node + result: List to append text to + """ + node_type: str = node.get("type", "") + + if node_type == "text": + text: str = node.get("text", "") + if text: + result.append(text) + elif node_type == "hardBreak": + result.append("") + elif node_type == "paragraph": + content: list[dict[str, Any]] = node.get("content", []) + para_text: list[str] = [] + for child in content: + self._extract_text_from_adf(child, para_text) + if para_text: + result.append(" ".join(para_text)) + result.append("") + elif node_type == "heading": + level: int = node.get("attrs", {}).get("level", 1) + content = node.get("content", []) + heading_text: list[str] = [] + for child in content: + self._extract_text_from_adf(child, heading_text) + if heading_text: + prefix: str = "#" * level + result.append(f"{prefix} {' '.join(heading_text)}") + result.append("") + elif node_type == "bulletList": + for item in node.get("content", []): + self._extract_list_item(item, result, bullet="- ") + elif node_type == "orderedList": + for idx, item in enumerate(node.get("content", []), 1): + self._extract_list_item(item, result, bullet=f"{idx}. ") + elif node_type == "codeBlock": + content = node.get("content", []) + code_text: list[str] = [] + for child in content: + self._extract_text_from_adf(child, code_text) + if code_text: + result.append("```") + result.extend(code_text) + result.append("```") + result.append("") + else: + # Process content for other node types + for child in node.get("content", []): + self._extract_text_from_adf(child, result) + + def _extract_list_item( + self, item: dict[str, Any], result: list[str], bullet: str + ) -> None: + """ + Extract text from a list item node. + + Args: + item: List item node + result: List to append text to + bullet: Bullet prefix (e.g., "- " or "1. ") + """ + item_text: list[str] = [] + for child in item.get("content", []): + child_text: list[str] = [] + self._extract_text_from_adf(child, child_text) + item_text.extend(child_text) + if item_text: + result.append(f"{bullet}{' '.join(item_text).strip()}") + + def _extract_acceptance_criteria(self, fields: dict[str, Any]) -> str | None: + """ + Try to extract acceptance criteria from common custom field patterns. + + Args: + fields: Issue fields + + Returns: + Acceptance criteria text or None + """ + # Common field names for acceptance criteria + ac_field_names: list[str] = [ + "customfield_10020", # Common in Jira Cloud + "customfield_10101", # Another common one + "acceptance criteria", + "acceptancecriteria", + ] + + for field_name in ac_field_names: + if value := fields.get(field_name): + if isinstance(value, dict): + return self._parse_adf_content(value) + elif isinstance(value, str): + return value + + return None diff --git a/src/tools/models.py b/src/tools/models.py new file mode 100644 index 0000000..4a6c3e2 --- /dev/null +++ b/src/tools/models.py @@ -0,0 +1,35 @@ +"""Pydantic models for tool return types.""" + +from pydantic import BaseModel, Field + + +class CheckVerificationStatus(BaseModel): + """Result of verifying a check is loaded in Prowler.""" + + success: bool = Field(description="Whether the check was successfully loaded") + message: str = Field(description="Verification message or error details") + + +class MkcheckResult(BaseModel): + """Result of creating a new check structure.""" + + success: bool = Field(description="Whether check creation was successful") + message: str = Field(description="Success or error message") + check_folder: str = Field( + default="", description="Path to the created check folder" + ) + + +class SkillsSetupResult(BaseModel): + """Result of setting up AI skills in the Prowler repository.""" + + success: bool = Field(description="Whether skills setup was successful") + message: str = Field(description="Success or error message") + skills_count: int = Field(default=0, description="Number of skills configured") + + +class TestRunResult(BaseModel): + """Result of running pytest.""" + + success: bool = Field(description="Whether all tests passed") + error_output: str = Field(default="", description="Error output if tests failed") diff --git a/src/tools/prowler.py b/src/tools/prowler.py new file mode 100644 index 0000000..fa643ee --- /dev/null +++ b/src/tools/prowler.py @@ -0,0 +1,235 @@ +"""Prowler-specific tools.""" + +import subprocess # nosec B404 +from pathlib import Path +from typing import Any + +from claude_agent_sdk import tool +from rich import print + +from core.exceptions import ToolError +from tools.models import CheckVerificationStatus, TestRunResult + + +class ProwlerToolError(ToolError): + """Exception for Prowler tool errors.""" + + +# Constants +DEPENDENCY_INSTALL_TIMEOUT: int = 300 # 5 minutes +CHECK_VERIFICATION_TIMEOUT: int = 60 # 1 minute +PYTEST_TIMEOUT: int = 300 # 5 minutes +DEFAULT_WORKING_DIR: Path = Path("./working/prowler") + + +@tool( + name="mkcheck", + description="Create a new check folder and files in the Prowler repository. This creates the directory structure and empty files for a new Prowler security check.", + input_schema={ + "provider": str, + "check_name": str, + "prowler_directory": str, + }, +) +async def mkcheck(args: dict[str, Any]) -> dict[str, Any]: + """ + Create a new check folder and files in the Prowler repository. + + This tool creates the necessary directory structure and empty files + for a new Prowler check. + + Args: + args: Dictionary containing: + - provider: Cloud provider (e.g., 'gcp', 'aws', 'azure') + - check_name: Name of the check (without service prefix) + - prowler_directory: Path to the Prowler repository (optional, defaults to ./working/prowler) + + Returns: + Dictionary with content and optional error status + """ + try: + # Extract parameters with explicit type hints + provider: str = args["provider"] + check_name: str = args["check_name"] + prowler_directory: Path = Path( + args.get("prowler_directory") or DEFAULT_WORKING_DIR + ) + + # Build check folder path + check_folder: Path = ( + prowler_directory + / "prowler" + / "providers" + / provider + / "services" + / check_name.split("_")[0] + / f"{check_name}" + ) + check_folder.mkdir(parents=True, exist_ok=True) + + # Create check files + check_file: Path = check_folder / f"{check_name}.py" + check_file.touch() + + check_metadata: Path = check_folder / f"{check_name}.metadata.json" + check_metadata.touch() + + init_file: Path = check_folder / "__init__.py" + init_file.touch() + + print(f"[green]✓ Created check structure at: {check_folder}[/green]") + + return { + "content": [ + { + "type": "text", + "text": f"✓ Successfully created check structure for {check_name} at {check_folder}", + } + ] + } + + except Exception as e: + return { + "content": [{"type": "text", "text": f"✗ Failed to create check: {e}"}], + "is_error": True, + } + + +def install_prowler_dependencies(prowler_directory: Path) -> None: + """ + Install Prowler dependencies using poetry. + + Args: + prowler_directory: Path to the Prowler repository + + Raises: + ProwlerToolError: If installation fails + """ + try: + print("[yellow]Installing Prowler dependencies...[/yellow]") + install_result: subprocess.CompletedProcess[str] = subprocess.run( # nosec B603 B607 + ["poetry", "install", "--with", "dev"], + cwd=prowler_directory, + capture_output=True, + text=True, + timeout=DEPENDENCY_INSTALL_TIMEOUT, + ) + + if install_result.returncode != 0: + raise ProwlerToolError( + f"Failed to install dependencies: {install_result.stderr}" + ) + + print("[green]✓ Prowler dependencies installed[/green]") + + except subprocess.TimeoutExpired as e: + raise ProwlerToolError( + f"Installation timed out after {DEPENDENCY_INSTALL_TIMEOUT} seconds" + ) from e + except Exception as e: + raise ProwlerToolError(f"Installation error: {e}") from e + + +def verify_check_loaded( + check_name: str, + provider: str, + prowler_directory: Path, +) -> CheckVerificationStatus: + """ + Verify that a check has been loaded correctly in Prowler. + + This function runs prowler --list-checks and checks if the check name appears in the output. + Note: Dependencies must be installed before calling this function. + + Args: + check_name: Name of the check to verify + provider: Cloud provider (e.g., 'aws', 'azure', 'gcp', 'kubernetes') + prowler_directory: Path to the Prowler repository + + Returns: + CheckVerificationStatus with verification results + """ + try: + # Run prowler --list-checks and capture output + output: str = subprocess.check_output( # nosec B603 B607 + ["poetry", "run", "prowler", provider, "--list-checks"], + cwd=prowler_directory, + stderr=subprocess.STDOUT, # Redirect stderr to stdout to capture everything + text=True, + timeout=CHECK_VERIFICATION_TIMEOUT, + ) + + # Check if the check name appears in the output + if check_name in output: + return CheckVerificationStatus( + success=True, + message=f"✓ Check '{check_name}' is successfully loaded in Prowler", + ) + else: + return CheckVerificationStatus( + success=False, + message=f"✗ Check '{check_name}' was NOT found in Prowler's check list. " + "This means there may be an issue with the implementation.", + ) + + except subprocess.CalledProcessError as e: + return CheckVerificationStatus( + success=False, + message=f"Failed to list checks (exit code {e.returncode}): {e.output}", + ) + except subprocess.TimeoutExpired: + return CheckVerificationStatus( + success=False, + message=f"Verification timed out after {CHECK_VERIFICATION_TIMEOUT} seconds", + ) + except Exception as e: + return CheckVerificationStatus( + success=False, message=f"Verification error: {e}" + ) + + +def run_pytest( + test_path: Path, + prowler_directory: Path, + timeout: int = PYTEST_TIMEOUT, +) -> TestRunResult: + """ + Run pytest on a specific test path. + + Args: + test_path: Path to the test file or directory (relative to prowler_directory) + prowler_directory: Path to the Prowler repository + timeout: Timeout in seconds (default: 300) + + Returns: + TestRunResult with test execution results + """ + try: + print(f"[yellow]Running pytest on {test_path}...[/yellow]") + result: subprocess.CompletedProcess[str] = subprocess.run( # nosec B603 B607 + ["poetry", "run", "pytest", "-v", str(test_path)], + cwd=prowler_directory, + capture_output=True, + text=True, + timeout=timeout, + ) + + if result.returncode == 0: + print(f"[green]✓ Tests passed for {test_path}[/green]") + return TestRunResult(success=True, error_output="") + else: + # Combine stdout and stderr for full error context + error_output: str = f"{result.stdout}\n{result.stderr}".strip() + print(f"[red]✗ Tests failed for {test_path}[/red]") + return TestRunResult(success=False, error_output=error_output) + + except subprocess.TimeoutExpired: + return TestRunResult( + success=False, + error_output=f"Tests timed out after {timeout} seconds", + ) + except Exception as e: + return TestRunResult( + success=False, + error_output=f"Error running tests: {e}", + ) diff --git a/src/tools/skills.py b/src/tools/skills.py new file mode 100644 index 0000000..e158b58 --- /dev/null +++ b/src/tools/skills.py @@ -0,0 +1,96 @@ +"""Skills setup tools for configuring AI assistant integration.""" + +import re +import subprocess # nosec B404 +from pathlib import Path + +from rich import print + +from core.exceptions import ToolError +from tools.models import SkillsSetupResult + + +class SkillsSetupError(ToolError): + """Exception for skills setup errors.""" + + +# Constants +SKILLS_SETUP_TIMEOUT: int = 60 +SETUP_SCRIPT_PATH: str = "skills/setup.sh" +CLAUDE_FLAG: str = "--claude" + + +def setup_prowler_skills(prowler_directory: Path) -> SkillsSetupResult: + """ + Set up AI skills for Claude in the Prowler repository. + + This runs the skills/setup.sh --claude script to configure symlinks + that allow Claude to access Prowler's AI assistant skills. + + Non-blocking: returns failure result instead of raising on error. + + Args: + prowler_directory: Path to the Prowler repository + + Returns: + SkillsSetupResult with setup status and skills count + """ + setup_script: Path = prowler_directory / SETUP_SCRIPT_PATH + + # Check if script exists + if not setup_script.exists(): + return SkillsSetupResult( + success=False, + message=f"Setup script not found at {setup_script}", + skills_count=0, + ) + + try: + print("[yellow]Setting up Prowler AI skills...[/yellow]") + + # Run: bash skills/setup.sh --claude + result: subprocess.CompletedProcess[str] = subprocess.run( # nosec B603 B607 + ["bash", SETUP_SCRIPT_PATH, CLAUDE_FLAG], + cwd=prowler_directory, + capture_output=True, + text=True, + timeout=SKILLS_SETUP_TIMEOUT, + ) + + if result.returncode != 0: + return SkillsSetupResult( + success=False, + message=f"Setup script failed: {result.stderr or result.stdout}", + skills_count=0, + ) + + # Parse output for skills count + # Expected format: "✅ Successfully configured X AI skills!" + skills_count: int = 0 + output: str = result.stdout + result.stderr + match = re.search( + r"configured\s+(\d+)\s+(?:AI\s+)?skills?", output, re.IGNORECASE + ) + if match: + skills_count = int(match.group(1)) + + print(f"[green]✓ Prowler AI skills configured ({skills_count} skills)[/green]") + + return SkillsSetupResult( + success=True, + message=f"Successfully configured {skills_count} skills", + skills_count=skills_count, + ) + + except subprocess.TimeoutExpired: + return SkillsSetupResult( + success=False, + message=f"Setup timed out after {SKILLS_SETUP_TIMEOUT} seconds", + skills_count=0, + ) + except Exception as e: + return SkillsSetupResult( + success=False, + message=f"Setup error: {e}", + skills_count=0, + ) diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..1d632b4 --- /dev/null +++ b/src/utils/__init__.py @@ -0,0 +1,5 @@ +"""Utility modules for Prowler Studio.""" + +from utils.prompts import load_prompt + +__all__ = ["load_prompt"] diff --git a/src/utils/logging.py b/src/utils/logging.py new file mode 100644 index 0000000..a1df374 --- /dev/null +++ b/src/utils/logging.py @@ -0,0 +1,162 @@ +"""Simple logging setup - console (with colors) + file output.""" + +from __future__ import annotations + +import json +import logging +import re +from datetime import datetime +from pathlib import Path # noqa: TC003 (used at runtime for path operations) +from typing import Any + +from rich.logging import RichHandler + +# Module-level file handler for agent output logging +_file_handler: logging.FileHandler | None = None + + +def setup_logging(base_dir: Path, ticket: str | None = None) -> Path: + """ + Configure logging to output to both console (colored) and file. + + Args: + base_dir: Base directory for the logs folder + ticket: Optional ticket key for log filename + + Returns: + Path to the log file + """ + global _file_handler + + logs_dir = base_dir / "logs" + logs_dir.mkdir(parents=True, exist_ok=True) + + timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S") + filename = f"{timestamp}_{ticket}.log" if ticket else f"{timestamp}.log" + log_file = logs_dir / filename + + # File handler (plain text) + _file_handler = logging.FileHandler(log_file, encoding="utf-8") + _file_handler.setFormatter( + logging.Formatter("%(asctime)s | %(levelname)-8s | %(message)s") + ) + + # Console handler (colored with Rich) + console_handler = RichHandler(rich_tracebacks=True, markup=True) + + # Set format to just the message - Rich adds level/time, file handler has its own format + logging.basicConfig( + level=logging.INFO, + format="%(message)s", + handlers=[_file_handler, console_handler], + force=True, + ) + + logging.info("=" * 60) + logging.info("WORKFLOW STARTED") + logging.info(f"Log file: {log_file}") + logging.info("=" * 60) + + return log_file + + +def _emit_debug_log(msg: str) -> None: + """Emit a DEBUG-level log record to file only.""" + if _file_handler is None: + return + record = logging.LogRecord( + name="tool", + level=logging.DEBUG, + pathname="", + lineno=0, + msg=msg, + args=(), + exc_info=None, + ) + _file_handler.emit(record) + + +def log_agent_output(text: str) -> None: + """ + Log agent output text to the log file only (not console). + + Args: + text: Agent output text (may contain Rich markup) + """ + if _file_handler is None: + return + + clean_text = _strip_rich_markup(text) + if clean_text.strip(): + for line in clean_text.splitlines(): + if line.strip(): + _emit_debug_log(f"[AGENT] {line}") + + +def _strip_rich_markup(text: str) -> str: + """Strip Rich console markup from text.""" + return re.sub(r"\[/?[^\]]+\]", "", text) + + +def log_tool_call( + tool_name: str | None, + tool_input: dict[str, Any] | None = None, + tool_output: str | list[dict[str, Any]] | None = None, + is_error: bool = False, + tool_use_id: str | None = None, +) -> None: + """ + Log tool call information to the log file only (not console). + + Args: + tool_name: Name of the tool being called + tool_input: Input parameters for the tool (for TOOL CALL) + tool_output: Output from the tool (for TOOL RESULT) + is_error: Whether the tool result was an error + tool_use_id: Unique ID for correlating calls and results + """ + if _file_handler is None: + return + + id_suffix = f" (id={tool_use_id})" if tool_use_id else "" + + if tool_input is not None: + # Log tool call with input + _emit_debug_log(f"[TOOL CALL] {tool_name}{id_suffix}") + + # Log the input as formatted JSON + try: + input_json = json.dumps(tool_input, indent=2) + for line in input_json.splitlines(): + _emit_debug_log(line) + except (TypeError, ValueError): + # Fallback if JSON serialization fails + _emit_debug_log(str(tool_input)) + + elif tool_output is not None: + # Log tool result + status = "ERROR" if is_error else "OK" + _emit_debug_log(f"[TOOL RESULT] {tool_name} [{status}]") + + # Log the output (truncated if too long) + output_str = _format_tool_output(tool_output) + max_lines = 50 + lines = output_str.splitlines() + if len(lines) > max_lines: + lines = [ + *lines[:max_lines], + f"... (truncated, {len(lines) - max_lines} more lines)", + ] + + for line in lines: + _emit_debug_log(line) + + +def _format_tool_output(output: str | list[dict[str, Any]]) -> str: + """Format tool output for logging.""" + if isinstance(output, str): + return output + try: + return json.dumps(output, indent=2) + except (TypeError, ValueError): + return str(output) diff --git a/src/utils/prompts.py b/src/utils/prompts.py new file mode 100644 index 0000000..a46422f --- /dev/null +++ b/src/utils/prompts.py @@ -0,0 +1,44 @@ +"""Prompt loading utilities.""" + +from pathlib import Path +from typing import Any + +from jinja2 import Environment, FileSystemLoader, TemplateNotFound + +from core.exceptions import ConfigurationError + + +def load_prompt(path: Path, context: dict[str, Any] | None = None) -> str: + """ + Load and render a prompt template. + + Args: + path: Path to the prompt template file + context: Optional data passed to the template during rendering + + Returns: + Rendered prompt content + + Raises: + ConfigurationError: If the prompt file is not found or rendering fails + """ + if not path.exists(): + raise ConfigurationError(f"Prompt file not found: {path}") + + try: + # Create environment rooted at the prompt's parent directory + # autoescape=False is safe here as we're generating prompts, not HTML + env: Environment = Environment( + loader=FileSystemLoader(path.parent), + autoescape=False, # nosec B701 + trim_blocks=True, + lstrip_blocks=True, + ) + + template = env.get_template(path.name) + return template.render(**(context or {})) + + except TemplateNotFound as e: + raise ConfigurationError(f"Prompt template not found: {path}") from e + except Exception as e: + raise ConfigurationError(f"Failed to render prompt template {path}: {e}") from e diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/tools/__init__.py b/tests/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/tools/test_github_client.py b/tests/tools/test_github_client.py new file mode 100644 index 0000000..7d345d7 --- /dev/null +++ b/tests/tools/test_github_client.py @@ -0,0 +1,169 @@ +"""Tests for GitHub REST API client.""" + +from typing import Any + +import pytest + +from tools.github_client import GitHubClient, GitHubIssueContent + + +class TestGitHubIssueContentToMarkdown: + """Tests for GitHubIssueContent.to_markdown method.""" + + def test_to_markdown_all_fields(self) -> None: + """Convert issue with all fields to markdown.""" + content = GitHubIssueContent( + number=123, + title="Test Issue", + body="This is the description.", + state="open", + labels=["bug", "high-priority"], + url="https://github.com/owner/repo/issues/123", + ) + + result = content.to_markdown() + + assert "# Issue #123: Test Issue" in result + assert "**State:** open" in result + assert "**URL:** https://github.com/owner/repo/issues/123" in result + assert "**Labels:** bug, high-priority" in result + assert "## Description" in result + assert "This is the description." in result + + def test_to_markdown_empty_labels(self) -> None: + """Convert issue with empty labels list.""" + content = GitHubIssueContent( + number=456, + title="No Labels Issue", + body="Description here.", + state="closed", + labels=[], + url="https://github.com/owner/repo/issues/456", + ) + + result = content.to_markdown() + + assert "# Issue #456: No Labels Issue" in result + assert "**State:** closed" in result + assert "**Labels:**" not in result + assert "Description here." in result + + def test_to_markdown_no_body(self) -> None: + """Convert issue with empty body.""" + content = GitHubIssueContent( + number=789, + title="Empty Body Issue", + body="", + state="open", + labels=["enhancement"], + url="https://github.com/owner/repo/issues/789", + ) + + result = content.to_markdown() + + assert "(No description)" in result + + def test_to_markdown_no_url(self) -> None: + """Convert issue without URL.""" + content = GitHubIssueContent( + number=101, + title="No URL Issue", + body="Some body.", + state="open", + labels=[], + url=None, + ) + + result = content.to_markdown() + + assert "**URL:**" not in result + + +class TestGitHubClientParseResponse: + """Tests for GitHubClient._parse_response method.""" + + def test_parse_response_full_data(self) -> None: + """Parse complete API response.""" + client = GitHubClient(token="test-token") + data = { + "number": 42, + "title": "Full Issue", + "body": "Complete body text.", + "state": "open", + "labels": [{"name": "bug"}, {"name": "critical"}], + "html_url": "https://github.com/owner/repo/issues/42", + } + + result = client._parse_response(data) + + assert result.number == 42 + assert result.title == "Full Issue" + assert result.body == "Complete body text." + assert result.state == "open" + assert result.labels == ["bug", "critical"] + assert result.url == "https://github.com/owner/repo/issues/42" + + def test_parse_response_minimal_data(self) -> None: + """Parse minimal API response with defaults.""" + client = GitHubClient(token="test-token") + data: dict[str, Any] = {} + + result = client._parse_response(data) + + assert result.number == 0 + assert result.title == "" + assert result.body == "" + assert result.state == "" + assert result.labels == [] + assert result.url is None + + def test_parse_response_null_body(self) -> None: + """Parse response with null body.""" + client = GitHubClient(token="test-token") + data = { + "number": 1, + "title": "Null Body", + "body": None, + "state": "open", + "labels": [], + } + + result = client._parse_response(data) + + assert result.body == "" + + def test_parse_response_labels_without_name(self) -> None: + """Parse response with label objects missing name.""" + client = GitHubClient(token="test-token") + data = { + "number": 1, + "title": "Test", + "body": "Body", + "state": "open", + "labels": [{"name": "valid"}, {}, {"name": ""}], + } + + result = client._parse_response(data) + + assert result.labels == ["valid"] + + +class TestGitHubClientInit: + """Tests for GitHubClient initialization.""" + + def test_init_with_explicit_token(self) -> None: + """Initialize with explicit token.""" + client = GitHubClient(token="explicit-token") + assert client.token == "explicit-token" + + def test_init_without_token(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Initialize without token (no env var) - should work for public repos.""" + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + client = GitHubClient() + assert client.token is None + + def test_init_with_env_token(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Initialize with token from environment.""" + monkeypatch.setenv("GITHUB_TOKEN", "env-token") + client = GitHubClient() + assert client.token == "env-token" diff --git a/tests/tools/test_github_issue.py b/tests/tools/test_github_issue.py new file mode 100644 index 0000000..cfd556a --- /dev/null +++ b/tests/tools/test_github_issue.py @@ -0,0 +1,108 @@ +"""Tests for GitHub issue URL parser.""" + +import pytest + +from tools.github_issue import GitHubIssueInfo, parse_github_issue_url + + +class TestParseGitHubIssueUrl: + """Tests for parse_github_issue_url function.""" + + def test_valid_standard_url(self) -> None: + """Parse standard GitHub issue URL.""" + url = "https://github.com/owner/repo/issues/123" + result = parse_github_issue_url(url) + + assert result.owner == "owner" + assert result.repo == "repo" + assert result.issue_number == 123 + + def test_valid_url_with_trailing_slash(self) -> None: + """Parse URL with trailing slash.""" + url = "https://github.com/owner/repo/issues/123/" + result = parse_github_issue_url(url) + + assert result.owner == "owner" + assert result.repo == "repo" + assert result.issue_number == 123 + + def test_valid_url_with_fragment(self) -> None: + """Parse URL with fragment identifier.""" + url = "https://github.com/owner/repo/issues/123#issuecomment-456" + result = parse_github_issue_url(url) + + assert result.owner == "owner" + assert result.repo == "repo" + assert result.issue_number == 123 + + def test_valid_url_with_query_params(self) -> None: + """Parse URL with query parameters.""" + url = "https://github.com/owner/repo/issues/123?foo=bar" + result = parse_github_issue_url(url) + + assert result.owner == "owner" + assert result.repo == "repo" + assert result.issue_number == 123 + + def test_valid_url_with_trailing_slash_and_fragment(self) -> None: + """Parse URL with trailing slash and fragment.""" + url = "https://github.com/owner/repo/issues/123/#comment" + result = parse_github_issue_url(url) + + assert result.owner == "owner" + assert result.repo == "repo" + assert result.issue_number == 123 + + def test_valid_http_url(self) -> None: + """Parse HTTP (non-HTTPS) URL.""" + url = "http://github.com/owner/repo/issues/456" + result = parse_github_issue_url(url) + + assert result.owner == "owner" + assert result.repo == "repo" + assert result.issue_number == 456 + + def test_invalid_url_wrong_domain(self) -> None: + """Reject URL from wrong domain.""" + url = "https://gitlab.com/owner/repo/issues/123" + + with pytest.raises(ValueError, match="Invalid GitHub issue URL format"): + parse_github_issue_url(url) + + def test_invalid_url_missing_issue_number(self) -> None: + """Reject URL without issue number.""" + url = "https://github.com/owner/repo/issues/" + + with pytest.raises(ValueError, match="Invalid GitHub issue URL format"): + parse_github_issue_url(url) + + def test_invalid_url_non_numeric_issue(self) -> None: + """Reject URL with non-numeric issue number.""" + url = "https://github.com/owner/repo/issues/abc" + + with pytest.raises(ValueError, match="Invalid GitHub issue URL format"): + parse_github_issue_url(url) + + def test_invalid_url_missing_repo(self) -> None: + """Reject URL without repo.""" + url = "https://github.com/owner/issues/123" + + with pytest.raises(ValueError, match="Invalid GitHub issue URL format"): + parse_github_issue_url(url) + + def test_invalid_url_pull_request(self) -> None: + """Reject pull request URL (must be issue).""" + url = "https://github.com/owner/repo/pull/123" + + with pytest.raises(ValueError, match="Invalid GitHub issue URL format"): + parse_github_issue_url(url) + + +class TestGitHubIssueInfo: + """Tests for GitHubIssueInfo dataclass.""" + + def test_url_property(self) -> None: + """Verify url property generates correct URL.""" + info = GitHubIssueInfo(owner="prowler-cloud", repo="prowler", issue_number=42) + + assert info.url == "https://github.com/prowler-cloud/prowler/issues/42" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..0aa3feb --- /dev/null +++ b/uv.lock @@ -0,0 +1,949 @@ +version = 1 +revision = 2 +requires-python = ">=3.12" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "bandit" +version = "1.8.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/b5/7eb834e213d6f73aace21938e5e90425c92e5f42abafaf8a6d5d21beed51/bandit-1.8.6.tar.gz", hash = "sha256:dbfe9c25fc6961c2078593de55fd19f2559f9e45b99f1272341f5b95dea4e56b", size = 4240271, upload-time = "2025-07-06T03:10:50.9Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/ca/ba5f909b40ea12ec542d5d7bdd13ee31c4d65f3beed20211ef81c18fa1f3/bandit-1.8.6-py3-none-any.whl", hash = "sha256:3348e934d736fcdb68b6aa4030487097e23a501adf3e7827b63658df464dddd0", size = 133808, upload-time = "2025-07-06T03:10:49.134Z" }, +] + +[[package]] +name = "certifi" +version = "2025.10.5" +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" } +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" }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + +[[package]] +name = "claude-agent-sdk" +version = "0.1.49" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "mcp" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/08/0e33ede7b3035ba953f5a59597b8cb94ea2f3fb3124e5d6f6e255bf9c025/claude_agent_sdk-0.1.49-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a869330d4a15b12c96ae24144b8e29999ff25315267f248634986179e9536a04", size = 57620663, upload-time = "2026-03-17T00:44:27.792Z" }, +] + +[[package]] +name = "click" +version = "8.3.0" +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" } +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" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.45" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/c8/dd58967d119baab745caec2f9d853297cec1989ec1d63f677d3880632b88/gitpython-3.1.45.tar.gz", hash = "sha256:85b0ee964ceddf211c41b9f27a49086010a190fd8132a24e21f362a4b36a791c", size = 215076, upload-time = "2025-07-24T03:45:54.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/61/d4b89fec821f72385526e1b9d9a3a0385dda4a72b206d28049e2c7cd39b8/gitpython-3.1.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77", size = 208168, upload-time = "2025-07-24T03:45:52.517Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "identify" +version = "2.6.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "interrogate" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "click" }, + { name = "colorama" }, + { name = "py" }, + { name = "tabulate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/22/74f7fcc96280eea46cf2bcbfa1354ac31de0e60a4be6f7966f12cef20893/interrogate-1.7.0.tar.gz", hash = "sha256:a320d6ec644dfd887cc58247a345054fc4d9f981100c45184470068f4b3719b0", size = 159636, upload-time = "2024-04-07T22:30:46.217Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl", hash = "sha256:b13ff4dd8403369670e2efe684066de9fcb868ad9d7f2b4095d8112142dc9d12", size = 46982, upload-time = "2024-04-07T22:30:44.277Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mcp" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/e0/fe34ce16ea2bacce489ab859abd1b47ae28b438c3ef60b9c5eee6c02592f/mcp-1.18.0.tar.gz", hash = "sha256:aa278c44b1efc0a297f53b68df865b988e52dd08182d702019edcf33a8e109f6", size = 482926, upload-time = "2025-10-16T19:19:55.125Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/44/f5970e3e899803823826283a70b6003afd46f28e082544407e24575eccd3/mcp-1.18.0-py3-none-any.whl", hash = "sha256:42f10c270de18e7892fdf9da259029120b1ea23964ff688248c69db9d72b1d0a", size = 168762, upload-time = "2025-10-16T19:19:53.2Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mypy" +version = "1.18.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" }, + { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" }, + { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" }, + { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" }, + { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" }, + { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, + { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" }, + { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, +] + +[[package]] +name = "prowler-studio" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "claude-agent-sdk" }, + { name = "gitpython" }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "typer" }, +] + +[package.optional-dependencies] +dev = [ + { name = "bandit" }, + { name = "interrogate" }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "bandit", marker = "extra == 'dev'", specifier = ">=1.8.0" }, + { name = "claude-agent-sdk", specifier = ">=0.1.49" }, + { name = "gitpython", specifier = ">=3.1.0" }, + { name = "httpx", specifier = ">=0.27.0" }, + { name = "interrogate", marker = "extra == 'dev'", specifier = ">=1.7.0" }, + { name = "jinja2", specifier = ">=3.1.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.13.0" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.0.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" }, + { name = "typer", specifier = ">=0.20.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "py" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/ff/fec109ceb715d2a6b4c4a85a61af3b40c723a961e8828319fbcb15b868dc/py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", size = 207796, upload-time = "2021-11-04T17:17:01.377Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/f0/10642828a8dfb741e5f3fbaac830550a518a775c7fff6f04a007259b0548/py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378", size = 98708, upload-time = "2021-11-04T17:17:00.152Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { 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" } +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" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.4" +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/e9/81/d3b3e95929c4369d30b2a66a91db63c8ed0a98381ae55a45da2cd1cc1288/pydantic_core-2.41.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ab06d77e053d660a6faaf04894446df7b0a7e7aba70c2797465a0a1af00fc887", size = 2099043, upload-time = "2025-10-14T10:20:28.561Z" }, + { url = "https://files.pythonhosted.org/packages/58/da/46fdac49e6717e3a94fc9201403e08d9d61aa7a770fab6190b8740749047/pydantic_core-2.41.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c53ff33e603a9c1179a9364b0a24694f183717b2e0da2b5ad43c316c956901b2", size = 1910699, upload-time = "2025-10-14T10:20:30.217Z" }, + { url = "https://files.pythonhosted.org/packages/1e/63/4d948f1b9dd8e991a5a98b77dd66c74641f5f2e5225fee37994b2e07d391/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:304c54176af2c143bd181d82e77c15c41cbacea8872a2225dd37e6544dce9999", size = 1952121, upload-time = "2025-10-14T10:20:32.246Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a7/e5fc60a6f781fc634ecaa9ecc3c20171d238794cef69ae0af79ac11b89d7/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:025ba34a4cf4fb32f917d5d188ab5e702223d3ba603be4d8aca2f82bede432a4", size = 2041590, upload-time = "2025-10-14T10:20:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/70/69/dce747b1d21d59e85af433428978a1893c6f8a7068fa2bb4a927fba7a5ff/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9f5f30c402ed58f90c70e12eff65547d3ab74685ffe8283c719e6bead8ef53f", size = 2219869, upload-time = "2025-10-14T10:20:35.965Z" }, + { url = "https://files.pythonhosted.org/packages/83/6a/c070e30e295403bf29c4df1cb781317b6a9bac7cd07b8d3acc94d501a63c/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd96e5d15385d301733113bcaa324c8bcf111275b7675a9c6e88bfb19fc05e3b", size = 2345169, upload-time = "2025-10-14T10:20:37.627Z" }, + { url = "https://files.pythonhosted.org/packages/f0/83/06d001f8043c336baea7fd202a9ac7ad71f87e1c55d8112c50b745c40324/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98f348cbb44fae6e9653c1055db7e29de67ea6a9ca03a5fa2c2e11a47cff0e47", size = 2070165, upload-time = "2025-10-14T10:20:39.246Z" }, + { url = "https://files.pythonhosted.org/packages/14/0a/e567c2883588dd12bcbc110232d892cf385356f7c8a9910311ac997ab715/pydantic_core-2.41.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec22626a2d14620a83ca583c6f5a4080fa3155282718b6055c2ea48d3ef35970", size = 2189067, upload-time = "2025-10-14T10:20:41.015Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1d/3d9fca34273ba03c9b1c5289f7618bc4bd09c3ad2289b5420481aa051a99/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a95d4590b1f1a43bf33ca6d647b990a88f4a3824a8c4572c708f0b45a5290ed", size = 2132997, upload-time = "2025-10-14T10:20:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/52/70/d702ef7a6cd41a8afc61f3554922b3ed8d19dd54c3bd4bdbfe332e610827/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:f9672ab4d398e1b602feadcffcdd3af44d5f5e6ddc15bc7d15d376d47e8e19f8", size = 2307187, upload-time = "2025-10-14T10:20:44.849Z" }, + { url = "https://files.pythonhosted.org/packages/68/4c/c06be6e27545d08b802127914156f38d10ca287a9e8489342793de8aae3c/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:84d8854db5f55fead3b579f04bda9a36461dab0730c5d570e1526483e7bb8431", size = 2305204, upload-time = "2025-10-14T10:20:46.781Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e5/35ae4919bcd9f18603419e23c5eaf32750224a89d41a8df1a3704b69f77e/pydantic_core-2.41.4-cp312-cp312-win32.whl", hash = "sha256:9be1c01adb2ecc4e464392c36d17f97e9110fbbc906bcbe1c943b5b87a74aabd", size = 1972536, upload-time = "2025-10-14T10:20:48.39Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c2/49c5bb6d2a49eb2ee3647a93e3dae7080c6409a8a7558b075027644e879c/pydantic_core-2.41.4-cp312-cp312-win_amd64.whl", hash = "sha256:d682cf1d22bab22a5be08539dca3d1593488a99998f9f412137bc323179067ff", size = 2031132, upload-time = "2025-10-14T10:20:50.421Z" }, + { url = "https://files.pythonhosted.org/packages/06/23/936343dbcba6eec93f73e95eb346810fc732f71ba27967b287b66f7b7097/pydantic_core-2.41.4-cp312-cp312-win_arm64.whl", hash = "sha256:833eebfd75a26d17470b58768c1834dfc90141b7afc6eb0429c21fc5a21dcfb8", size = 1969483, upload-time = "2025-10-14T10:20:52.35Z" }, + { url = "https://files.pythonhosted.org/packages/13/d0/c20adabd181a029a970738dfe23710b52a31f1258f591874fcdec7359845/pydantic_core-2.41.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:85e050ad9e5f6fe1004eec65c914332e52f429bc0ae12d6fa2092407a462c746", size = 2105688, upload-time = "2025-10-14T10:20:54.448Z" }, + { url = "https://files.pythonhosted.org/packages/00/b6/0ce5c03cec5ae94cca220dfecddc453c077d71363b98a4bbdb3c0b22c783/pydantic_core-2.41.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7393f1d64792763a48924ba31d1e44c2cfbc05e3b1c2c9abb4ceeadd912cced", size = 1910807, upload-time = "2025-10-14T10:20:56.115Z" }, + { url = "https://files.pythonhosted.org/packages/68/3e/800d3d02c8beb0b5c069c870cbb83799d085debf43499c897bb4b4aaff0d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94dab0940b0d1fb28bcab847adf887c66a27a40291eedf0b473be58761c9799a", size = 1956669, upload-time = "2025-10-14T10:20:57.874Z" }, + { url = "https://files.pythonhosted.org/packages/60/a4/24271cc71a17f64589be49ab8bd0751f6a0a03046c690df60989f2f95c2c/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de7c42f897e689ee6f9e93c4bec72b99ae3b32a2ade1c7e4798e690ff5246e02", size = 2051629, upload-time = "2025-10-14T10:21:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/68/de/45af3ca2f175d91b96bfb62e1f2d2f1f9f3b14a734afe0bfeff079f78181/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:664b3199193262277b8b3cd1e754fb07f2c6023289c815a1e1e8fb415cb247b1", size = 2224049, upload-time = "2025-10-14T10:21:01.801Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/ae4e1ff84672bf869d0a77af24fd78387850e9497753c432875066b5d622/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d95b253b88f7d308b1c0b417c4624f44553ba4762816f94e6986819b9c273fb2", size = 2342409, upload-time = "2025-10-14T10:21:03.556Z" }, + { url = "https://files.pythonhosted.org/packages/18/62/273dd70b0026a085c7b74b000394e1ef95719ea579c76ea2f0cc8893736d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1351f5bbdbbabc689727cb91649a00cb9ee7203e0a6e54e9f5ba9e22e384b84", size = 2069635, upload-time = "2025-10-14T10:21:05.385Z" }, + { url = "https://files.pythonhosted.org/packages/30/03/cf485fff699b4cdaea469bc481719d3e49f023241b4abb656f8d422189fc/pydantic_core-2.41.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1affa4798520b148d7182da0615d648e752de4ab1a9566b7471bc803d88a062d", size = 2194284, upload-time = "2025-10-14T10:21:07.122Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7e/c8e713db32405dfd97211f2fc0a15d6bf8adb7640f3d18544c1f39526619/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7b74e18052fea4aa8dea2fb7dbc23d15439695da6cbe6cfc1b694af1115df09d", size = 2137566, upload-time = "2025-10-14T10:21:08.981Z" }, + { url = "https://files.pythonhosted.org/packages/04/f7/db71fd4cdccc8b75990f79ccafbbd66757e19f6d5ee724a6252414483fb4/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:285b643d75c0e30abda9dc1077395624f314a37e3c09ca402d4015ef5979f1a2", size = 2316809, upload-time = "2025-10-14T10:21:10.805Z" }, + { url = "https://files.pythonhosted.org/packages/76/63/a54973ddb945f1bca56742b48b144d85c9fc22f819ddeb9f861c249d5464/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f52679ff4218d713b3b33f88c89ccbf3a5c2c12ba665fb80ccc4192b4608dbab", size = 2311119, upload-time = "2025-10-14T10:21:12.583Z" }, + { url = "https://files.pythonhosted.org/packages/f8/03/5d12891e93c19218af74843a27e32b94922195ded2386f7b55382f904d2f/pydantic_core-2.41.4-cp313-cp313-win32.whl", hash = "sha256:ecde6dedd6fff127c273c76821bb754d793be1024bc33314a120f83a3c69460c", size = 1981398, upload-time = "2025-10-14T10:21:14.584Z" }, + { url = "https://files.pythonhosted.org/packages/be/d8/fd0de71f39db91135b7a26996160de71c073d8635edfce8b3c3681be0d6d/pydantic_core-2.41.4-cp313-cp313-win_amd64.whl", hash = "sha256:d081a1f3800f05409ed868ebb2d74ac39dd0c1ff6c035b5162356d76030736d4", size = 2030735, upload-time = "2025-10-14T10:21:16.432Z" }, + { url = "https://files.pythonhosted.org/packages/72/86/c99921c1cf6650023c08bfab6fe2d7057a5142628ef7ccfa9921f2dda1d5/pydantic_core-2.41.4-cp313-cp313-win_arm64.whl", hash = "sha256:f8e49c9c364a7edcbe2a310f12733aad95b022495ef2a8d653f645e5d20c1564", size = 1973209, upload-time = "2025-10-14T10:21:18.213Z" }, + { url = "https://files.pythonhosted.org/packages/36/0d/b5706cacb70a8414396efdda3d72ae0542e050b591119e458e2490baf035/pydantic_core-2.41.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ed97fd56a561f5eb5706cebe94f1ad7c13b84d98312a05546f2ad036bafe87f4", size = 1877324, upload-time = "2025-10-14T10:21:20.363Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/cba1fa02cfdea72dfb3a9babb067c83b9dff0bbcb198368e000a6b756ea7/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a870c307bf1ee91fc58a9a61338ff780d01bfae45922624816878dce784095d2", size = 1884515, upload-time = "2025-10-14T10:21:22.339Z" }, + { url = "https://files.pythonhosted.org/packages/07/ea/3df927c4384ed9b503c9cc2d076cf983b4f2adb0c754578dfb1245c51e46/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d25e97bc1f5f8f7985bdc2335ef9e73843bb561eb1fa6831fdfc295c1c2061cf", size = 2042819, upload-time = "2025-10-14T10:21:26.683Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ee/df8e871f07074250270a3b1b82aad4cd0026b588acd5d7d3eb2fcb1471a3/pydantic_core-2.41.4-cp313-cp313t-win_amd64.whl", hash = "sha256:d405d14bea042f166512add3091c1af40437c2e7f86988f3915fabd27b1e9cd2", size = 1995866, upload-time = "2025-10-14T10:21:28.951Z" }, + { url = "https://files.pythonhosted.org/packages/fc/de/b20f4ab954d6d399499c33ec4fafc46d9551e11dc1858fb7f5dca0748ceb/pydantic_core-2.41.4-cp313-cp313t-win_arm64.whl", hash = "sha256:19f3684868309db5263a11bace3c45d93f6f24afa2ffe75a647583df22a2ff89", size = 1970034, upload-time = "2025-10-14T10:21:30.869Z" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/c4/48/ae937e5a831b7c0dc646b2ef788c27cd003894882415300ed21927c21efa/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:4f5d640aeebb438517150fdeec097739614421900e4a08db4a3ef38898798537", size = 2112087, upload-time = "2025-10-14T10:22:56.818Z" }, + { url = "https://files.pythonhosted.org/packages/5e/db/6db8073e3d32dae017da7e0d16a9ecb897d0a4d92e00634916e486097961/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a9ab037b71927babc6d9e7fc01aea9e66dc2a4a34dff06ef0724a4049629f94", size = 1920387, upload-time = "2025-10-14T10:22:59.342Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c1/dd3542d072fcc336030d66834872f0328727e3b8de289c662faa04aa270e/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4dab9484ec605c3016df9ad4fd4f9a390bc5d816a3b10c6550f8424bb80b18c", size = 1951495, upload-time = "2025-10-14T10:23:02.089Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c6/db8d13a1f8ab3f1eb08c88bd00fd62d44311e3456d1e85c0e59e0a0376e7/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8a5028425820731d8c6c098ab642d7b8b999758e24acae03ed38a66eca8335", size = 2139008, upload-time = "2025-10-14T10:23:04.539Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/c5/dbbc27b814c71676593d1c3f718e6cd7d4f00652cefa24b75f7aa3efb25e/pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180", size = 188394, upload-time = "2025-09-24T14:19:11.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/d6/887a1ff844e64aa823fb4905978d882a633cfe295c32eacad582b78a7d8b/pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c", size = 48608, upload-time = "2025-09-24T14:19:10.015Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + +[[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/b8/5c/6c3936495003875fe7b14f90ea812841a08fca50ab26bd840e924097d9c8/rpds_py-0.28.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6b4f28583a4f247ff60cd7bdda83db8c3f5b05a7a82ff20dd4b078571747708f", size = 366439, upload-time = "2025-10-22T22:22:04.525Z" }, + { url = "https://files.pythonhosted.org/packages/56/f9/a0f1ca194c50aa29895b442771f036a25b6c41a35e4f35b1a0ea713bedae/rpds_py-0.28.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d678e91b610c29c4b3d52a2c148b641df2b4676ffe47c59f6388d58b99cdc424", size = 348170, upload-time = "2025-10-22T22:22:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/18/ea/42d243d3a586beb72c77fa5def0487daf827210069a95f36328e869599ea/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e819e0e37a44a78e1383bf1970076e2ccc4dc8c2bbaa2f9bd1dc987e9afff628", size = 378838, upload-time = "2025-10-22T22:22:07.932Z" }, + { url = "https://files.pythonhosted.org/packages/e7/78/3de32e18a94791af8f33601402d9d4f39613136398658412a4e0b3047327/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5ee514e0f0523db5d3fb171f397c54875dbbd69760a414dccf9d4d7ad628b5bd", size = 393299, upload-time = "2025-10-22T22:22:09.435Z" }, + { url = "https://files.pythonhosted.org/packages/13/7e/4bdb435afb18acea2eb8a25ad56b956f28de7c59f8a1d32827effa0d4514/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3fa06d27fdcee47f07a39e02862da0100cb4982508f5ead53ec533cd5fe55e", size = 518000, upload-time = "2025-10-22T22:22:11.326Z" }, + { url = "https://files.pythonhosted.org/packages/31/d0/5f52a656875cdc60498ab035a7a0ac8f399890cc1ee73ebd567bac4e39ae/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46959ef2e64f9e4a41fc89aa20dbca2b85531f9a72c21099a3360f35d10b0d5a", size = 408746, upload-time = "2025-10-22T22:22:13.143Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cd/49ce51767b879cde77e7ad9fae164ea15dce3616fe591d9ea1df51152706/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8455933b4bcd6e83fde3fefc987a023389c4b13f9a58c8d23e4b3f6d13f78c84", size = 386379, upload-time = "2025-10-22T22:22:14.602Z" }, + { url = "https://files.pythonhosted.org/packages/6a/99/e4e1e1ee93a98f72fc450e36c0e4d99c35370220e815288e3ecd2ec36a2a/rpds_py-0.28.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ad50614a02c8c2962feebe6012b52f9802deec4263946cddea37aaf28dd25a66", size = 401280, upload-time = "2025-10-22T22:22:16.063Z" }, + { url = "https://files.pythonhosted.org/packages/61/35/e0c6a57488392a8b319d2200d03dad2b29c0db9996f5662c3b02d0b86c02/rpds_py-0.28.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e5deca01b271492553fdb6c7fd974659dce736a15bae5dad7ab8b93555bceb28", size = 412365, upload-time = "2025-10-22T22:22:17.504Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6a/841337980ea253ec797eb084665436007a1aad0faac1ba097fb906c5f69c/rpds_py-0.28.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:735f8495a13159ce6a0d533f01e8674cec0c57038c920495f87dcb20b3ddb48a", size = 559573, upload-time = "2025-10-22T22:22:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/e7/5e/64826ec58afd4c489731f8b00729c5f6afdb86f1df1df60bfede55d650bb/rpds_py-0.28.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:961ca621ff10d198bbe6ba4957decca61aa2a0c56695384c1d6b79bf61436df5", size = 583973, upload-time = "2025-10-22T22:22:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ee/44d024b4843f8386a4eeaa4c171b3d31d55f7177c415545fd1a24c249b5d/rpds_py-0.28.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2374e16cc9131022e7d9a8f8d65d261d9ba55048c78f3b6e017971a4f5e6353c", size = 553800, upload-time = "2025-10-22T22:22:22.25Z" }, + { url = "https://files.pythonhosted.org/packages/7d/89/33e675dccff11a06d4d85dbb4d1865f878d5020cbb69b2c1e7b2d3f82562/rpds_py-0.28.0-cp312-cp312-win32.whl", hash = "sha256:d15431e334fba488b081d47f30f091e5d03c18527c325386091f31718952fe08", size = 216954, upload-time = "2025-10-22T22:22:24.105Z" }, + { url = "https://files.pythonhosted.org/packages/af/36/45f6ebb3210887e8ee6dbf1bc710ae8400bb417ce165aaf3024b8360d999/rpds_py-0.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:a410542d61fc54710f750d3764380b53bf09e8c4edbf2f9141a82aa774a04f7c", size = 227844, upload-time = "2025-10-22T22:22:25.551Z" }, + { url = "https://files.pythonhosted.org/packages/57/91/f3fb250d7e73de71080f9a221d19bd6a1c1eb0d12a1ea26513f6c1052ad6/rpds_py-0.28.0-cp312-cp312-win_arm64.whl", hash = "sha256:1f0cfd1c69e2d14f8c892b893997fa9a60d890a0c8a603e88dca4955f26d1edd", size = 217624, upload-time = "2025-10-22T22:22:26.914Z" }, + { url = "https://files.pythonhosted.org/packages/d3/03/ce566d92611dfac0085c2f4b048cd53ed7c274a5c05974b882a908d540a2/rpds_py-0.28.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e9e184408a0297086f880556b6168fa927d677716f83d3472ea333b42171ee3b", size = 366235, upload-time = "2025-10-22T22:22:28.397Z" }, + { url = "https://files.pythonhosted.org/packages/00/34/1c61da1b25592b86fd285bd7bd8422f4c9d748a7373b46126f9ae792a004/rpds_py-0.28.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:edd267266a9b0448f33dc465a97cfc5d467594b600fe28e7fa2f36450e03053a", size = 348241, upload-time = "2025-10-22T22:22:30.171Z" }, + { url = "https://files.pythonhosted.org/packages/fc/00/ed1e28616848c61c493a067779633ebf4b569eccaacf9ccbdc0e7cba2b9d/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85beb8b3f45e4e32f6802fb6cd6b17f615ef6c6a52f265371fb916fae02814aa", size = 378079, upload-time = "2025-10-22T22:22:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/11/b2/ccb30333a16a470091b6e50289adb4d3ec656fd9951ba8c5e3aaa0746a67/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d2412be8d00a1b895f8ad827cc2116455196e20ed994bb704bf138fe91a42724", size = 393151, upload-time = "2025-10-22T22:22:33.453Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d0/73e2217c3ee486d555cb84920597480627d8c0240ff3062005c6cc47773e/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cf128350d384b777da0e68796afdcebc2e9f63f0e9f242217754e647f6d32491", size = 517520, upload-time = "2025-10-22T22:22:34.949Z" }, + { url = "https://files.pythonhosted.org/packages/c4/91/23efe81c700427d0841a4ae7ea23e305654381831e6029499fe80be8a071/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a2036d09b363aa36695d1cc1a97b36865597f4478470b0697b5ee9403f4fe399", size = 408699, upload-time = "2025-10-22T22:22:36.584Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ee/a324d3198da151820a326c1f988caaa4f37fc27955148a76fff7a2d787a9/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8e1e9be4fa6305a16be628959188e4fd5cd6f1b0e724d63c6d8b2a8adf74ea6", size = 385720, upload-time = "2025-10-22T22:22:38.014Z" }, + { url = "https://files.pythonhosted.org/packages/19/ad/e68120dc05af8b7cab4a789fccd8cdcf0fe7e6581461038cc5c164cd97d2/rpds_py-0.28.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0a403460c9dd91a7f23fc3188de6d8977f1d9603a351d5db6cf20aaea95b538d", size = 401096, upload-time = "2025-10-22T22:22:39.869Z" }, + { url = "https://files.pythonhosted.org/packages/99/90/c1e070620042459d60df6356b666bb1f62198a89d68881816a7ed121595a/rpds_py-0.28.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d7366b6553cdc805abcc512b849a519167db8f5e5c3472010cd1228b224265cb", size = 411465, upload-time = "2025-10-22T22:22:41.395Z" }, + { url = "https://files.pythonhosted.org/packages/68/61/7c195b30d57f1b8d5970f600efee72a4fad79ec829057972e13a0370fd24/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b43c6a3726efd50f18d8120ec0551241c38785b68952d240c45ea553912ac41", size = 558832, upload-time = "2025-10-22T22:22:42.871Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3d/06f3a718864773f69941d4deccdf18e5e47dd298b4628062f004c10f3b34/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0cb7203c7bc69d7c1585ebb33a2e6074492d2fc21ad28a7b9d40457ac2a51ab7", size = 583230, upload-time = "2025-10-22T22:22:44.877Z" }, + { url = "https://files.pythonhosted.org/packages/66/df/62fc783781a121e77fee9a21ead0a926f1b652280a33f5956a5e7833ed30/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a52a5169c664dfb495882adc75c304ae1d50df552fbd68e100fdc719dee4ff9", size = 553268, upload-time = "2025-10-22T22:22:46.441Z" }, + { url = "https://files.pythonhosted.org/packages/84/85/d34366e335140a4837902d3dea89b51f087bd6a63c993ebdff59e93ee61d/rpds_py-0.28.0-cp313-cp313-win32.whl", hash = "sha256:2e42456917b6687215b3e606ab46aa6bca040c77af7df9a08a6dcfe8a4d10ca5", size = 217100, upload-time = "2025-10-22T22:22:48.342Z" }, + { url = "https://files.pythonhosted.org/packages/3c/1c/f25a3f3752ad7601476e3eff395fe075e0f7813fbb9862bd67c82440e880/rpds_py-0.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:e0a0311caedc8069d68fc2bf4c9019b58a2d5ce3cd7cb656c845f1615b577e1e", size = 227759, upload-time = "2025-10-22T22:22:50.219Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d6/5f39b42b99615b5bc2f36ab90423ea404830bdfee1c706820943e9a645eb/rpds_py-0.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:04c1b207ab8b581108801528d59ad80aa83bb170b35b0ddffb29c20e411acdc1", size = 217326, upload-time = "2025-10-22T22:22:51.647Z" }, + { url = "https://files.pythonhosted.org/packages/5c/8b/0c69b72d1cee20a63db534be0df271effe715ef6c744fdf1ff23bb2b0b1c/rpds_py-0.28.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:f296ea3054e11fc58ad42e850e8b75c62d9a93a9f981ad04b2e5ae7d2186ff9c", size = 355736, upload-time = "2025-10-22T22:22:53.211Z" }, + { url = "https://files.pythonhosted.org/packages/f7/6d/0c2ee773cfb55c31a8514d2cece856dd299170a49babd50dcffb15ddc749/rpds_py-0.28.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5a7306c19b19005ad98468fcefeb7100b19c79fc23a5f24a12e06d91181193fa", size = 342677, upload-time = "2025-10-22T22:22:54.723Z" }, + { url = "https://files.pythonhosted.org/packages/e2/1c/22513ab25a27ea205144414724743e305e8153e6abe81833b5e678650f5a/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5d9b86aa501fed9862a443c5c3116f6ead8bc9296185f369277c42542bd646b", size = 371847, upload-time = "2025-10-22T22:22:56.295Z" }, + { url = "https://files.pythonhosted.org/packages/60/07/68e6ccdb4b05115ffe61d31afc94adef1833d3a72f76c9632d4d90d67954/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e5bbc701eff140ba0e872691d573b3d5d30059ea26e5785acba9132d10c8c31d", size = 381800, upload-time = "2025-10-22T22:22:57.808Z" }, + { url = "https://files.pythonhosted.org/packages/73/bf/6d6d15df80781d7f9f368e7c1a00caf764436518c4877fb28b029c4624af/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a5690671cd672a45aa8616d7374fdf334a1b9c04a0cac3c854b1136e92374fe", size = 518827, upload-time = "2025-10-22T22:22:59.826Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d3/2decbb2976cc452cbf12a2b0aaac5f1b9dc5dd9d1f7e2509a3ee00421249/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9f1d92ecea4fa12f978a367c32a5375a1982834649cdb96539dcdc12e609ab1a", size = 399471, upload-time = "2025-10-22T22:23:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2c/f30892f9e54bd02e5faca3f6a26d6933c51055e67d54818af90abed9748e/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d252db6b1a78d0a3928b6190156042d54c93660ce4d98290d7b16b5296fb7cc", size = 377578, upload-time = "2025-10-22T22:23:03.52Z" }, + { url = "https://files.pythonhosted.org/packages/f0/5d/3bce97e5534157318f29ac06bf2d279dae2674ec12f7cb9c12739cee64d8/rpds_py-0.28.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d61b355c3275acb825f8777d6c4505f42b5007e357af500939d4a35b19177259", size = 390482, upload-time = "2025-10-22T22:23:05.391Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f0/886bd515ed457b5bd93b166175edb80a0b21a210c10e993392127f1e3931/rpds_py-0.28.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:acbe5e8b1026c0c580d0321c8aae4b0a1e1676861d48d6e8c6586625055b606a", size = 402447, upload-time = "2025-10-22T22:23:06.93Z" }, + { url = "https://files.pythonhosted.org/packages/42/b5/71e8777ac55e6af1f4f1c05b47542a1eaa6c33c1cf0d300dca6a1c6e159a/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8aa23b6f0fc59b85b4c7d89ba2965af274346f738e8d9fc2455763602e62fd5f", size = 552385, upload-time = "2025-10-22T22:23:08.557Z" }, + { url = "https://files.pythonhosted.org/packages/5d/cb/6ca2d70cbda5a8e36605e7788c4aa3bea7c17d71d213465a5a675079b98d/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7b14b0c680286958817c22d76fcbca4800ddacef6f678f3a7c79a1fe7067fe37", size = 575642, upload-time = "2025-10-22T22:23:10.348Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d4/407ad9960ca7856d7b25c96dcbe019270b5ffdd83a561787bc682c797086/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:bcf1d210dfee61a6c86551d67ee1031899c0fdbae88b2d44a569995d43797712", size = 544507, upload-time = "2025-10-22T22:23:12.434Z" }, + { url = "https://files.pythonhosted.org/packages/51/31/2f46fe0efcac23fbf5797c6b6b7e1c76f7d60773e525cb65fcbc582ee0f2/rpds_py-0.28.0-cp313-cp313t-win32.whl", hash = "sha256:3aa4dc0fdab4a7029ac63959a3ccf4ed605fee048ba67ce89ca3168da34a1342", size = 205376, upload-time = "2025-10-22T22:23:13.979Z" }, + { url = "https://files.pythonhosted.org/packages/92/e4/15947bda33cbedfc134490a41841ab8870a72a867a03d4969d886f6594a2/rpds_py-0.28.0-cp313-cp313t-win_amd64.whl", hash = "sha256:7b7d9d83c942855e4fdcfa75d4f96f6b9e272d42fffcb72cd4bb2577db2e2907", size = 215907, upload-time = "2025-10-22T22:23:15.5Z" }, + { 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" }, +] + +[[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" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "smmap" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/6f/22ed6e33f8a9e76ca0a412405f31abb844b779d52c5f96660766edcd737c/sse_starlette-3.0.2.tar.gz", hash = "sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a", size = 20985, upload-time = "2025-07-27T09:07:44.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/10/c78f463b4ef22eef8491f218f692be838282cd65480f6e423d7730dfd1fb/sse_starlette-3.0.2-py3-none-any.whl", hash = "sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a", size = 11297, upload-time = "2025-07-27T09:07:43.268Z" }, +] + +[[package]] +name = "starlette" +version = "0.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/a5/d6f429d43394057b67a6b5bbe6eae2f77a6bf7459d961fdb224bf206eee6/starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46", size = 2652949, upload-time = "2025-09-13T08:41:05.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/72/2db2f49247d0a18b4f1bb9a5a39a0162869acf235f3a96418363947b3d46/starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659", size = 73736, upload-time = "2025-09-13T08:41:03.869Z" }, +] + +[[package]] +name = "stevedore" +version = "5.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/5f/8418daad5c353300b7661dd8ce2574b0410a6316a8be650a189d5c68d938/stevedore-5.5.0.tar.gz", hash = "sha256:d31496a4f4df9825e1a1e4f1f74d19abb0154aff311c3b376fcc89dae8fccd73", size = 513878, upload-time = "2025-08-25T12:54:26.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/c5/0c06759b95747882bb50abda18f5fb48c3e9b0fbfc6ebc0e23550b52415d/stevedore-5.5.0-py3-none-any.whl", hash = "sha256:18363d4d268181e8e8452e71a38cd77630f345b2ef6b4a8d5614dac5ee0d18cf", size = 49518, upload-time = "2025-08-25T12:54:25.445Z" }, +] + +[[package]] +name = "tabulate" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/fe/802052aecb21e3797b8f7902564ab6ea0d60ff8ca23952079064155d1ae1/tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", size = 81090, upload-time = "2022-10-06T17:21:48.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" }, +] + +[[package]] +name = "typer" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.35.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, +]