From 3c371d2f01bb99ac38b4f9024a549092ffc9ba4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20C=2E=20Andersen?= Date: Sat, 18 Oct 2025 13:43:47 +0200 Subject: [PATCH 01/14] Adds .idea/ to .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 4379972e..d613531e 100644 --- a/.gitignore +++ b/.gitignore @@ -183,3 +183,5 @@ devenv.local.nix # pre-commit .pre-commit-config.yaml *.sql + +.idea/ From c11debf92b0cdfbac31950c75085e3a92904ef39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20C=2E=20Andersen?= Date: Sat, 18 Oct 2025 13:44:20 +0200 Subject: [PATCH 02/14] Remove bloat. --- src/postgres_mcp/__init__.py | 2 - src/postgres_mcp/artifacts.py | 323 ---- src/postgres_mcp/database_health/__init__.py | 4 - .../database_health/buffer_health_calc.py | 62 - .../database_health/connection_health_calc.py | 76 - .../database_health/constraint_health_calc.py | 101 -- .../database_health/database_health.py | 98 -- .../database_health/index_health_calc.py | 345 ---- src/postgres_mcp/database_health/init.sql | 1 - .../database_health/replication_calc.py | 165 -- .../database_health/sequence_health_calc.py | 151 -- .../database_health/vacuum_health_calc.py | 102 -- src/postgres_mcp/explain/README.md | 26 - src/postgres_mcp/explain/__init__.py | 7 - src/postgres_mcp/explain/explain_plan.py | 241 --- src/postgres_mcp/index/dta_calc.py | 837 ---------- src/postgres_mcp/index/index_opt_base.py | 671 -------- src/postgres_mcp/index/llm_opt.py | 384 ----- src/postgres_mcp/index/presentation.py | 266 ---- src/postgres_mcp/server.py | 215 +-- src/postgres_mcp/top_queries/__init__.py | 4 - .../top_queries/top_queries_calc.py | 211 --- .../dta/test_dta_calc_integration.py | 1417 ----------------- .../test_top_queries_integration.py | 181 --- .../test_database_health_tool.py | 155 -- tests/unit/explain/test_explain_plan.py | 477 ------ .../unit/explain/test_explain_plan_real_db.py | 265 --- tests/unit/explain/test_server.py | 131 -- tests/unit/explain/test_server_integration.py | 160 -- tests/unit/index/test_dta_calc.py | 1289 --------------- .../unit/top_queries/test_top_queries_calc.py | 214 --- 31 files changed, 1 insertion(+), 8580 deletions(-) delete mode 100644 src/postgres_mcp/artifacts.py delete mode 100644 src/postgres_mcp/database_health/__init__.py delete mode 100644 src/postgres_mcp/database_health/buffer_health_calc.py delete mode 100644 src/postgres_mcp/database_health/connection_health_calc.py delete mode 100644 src/postgres_mcp/database_health/constraint_health_calc.py delete mode 100644 src/postgres_mcp/database_health/database_health.py delete mode 100644 src/postgres_mcp/database_health/index_health_calc.py delete mode 100644 src/postgres_mcp/database_health/init.sql delete mode 100644 src/postgres_mcp/database_health/replication_calc.py delete mode 100644 src/postgres_mcp/database_health/sequence_health_calc.py delete mode 100644 src/postgres_mcp/database_health/vacuum_health_calc.py delete mode 100644 src/postgres_mcp/explain/README.md delete mode 100644 src/postgres_mcp/explain/__init__.py delete mode 100644 src/postgres_mcp/explain/explain_plan.py delete mode 100644 src/postgres_mcp/index/dta_calc.py delete mode 100644 src/postgres_mcp/index/index_opt_base.py delete mode 100644 src/postgres_mcp/index/llm_opt.py delete mode 100644 src/postgres_mcp/index/presentation.py delete mode 100644 src/postgres_mcp/top_queries/__init__.py delete mode 100644 src/postgres_mcp/top_queries/top_queries_calc.py delete mode 100644 tests/integration/dta/test_dta_calc_integration.py delete mode 100644 tests/integration/test_top_queries_integration.py delete mode 100644 tests/unit/database_health/test_database_health_tool.py delete mode 100644 tests/unit/explain/test_explain_plan.py delete mode 100644 tests/unit/explain/test_explain_plan_real_db.py delete mode 100644 tests/unit/explain/test_server.py delete mode 100644 tests/unit/explain/test_server_integration.py delete mode 100644 tests/unit/index/test_dta_calc.py delete mode 100644 tests/unit/top_queries/test_top_queries_calc.py diff --git a/src/postgres_mcp/__init__.py b/src/postgres_mcp/__init__.py index a00e3497..fb3254f0 100644 --- a/src/postgres_mcp/__init__.py +++ b/src/postgres_mcp/__init__.py @@ -2,7 +2,6 @@ import sys from . import server -from . import top_queries def main(): @@ -20,5 +19,4 @@ def main(): __all__ = [ "main", "server", - "top_queries", ] diff --git a/src/postgres_mcp/artifacts.py b/src/postgres_mcp/artifacts.py deleted file mode 100644 index 65cc9f79..00000000 --- a/src/postgres_mcp/artifacts.py +++ /dev/null @@ -1,323 +0,0 @@ -"""Artifacts for the Database Tuning Advisor.""" - -import difflib -import json -from typing import Any - -from attrs import define -from attrs import field - -# If the recommendation cost is 0.0, we can't calculate the improvement multiple. -# Return 1000000.0 to indicate infinite improvement. -INFINITE_IMPROVEMENT_MULTIPLIER = 1000000.0 - - -class ErrorResult: - """Simple error result class.""" - - def to_text(self) -> str: - return self.value - - def __init__(self, message: str): - self.value = message - - -def calculate_improvement_multiple(base_cost: float, rec_cost: float) -> float: - """Calculate the improvement multiple from this recommendation.""" - if base_cost <= 0.0: - # base_cost or rec_cost might be zero, but as they are floats, the might be - # represented as -0.0. That's why we compare to <= 0.0. - return 1.0 - if rec_cost <= 0.0: - # If the recommendation cost is 0.0, we can't calculate the improvement multiple. - # Return INFINITE_IMPROVEMENT_MULTIPLIER to indicate infinite improvement. - return INFINITE_IMPROVEMENT_MULTIPLIER - return base_cost / rec_cost - - -@define -class PlanNode: - node_type: str - total_cost: float - startup_cost: float - plan_rows: int - plan_width: int - - # Actual metrics from ANALYZE - actual_total_time: float | None = field(default=None) - actual_startup_time: float | None = field(default=None) - actual_rows: int | None = field(default=None) - actual_loops: int | None = field(default=None) - - # Buffer info - shared_hit_blocks: int | None = field(default=None) - shared_read_blocks: int | None = field(default=None) - shared_written_blocks: int | None = field(default=None) - - # Other common fields - relation_name: str | None = field(default=None) - filter: str | None = field(default=None) - children: list["PlanNode"] = field(factory=list) - - @classmethod - def from_json_data(cls, json_node: dict[str, Any]) -> "PlanNode": - # Extract basic fields - node = cls( - node_type=json_node["Node Type"], - total_cost=json_node["Total Cost"], - startup_cost=json_node["Startup Cost"], - plan_rows=json_node["Plan Rows"], - plan_width=json_node["Plan Width"], - ) - - # Optional ANALYZE fields - if "Actual Total Time" in json_node: - node.actual_total_time = json_node["Actual Total Time"] - node.actual_startup_time = json_node["Actual Startup Time"] - node.actual_rows = json_node["Actual Rows"] - node.actual_loops = json_node["Actual Loops"] - - # Optional BUFFERS fields - if "Shared Hit Blocks" in json_node: - node.shared_hit_blocks = json_node["Shared Hit Blocks"] - node.shared_read_blocks = json_node["Shared Read Blocks"] - node.shared_written_blocks = json_node["Shared Written Blocks"] - - # Common optional fields - if "Relation Name" in json_node: - node.relation_name = json_node["Relation Name"] - if "Filter" in json_node: - node.filter = json_node["Filter"] - - # Recursively process child plans - if "Plans" in json_node: - node.children = [cls.from_json_data(child) for child in json_node["Plans"]] - - return node - - -@define -class ExplainPlanArtifact: - value: str - plan_tree: PlanNode - planning_time: float | None = field(default=None) - execution_time: float | None = field(default=None) - - def __init__( - self, - value: str, - plan_tree: PlanNode, - planning_time: float | None = None, - execution_time: float | None = None, - ): - self.value = value - self.plan_tree = plan_tree - self.planning_time = planning_time - self.execution_time = execution_time - - def to_text(self) -> str: - """Convert the explain plan to a text representation. - - Returns: - str: A string representation of the execution plan with timing information. - """ - result = [] - - # Add timing information if available - if self.planning_time is not None: - result.append(f"Planning Time: {self.planning_time:.3f} ms") - if self.execution_time is not None: - result.append(f"Execution Time: {self.execution_time:.3f} ms") - - # Add plan tree representation - result.append(self._format_plan_node(self.plan_tree)) - - return "\n".join(result) - - @staticmethod - def _format_plan_node(node: PlanNode, level: int = 0) -> str: - """Recursively format a plan node and its children. - - Args: - node: The plan node to format - level: The current indentation level - - Returns: - str: A formatted string representation of the node and its children - """ - indent = " " * level - output = f"{indent}→ {node.node_type} (Cost: {node.startup_cost:.2f}..{node.total_cost:.2f})" - - # Add table name if present - if node.relation_name: - output += f" on {node.relation_name}" - - # Add rows information - output += f" [Rows: {node.plan_rows}]" - - # Add actual metrics if available in a compact form - if node.actual_total_time is not None: - output += ( - f" [Actual: {node.actual_startup_time:.2f}..{node.actual_total_time:.2f} ms, Rows: {node.actual_rows}, Loops: {node.actual_loops}]" - ) - - # Add filter if present - if node.filter: - filter_text = node.filter - # Truncate long filters for readability - if len(filter_text) > 100: - filter_text = filter_text[:97] + "..." - output += f"\n{indent} Filter: {filter_text}" - - # Add buffer information if available in a compact form - if node.shared_hit_blocks is not None: - output += f"\n{indent} Buffers - hit: {node.shared_hit_blocks}, read: {node.shared_read_blocks}, written: {node.shared_written_blocks}" - - # Recursively format children - if node.children: - for child in node.children: - output += "\n" + ExplainPlanArtifact._format_plan_node(child, level + 1) - - return output - - @classmethod - def from_json_data(cls, plan_data: dict[str, Any]) -> "ExplainPlanArtifact": - if "Plan" not in plan_data: - raise ValueError("Missing 'Plan' field in explain plan data") - - # Create plan tree from the "Plan" field - plan_tree = PlanNode.from_json_data(plan_data["Plan"]) - - # Extract optional timing information - planning_time = plan_data.get("Planning Time") - execution_time = plan_data.get("Execution Time") - - return cls( - value=json.dumps(plan_data, indent=2), - plan_tree=plan_tree, - planning_time=planning_time, - execution_time=execution_time, - ) - - @staticmethod - def format_plan_summary(plan_data): - """Extract and format key information from a raw plan data.""" - if not plan_data: - return "No plan data available" - - try: - # Create a PlanNode from the raw JSON data - if "Plan" in plan_data: - plan_node = PlanNode.from_json_data(plan_data["Plan"]) - - # Use _format_plan_node to format the output - plan_tree = ExplainPlanArtifact._format_plan_node(plan_node, 0) - - return f"{plan_tree}" - else: - return "Invalid plan data (missing Plan field)" - - except Exception as e: - return f"Error summarizing plan: {e}" - - @staticmethod - def create_plan_diff(before_plan: dict[str, Any], after_plan: dict[str, Any]) -> str: - """Generate a textual diff between two explain plans. - - Args: - before_plan: The explain plan before changes - after_plan: The explain plan after changes - - Returns: - A string containing a readable diff between the two plans - """ - if not before_plan or not after_plan: - return "Cannot generate diff: Missing plan data" - - try: - # Create PlanNode objects from the plans - before_tree = PlanNode.from_json_data(before_plan["Plan"]) if "Plan" in before_plan else None - after_tree = PlanNode.from_json_data(after_plan["Plan"]) if "Plan" in after_plan else None - - if not before_tree or not after_tree: - return "Cannot generate diff: Invalid plan structure" - - # Format the plans as text - before_lines = ExplainPlanArtifact._format_plan_node(before_tree).split("\n") - after_lines = ExplainPlanArtifact._format_plan_node(after_tree).split("\n") - - # Generate a readable diff with context - diff_lines = [] - diff_lines.append("PLAN CHANGES:") - diff_lines.append("------------") - - # Extract cost information for a summary - before_cost = before_tree.total_cost - after_cost = after_tree.total_cost - improvement = calculate_improvement_multiple(before_cost, after_cost) - - diff_lines.append(f"Cost: {before_cost:.2f} → {after_cost:.2f} ({improvement:.1f}x improvement)") - diff_lines.append("") - - # Node type changes - a simplified structural diff - diff_lines.append("Operation Changes:") - - # Helper function to extract node types with indentation - def extract_node_types(node, level=0, result=None): - if result is None: - result = [] - indent = " " * level - node_info = f"{indent}→ {node.node_type}" - if node.relation_name: - node_info += f" on {node.relation_name}" - result.append(node_info) - for child in node.children: - extract_node_types(child, level + 1, result) - return result - - before_structure = extract_node_types(before_tree) - after_structure = extract_node_types(after_tree) - - # Generate the structural diff - structure_diff = list( - difflib.unified_diff( - before_structure, - after_structure, - n=1, # Context lines - lineterm="", - ) - ) - - # Add structural diff to output - if structure_diff: - diff_lines.extend(structure_diff) - else: - diff_lines.append("No structural changes detected") - - # Add more specific details about key changes - diff_lines.append("") - diff_lines.append("Major Changes:") - - # Look for significant changes like seq scan to index scan, changed filters, etc. - # This requires traversing both trees and comparing nodes - - # For simplicity, we'll just list key changes in cost and rows - if before_tree.node_type != after_tree.node_type: - diff_lines.append(f"- Root operation changed: {before_tree.node_type} → {after_tree.node_type}") - - # Compare scan methods used - before_scans = [line for line in before_lines if "Seq Scan" in line] - after_scans = [line for line in after_lines if "Seq Scan" in line] - if len(before_scans) > len(after_scans): - diff_lines.append(f"- {len(before_scans) - len(after_scans)} sequential scans replaced with more efficient access methods") - - # Look for new index scans - before_idx_scans = [line for line in before_lines if "Index Scan" in line] - after_idx_scans = [line for line in after_lines if "Index Scan" in line] - if len(after_idx_scans) > len(before_idx_scans): - diff_lines.append(f"- {len(after_idx_scans) - len(before_idx_scans)} new index scans used") - - return "\n".join(diff_lines) - - except Exception as e: - return f"Error generating plan diff: {e}" diff --git a/src/postgres_mcp/database_health/__init__.py b/src/postgres_mcp/database_health/__init__.py deleted file mode 100644 index 085cf5ec..00000000 --- a/src/postgres_mcp/database_health/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .database_health import DatabaseHealthTool -from .database_health import HealthType - -__all__ = ["DatabaseHealthTool", "HealthType"] diff --git a/src/postgres_mcp/database_health/buffer_health_calc.py b/src/postgres_mcp/database_health/buffer_health_calc.py deleted file mode 100644 index 85bce667..00000000 --- a/src/postgres_mcp/database_health/buffer_health_calc.py +++ /dev/null @@ -1,62 +0,0 @@ -from typing import Any - -from ..sql import SqlDriver - - -class BufferHealthCalc: - _cached_indexes: list[dict[str, Any]] | None = None - - def __init__(self, sql_driver: SqlDriver): - self.sql_driver = sql_driver - - async def index_hit_rate(self, threshold: float = 0.95) -> str: - """Calculate the index cache hit rate. - - Returns: - String describing the index cache hit rate as a percentage and comparison to threshold - """ - result = await self.sql_driver.execute_query(""" - SELECT - (sum(idx_blks_hit)) / nullif(sum(idx_blks_hit + idx_blks_read), 0) AS rate - FROM - pg_statio_user_indexes - """) - - result_list = [dict(x.cells) for x in result] if result else [] - - if not result_list or result_list[0]["rate"] is None: - return "No index cache statistics available." - - hit_rate = float(result_list[0]["rate"]) * 100 - threshold_pct = threshold * 100 - - if hit_rate >= threshold_pct: - return f"Index cache hit rate: {hit_rate:.1f}% (above {threshold_pct:.1f}% threshold)" - else: - return f"Index cache hit rate: {hit_rate:.1f}% (below {threshold_pct:.1f}% threshold)" - - async def table_hit_rate(self, threshold: float = 0.95) -> str: - """Calculate the table cache hit rate. - - Returns: - String describing the table cache hit rate as a percentage and comparison to threshold - """ - result = await self.sql_driver.execute_query(""" - SELECT - sum(heap_blks_hit) / nullif(sum(heap_blks_hit + heap_blks_read), 0) AS rate - FROM - pg_statio_user_tables - """) - - result_list = [dict(x.cells) for x in result] if result else [] - - if not result_list or result_list[0]["rate"] is None: - return "No table cache statistics available." - - hit_rate = float(result_list[0]["rate"]) * 100 - threshold_pct = threshold * 100 - - if hit_rate >= threshold_pct: - return f"Table cache hit rate: {hit_rate:.1f}% (above {threshold_pct:.1f}% threshold)" - else: - return f"Table cache hit rate: {hit_rate:.1f}% (below {threshold_pct:.1f}% threshold)" diff --git a/src/postgres_mcp/database_health/connection_health_calc.py b/src/postgres_mcp/database_health/connection_health_calc.py deleted file mode 100644 index 5604a7c3..00000000 --- a/src/postgres_mcp/database_health/connection_health_calc.py +++ /dev/null @@ -1,76 +0,0 @@ -from dataclasses import dataclass - -from ..sql import SqlDriver - - -@dataclass -class ConnectionHealthMetrics: - total_connections: int - idle_connections: int - max_total_connections: int - max_idle_connections: int - is_total_connections_healthy: bool - is_idle_connections_healthy: bool - - @property - def is_healthy(self) -> bool: - return self.is_total_connections_healthy and self.is_idle_connections_healthy - - -class ConnectionHealthCalc: - def __init__( - self, - sql_driver: SqlDriver, - max_total_connections: int = 500, - max_idle_connections: int = 100, - ): - self.sql_driver = sql_driver - self.max_total_connections = max_total_connections - self.max_idle_connections = max_idle_connections - - async def total_connections_check(self) -> str: - """Check if total number of connections is within healthy limits.""" - total = await self._get_total_connections() - - if total <= self.max_total_connections: - return f"Total connections healthy: {total}" - return f"High number of connections: {total} (max: {self.max_total_connections})" - - async def idle_connections_check(self) -> str: - """Check if number of idle connections is within healthy limits.""" - idle = await self._get_idle_connections() - - if idle <= self.max_idle_connections: - return f"Idle connections healthy: {idle}" - return f"High number of idle connections: {idle} (max: {self.max_idle_connections})" - - async def connection_health_check(self) -> str: - """Run all connection health checks and return combined results.""" - total = await self._get_total_connections() - idle = await self._get_idle_connections() - - if total > self.max_total_connections: - return f"High number of connections: {total}" - elif idle > self.max_idle_connections: - return f"High number of connections idle in transaction: {idle}" - else: - return f"Connections healthy: {total} total, {idle} idle" - - async def _get_total_connections(self) -> int: - """Get the total number of database connections.""" - result = await self.sql_driver.execute_query(""" - SELECT COUNT(*) as count - FROM pg_stat_activity - """) - result_list = [dict(x.cells) for x in result] if result else [] - return result_list[0]["count"] if result_list else 0 - - async def _get_idle_connections(self) -> int: - """Get the number of connections that are idle in transaction.""" - result = await self.sql_driver.execute_query(""" - SELECT COUNT(*) as count - FROM pg_stat_activity - WHERE state = 'idle in transaction' - """) - result_list = [dict(x.cells) for x in result] if result else [] - return result_list[0]["count"] if result_list else 0 diff --git a/src/postgres_mcp/database_health/constraint_health_calc.py b/src/postgres_mcp/database_health/constraint_health_calc.py deleted file mode 100644 index 1183c6ae..00000000 --- a/src/postgres_mcp/database_health/constraint_health_calc.py +++ /dev/null @@ -1,101 +0,0 @@ -from dataclasses import dataclass - -from ..sql import SqlDriver - - -@dataclass -class ConstraintMetrics: - schema: str - table: str - name: str - referenced_schema: str | None - referenced_table: str | None - - -class ConstraintHealthCalc: - def __init__(self, sql_driver: SqlDriver): - self.sql_driver = sql_driver - - async def invalid_constraints_check(self) -> str: - """Check for any invalid constraints in the database. - - Returns: - String describing any invalid constraints found - """ - metrics = await self._get_invalid_constraints() - - if not metrics: - return "No invalid constraints found." - - result = ["Invalid constraints found:"] - for metric in metrics: - if metric.referenced_table: - result.append( - f"Constraint '{metric.name}' on table '{metric.schema}.{metric.table}' " - f"referencing '{metric.referenced_schema}.{metric.referenced_table}' is invalid" - ) - else: - result.append(f"Constraint '{metric.name}' on table '{metric.schema}.{metric.table}' is invalid") - return "\n".join(result) - - async def _get_invalid_constraints(self) -> list[ConstraintMetrics]: - """Get all invalid constraints in the database.""" - results = await self.sql_driver.execute_query(""" - SELECT - nsp.nspname AS schema, - rel.relname AS table, - con.conname AS name, - fnsp.nspname AS referenced_schema, - frel.relname AS referenced_table - FROM - pg_catalog.pg_constraint con - INNER JOIN - pg_catalog.pg_class rel ON rel.oid = con.conrelid - LEFT JOIN - pg_catalog.pg_class frel ON frel.oid = con.confrelid - LEFT JOIN - pg_catalog.pg_namespace nsp ON nsp.oid = con.connamespace - LEFT JOIN - pg_catalog.pg_namespace fnsp ON fnsp.oid = frel.relnamespace - WHERE - con.convalidated = 'f' - """) - - if not results: - return [] - - result_list = [dict(x.cells) for x in results] - - return [ - ConstraintMetrics( - schema=row["schema"], - table=row["table"], - name=row["name"], - referenced_schema=row["referenced_schema"], - referenced_table=row["referenced_table"], - ) - for row in result_list - ] - - async def _get_total_constraints(self) -> int: - """Get the total number of constraints.""" - result = await self.sql_driver.execute_query(""" - SELECT COUNT(*) as count - FROM information_schema.table_constraints - """) - if not result: - return 0 - result_list = [dict(x.cells) for x in result] - return result_list[0]["count"] if result_list else 0 - - async def _get_active_constraints(self) -> int: - """Get the number of active constraints.""" - result = await self.sql_driver.execute_query(""" - SELECT COUNT(*) as count - FROM information_schema.table_constraints - WHERE is_deferrable = 'NO' - """) - if not result: - return 0 - result_list = [dict(x.cells) for x in result] - return result_list[0]["count"] if result_list else 0 diff --git a/src/postgres_mcp/database_health/database_health.py b/src/postgres_mcp/database_health/database_health.py deleted file mode 100644 index ecf94924..00000000 --- a/src/postgres_mcp/database_health/database_health.py +++ /dev/null @@ -1,98 +0,0 @@ -from __future__ import annotations - -import logging -from enum import Enum -from typing import List - -import mcp.types as types - -from .buffer_health_calc import BufferHealthCalc -from .connection_health_calc import ConnectionHealthCalc -from .constraint_health_calc import ConstraintHealthCalc -from .index_health_calc import IndexHealthCalc -from .replication_calc import ReplicationCalc -from .sequence_health_calc import SequenceHealthCalc -from .vacuum_health_calc import VacuumHealthCalc - -ResponseType = List[types.TextContent | types.ImageContent | types.EmbeddedResource] - -logger = logging.getLogger(__name__) - - -class HealthType(str, Enum): - INDEX = "index" - CONNECTION = "connection" - VACUUM = "vacuum" - SEQUENCE = "sequence" - REPLICATION = "replication" - BUFFER = "buffer" - CONSTRAINT = "constraint" - ALL = "all" - - -class DatabaseHealthTool: - """Tool for analyzing database health metrics.""" - - def __init__(self, sql_driver): - self.sql_driver = sql_driver - - async def health(self, health_type: str) -> str: - """Run database health checks for the specified components. - - Args: - health_type: Comma-separated list of health check types to perform - Valid values: index, connection, vacuum, sequence, replication, buffer, constraint, all - - Returns: - A string with the health check results - """ - try: - result = "" - try: - health_types = {HealthType(x.strip()) for x in health_type.split(",")} - except ValueError: - return ( - f"Invalid health types provided: '{health_type}'. " - + f"Valid values are: {', '.join(sorted([t.value for t in HealthType]))}. " - + "Please try again with a comma-separated list of valid health types." - ) - - if HealthType.ALL in health_types: - health_types = [t.value for t in HealthType if t != HealthType.ALL] - - if HealthType.INDEX in health_types: - index_health = IndexHealthCalc(self.sql_driver) - result += "Invalid index check: " + await index_health.invalid_index_check() + "\n" - result += "Duplicate index check: " + await index_health.duplicate_index_check() + "\n" - result += "Index bloat: " + await index_health.index_bloat() + "\n" - result += "Unused index check: " + await index_health.unused_indexes() + "\n" - - if HealthType.CONNECTION in health_types: - connection_health = ConnectionHealthCalc(self.sql_driver) - result += "Connection health: " + await connection_health.connection_health_check() + "\n" - - if HealthType.VACUUM in health_types: - vacuum_health = VacuumHealthCalc(self.sql_driver) - result += "Vacuum health: " + await vacuum_health.transaction_id_danger_check() + "\n" - - if HealthType.SEQUENCE in health_types: - sequence_health = SequenceHealthCalc(self.sql_driver) - result += "Sequence health: " + await sequence_health.sequence_danger_check() + "\n" - - if HealthType.REPLICATION in health_types: - replication_health = ReplicationCalc(self.sql_driver) - result += "Replication health: " + await replication_health.replication_health_check() + "\n" - - if HealthType.BUFFER in health_types: - buffer_health = BufferHealthCalc(self.sql_driver) - result += "Buffer health for indexes: " + await buffer_health.index_hit_rate() + "\n" - result += "Buffer health for tables: " + await buffer_health.table_hit_rate() + "\n" - - if HealthType.CONSTRAINT in health_types: - constraint_health = ConstraintHealthCalc(self.sql_driver) - result += "Constraint health: " + await constraint_health.invalid_constraints_check() + "\n" - - return result if result else "No health checks were performed." - except Exception as e: - logger.error(f"Error calculating database health: {e}", exc_info=True) - return f"Error calculating database health: {e}" diff --git a/src/postgres_mcp/database_health/index_health_calc.py b/src/postgres_mcp/database_health/index_health_calc.py deleted file mode 100644 index 041ad719..00000000 --- a/src/postgres_mcp/database_health/index_health_calc.py +++ /dev/null @@ -1,345 +0,0 @@ -from typing import Any - -from ..sql import SafeSqlDriver -from ..sql import SqlDriver - - -class IndexHealthCalc: - _cached_indexes: list[dict[str, Any]] | None = None - - def __init__(self, sql_driver: SqlDriver): - self.sql_driver = sql_driver - - async def invalid_index_check(self) -> str: - indexes = await self._indexes() - # Check for invalid indexes being created - invalid_indexes = [idx for idx in indexes if not idx["valid"]] - if not invalid_indexes: - return "No invalid indexes found." - - return "Invalid indexes found: " + "\n".join([f"{idx['name']} on {idx['table']} is invalid." for idx in invalid_indexes]) - - async def duplicate_index_check(self) -> str: - indexes = await self._indexes() - dup_indexes = [] - - # Group indexes by schema and table - indexes_by_table = {} - for idx in indexes: - key = (idx["schema"], idx["table"]) - if key not in indexes_by_table: - indexes_by_table[key] = [] - indexes_by_table[key].append(idx) - - # Check each valid non-primary/unique index for duplicates - for index in [i for i in indexes if i["valid"] and not i["primary"] and not i["unique"]]: - table_indexes = indexes_by_table[(index["schema"], index["table"])] - - # Find covering indexes - for covering_idx in table_indexes: - if ( - covering_idx["valid"] - and covering_idx["name"] != index["name"] - and self._index_covers(covering_idx["columns"], index["columns"]) - and covering_idx["using"] == index["using"] - and covering_idx["indexprs"] == index["indexprs"] - and covering_idx["indpred"] == index["indpred"] - ): - # Add to duplicates if conditions are met - if ( - covering_idx["columns"] != index["columns"] - or index["name"] > covering_idx["name"] - or covering_idx["primary"] - or covering_idx["unique"] - ): - dup_indexes.append({"unneeded_index": index, "covering_index": covering_idx}) - break - - if not dup_indexes: - return "No duplicate indexes found." - - # Sort by table and columns and format the output - sorted_dups = sorted( - dup_indexes, - key=lambda x: ( - x["unneeded_index"]["table"], - x["unneeded_index"]["columns"], - ), - ) - - result = ["Duplicate indexes found:"] - for dup in sorted_dups: - result.append( - f"Index '{dup['unneeded_index']['name']}' on table '{dup['unneeded_index']['table']}' " - f"is covered by index '{dup['covering_index']['name']}'" - ) - - return "\n".join(result) - - async def index_bloat(self, min_size: int = 104857600) -> str: - """Check for bloated indexes that are larger than min_size bytes. - - Args: - min_size: Minimum size in bytes to consider an index as bloated (default 100MB) - - Returns: - String describing any bloated indexes found - """ - bloated_indexes = await SafeSqlDriver.execute_param_query( - self.sql_driver, - """ - WITH btree_index_atts AS ( - SELECT - nspname, relname, reltuples, relpages, indrelid, relam, - regexp_split_to_table(indkey::text, ' ')::smallint AS attnum, - indexrelid as index_oid - FROM - pg_index - JOIN - pg_class ON pg_class.oid = pg_index.indexrelid - JOIN - pg_namespace ON pg_namespace.oid = pg_class.relnamespace - JOIN - pg_am ON pg_class.relam = pg_am.oid - WHERE - pg_am.amname = 'btree' - ), - index_item_sizes AS ( - SELECT - i.nspname, - i.relname, - i.reltuples, - i.relpages, - i.relam, - (quote_ident(s.schemaname) || '.' || quote_ident(s.tablename))::regclass AS starelid, - a.attrelid AS table_oid, index_oid, - current_setting('block_size')::numeric AS bs, - CASE - WHEN version() ~ 'mingw32' OR version() ~ '64-bit' THEN 8 - ELSE 4 - END AS maxalign, - 24 AS pagehdr, - CASE WHEN max(coalesce(s.null_frac,0)) = 0 - THEN 2 - ELSE 6 - END AS index_tuple_hdr, - sum( (1-coalesce(s.null_frac, 0)) * coalesce(s.avg_width, 2048) ) AS nulldatawidth - FROM - pg_attribute AS a - JOIN - pg_stats AS s ON (quote_ident(s.schemaname) || '.' || quote_ident(s.tablename))::regclass=a.attrelid AND s.attname = a.attname - JOIN - btree_index_atts AS i ON i.indrelid = a.attrelid AND a.attnum = i.attnum - WHERE - a.attnum > 0 - GROUP BY - 1, 2, 3, 4, 5, 6, 7, 8, 9 - ), - index_aligned AS ( - SELECT - maxalign, - bs, - nspname, - relname AS index_name, - reltuples, - relpages, - relam, - table_oid, - index_oid, - ( 2 + - maxalign - CASE - WHEN index_tuple_hdr%maxalign = 0 THEN maxalign - ELSE index_tuple_hdr%maxalign - END - + nulldatawidth + maxalign - CASE - WHEN nulldatawidth::integer%maxalign = 0 THEN maxalign - ELSE nulldatawidth::integer%maxalign - END - )::numeric AS nulldatahdrwidth, pagehdr - FROM - index_item_sizes AS s1 - ), - otta_calc AS ( - SELECT - bs, - nspname, - table_oid, - index_oid, - index_name, - relpages, - coalesce( - ceil((reltuples*(4+nulldatahdrwidth))/(bs-pagehdr::float)) + - CASE WHEN am.amname IN ('hash','btree') THEN 1 ELSE 0 END , 0 - ) AS otta - FROM - index_aligned AS s2 - LEFT JOIN - pg_am am ON s2.relam = am.oid - ), - raw_bloat AS ( - SELECT - nspname, - c.relname AS table_name, - index_name, - bs*(sub.relpages)::bigint AS totalbytes, - CASE - WHEN sub.relpages <= otta THEN 0 - ELSE bs*(sub.relpages-otta)::bigint END - AS wastedbytes, - CASE - WHEN sub.relpages <= otta - THEN 0 ELSE bs*(sub.relpages-otta)::bigint * 100 / (bs*(sub.relpages)::bigint) END - AS realbloat, - pg_relation_size(sub.table_oid) as table_bytes, - stat.idx_scan as index_scans, - stat.indexrelid - FROM - otta_calc AS sub - JOIN - pg_class AS c ON c.oid=sub.table_oid - JOIN - pg_stat_user_indexes AS stat ON sub.index_oid = stat.indexrelid - ) - SELECT - nspname AS schema, - table_name AS table, - index_name AS index, - wastedbytes AS bloat_bytes, - totalbytes AS index_bytes, - pg_get_indexdef(rb.indexrelid) AS definition, - indisprimary AS primary - FROM - raw_bloat rb - INNER JOIN - pg_index i ON i.indexrelid = rb.indexrelid - WHERE - wastedbytes >= {} - ORDER BY - wastedbytes DESC, - index_name - """, - [min_size], - ) - - if not bloated_indexes: - return "No bloated indexes found." - - result = ["Bloated indexes found:"] - # Convert RowResults to dicts first - bloated_indexes_dicts = [dict(idx.cells) for idx in bloated_indexes] - for idx in bloated_indexes_dicts: - bloat_mb = int(idx["bloat_bytes"]) / (1024 * 1024) - total_mb = int(idx["index_bytes"]) / (1024 * 1024) - result.append(f"Index '{idx['index']}' on table '{idx['table']}' has {bloat_mb:.1f}MB bloat out of {total_mb:.1f}MB total size") - - return "\n".join(result) - - async def _indexes(self) -> list[dict[str, Any]]: - if self._cached_indexes: - return self._cached_indexes - - # Get index information - results = await self.sql_driver.execute_query(""" - SELECT - schemaname AS schema, - t.relname AS table, - ix.relname AS name, - regexp_replace(pg_get_indexdef(i.indexrelid), '^[^\\(]*\\((.*)\\)$', '\\1') AS columns, - regexp_replace(pg_get_indexdef(i.indexrelid), '.* USING ([^ ]*) \\(.*', '\\1') AS using, - indisunique AS unique, - indisprimary AS primary, - indisvalid AS valid, - indexprs::text, - indpred::text, - pg_get_indexdef(i.indexrelid) AS definition - FROM - pg_index i - INNER JOIN - pg_class t ON t.oid = i.indrelid - INNER JOIN - pg_class ix ON ix.oid = i.indexrelid - LEFT JOIN - pg_stat_user_indexes ui ON ui.indexrelid = i.indexrelid - WHERE - schemaname IS NOT NULL - ORDER BY - 1, 2 - """) - - if results is None: - return [] - - # Convert RowResults to dicts - indexes = [dict(idx.cells) for idx in results] - - # Process columns - for idx in indexes: - cols = idx["columns"] - cols = cols.replace(") WHERE (", " WHERE ").split(", ") - # Unquote column names - idx["columns"] = [col.strip('"') for col in cols] - - self._cached_indexes = indexes - return indexes - - def _index_covers(self, indexed_columns: list[str], columns: list[str]) -> bool: - """Check if indexed_columns cover the columns by comparing their prefixes. - - Args: - indexed_columns: The columns of the potentially covering index - columns: The columns being checked for coverage - - Returns: - True if indexed_columns cover columns, False otherwise - """ - return indexed_columns[: len(columns)] == columns - - async def unused_indexes(self, max_scans: int = 50) -> str: - """Check for unused or rarely used indexes. - - Args: - max_scans: Maximum number of scans to consider an index as unused (default 50) - - Returns: - String describing any unused indexes found - """ - unused = await SafeSqlDriver.execute_param_query( - self.sql_driver, - """ - SELECT - schemaname AS schema, - relname AS table, - indexrelname AS index, - pg_relation_size(i.indexrelid) AS size_bytes, - idx_scan as index_scans, - pg_get_indexdef(i.indexrelid) AS definition, - indisprimary AS primary - FROM - pg_stat_user_indexes ui - INNER JOIN - pg_index i ON ui.indexrelid = i.indexrelid - WHERE - NOT indisunique - AND idx_scan <= {} - ORDER BY - pg_relation_size(i.indexrelid) DESC, - relname ASC - """, - [max_scans], - ) - - if not unused: - return "No unused indexes found." - - indexes = [dict(idx.cells) for idx in unused] - - result = ["Rarely used indexes found:"] - for idx in indexes: - if idx["primary"]: - continue - size_mb = int(idx["size_bytes"]) / (1024 * 1024) - result.append( - f"Index '{idx['index']}' on table '{idx['table']}' has only been scanned {idx['index_scans']} times and uses {size_mb:.1f}MB of space" - ) - - return "\n".join(result) diff --git a/src/postgres_mcp/database_health/init.sql b/src/postgres_mcp/database_health/init.sql deleted file mode 100644 index 841ff0c4..00000000 --- a/src/postgres_mcp/database_health/init.sql +++ /dev/null @@ -1 +0,0 @@ -CREATE EXTENSION IF NOT EXISTS pg_stat_statements; diff --git a/src/postgres_mcp/database_health/replication_calc.py b/src/postgres_mcp/database_health/replication_calc.py deleted file mode 100644 index 42b7882c..00000000 --- a/src/postgres_mcp/database_health/replication_calc.py +++ /dev/null @@ -1,165 +0,0 @@ -from dataclasses import dataclass -from typing import Optional - -from ..sql import SqlDriver - - -@dataclass -class ReplicationSlot: - slot_name: str - database: str - active: bool - - -@dataclass -class ReplicationMetrics: - is_replica: bool - replication_lag_seconds: Optional[float] - is_replicating: bool - replication_slots: list[ReplicationSlot] - - -class ReplicationCalc: - def __init__(self, sql_driver: SqlDriver): - self.sql_driver = sql_driver - self._server_version: Optional[int] = None - self._feature_support: dict[str, bool] = {} - - async def replication_health_check(self) -> str: - """Check replication health including lag and slots.""" - metrics = await self._get_replication_metrics() - result = [] - - if metrics.is_replica: - result.append("This is a replica database.") - # Check replication status - if not metrics.is_replicating: - result.append("WARNING: Replica is not actively replicating from primary!") - else: - result.append("Replica is actively replicating from primary.") - - # Check replication lag - if metrics.replication_lag_seconds is not None: - if metrics.replication_lag_seconds == 0: - result.append("No replication lag detected.") - else: - result.append(f"Replication lag: {metrics.replication_lag_seconds:.1f} seconds") - else: - result.append("This is a primary database.") - if metrics.is_replicating: - result.append("Has active replicas connected.") - else: - result.append("No active replicas connected.") - - # Check replication slots for both primary and replica - if metrics.replication_slots: - active_slots = [s for s in metrics.replication_slots if s.active] - inactive_slots = [s for s in metrics.replication_slots if not s.active] - - if active_slots: - result.append("\nActive replication slots:") - for slot in active_slots: - result.append(f"- {slot.slot_name} (database: {slot.database})") - - if inactive_slots: - result.append("\nInactive replication slots:") - for slot in inactive_slots: - result.append(f"- {slot.slot_name} (database: {slot.database})") - else: - result.append("\nNo replication slots found.") - - return "\n".join(result) - - async def _get_replication_metrics(self) -> ReplicationMetrics: - """Get comprehensive replication metrics.""" - return ReplicationMetrics( - is_replica=await self._is_replica(), - replication_lag_seconds=await self._get_replication_lag(), - is_replicating=await self._is_replicating(), - replication_slots=await self._get_replication_slots(), - ) - - async def _is_replica(self) -> bool: - """Check if this database is a replica.""" - result = await self.sql_driver.execute_query("SELECT pg_is_in_recovery()") - result_list = [dict(x.cells) for x in result] if result is not None else [] - return bool(result_list[0]["pg_is_in_recovery"]) if result_list else False - - async def _get_replication_lag(self) -> Optional[float]: - """Get replication lag in seconds.""" - if not self._feature_supported("replication_lag"): - return None - - # Use appropriate functions based on PostgreSQL version - if await self._get_server_version() >= 100000: - lag_condition = "pg_last_wal_receive_lsn() = pg_last_wal_replay_lsn()" - else: - lag_condition = "pg_last_xlog_receive_location() = pg_last_xlog_replay_location()" - - try: - result = await self.sql_driver.execute_query(f""" - SELECT - CASE - WHEN NOT pg_is_in_recovery() OR {lag_condition} THEN 0 - ELSE EXTRACT (EPOCH FROM NOW() - pg_last_xact_replay_timestamp()) - END - AS replication_lag - """) - result_list = [dict(x.cells) for x in result] if result is not None else [] - return float(result_list[0]["replication_lag"]) if result_list else None - except Exception: - self._feature_support["replication_lag"] = False - return None - - async def _get_replication_slots(self) -> list[ReplicationSlot]: - """Get information about replication slots.""" - if await self._get_server_version() < 90400 or not self._feature_supported("replication_slots"): - return [] - - try: - result = await self.sql_driver.execute_query(""" - SELECT - slot_name, - database, - active - FROM pg_replication_slots - """) - if result is None: - return [] - result_list = [dict(x.cells) for x in result] - return [ - ReplicationSlot( - slot_name=row["slot_name"], - database=row["database"], - active=row["active"], - ) - for row in result_list - ] - except Exception: - self._feature_support["replication_slots"] = False - return [] - - async def _is_replicating(self) -> bool: - """Check if replication is active.""" - if not self._feature_supported("replicating"): - return False - - try: - result = await self.sql_driver.execute_query("SELECT state FROM pg_stat_replication") - result_list = [dict(x.cells) for x in result] if result is not None else [] - return bool(result_list and len(result_list) > 0) - except Exception: - self._feature_support["replicating"] = False - return False - - async def _get_server_version(self) -> int: - """Get PostgreSQL server version as a number (e.g. 100000 for version 10.0).""" - if self._server_version is None: - result = await self.sql_driver.execute_query("SHOW server_version_num") - result_list = [dict(x.cells) for x in result] if result is not None else [] - self._server_version = int(result_list[0]["server_version_num"]) if result_list else 0 - return self._server_version - - def _feature_supported(self, feature: str) -> bool: - """Check if a feature is supported and cache the result.""" - return self._feature_support.get(feature, True) diff --git a/src/postgres_mcp/database_health/sequence_health_calc.py b/src/postgres_mcp/database_health/sequence_health_calc.py deleted file mode 100644 index 81582fa6..00000000 --- a/src/postgres_mcp/database_health/sequence_health_calc.py +++ /dev/null @@ -1,151 +0,0 @@ -from dataclasses import dataclass - -from psycopg.sql import Identifier - -from ..sql import SafeSqlDriver -from ..sql import SqlDriver - - -@dataclass -class SequenceMetrics: - schema: str - table: str - column: str - sequence: str - column_type: str - last_value: int - max_value: int - is_healthy: bool - readable: bool = True - - @property - def percent_used(self) -> float: - """Calculate what percentage of the sequence has been used.""" - return (self.last_value / self.max_value) * 100 if self.max_value else 0 - - -class SequenceHealthCalc: - def __init__(self, sql_driver: SqlDriver, threshold: float = 0.9): - """Initialize sequence health calculator. - - Args: - sql_driver: SQL driver for database access - threshold: Percentage (as decimal) of sequence usage that triggers warning - """ - self.sql_driver = sql_driver - self.threshold = threshold - - async def sequence_danger_check(self) -> str: - """Check if any sequences are approaching their maximum values.""" - metrics = await self._get_sequence_metrics() - - if not metrics: - return "No sequences found in the database." - - # Sort by remaining values ascending to show most critical first - metrics.sort(key=lambda x: x.max_value - x.last_value) - - unhealthy = [m for m in metrics if not m.is_healthy] - if not unhealthy: - return "All sequences have healthy usage levels." - - result = ["Sequences approaching maximum value:"] - for metric in unhealthy: - remaining = metric.max_value - metric.last_value - result.append( - f"Sequence '{metric.schema}.{metric.sequence}' used for {metric.table}.{metric.column} " - f"has used {metric.percent_used:.1f}% of available values " - f"({metric.last_value:,} of {metric.max_value:,}, {remaining:,} remaining)" - ) - return "\n".join(result) - - async def _get_sequence_metrics(self) -> list[SequenceMetrics]: - """Get metrics for sequences in the database.""" - # First get all sequences used as default values - sequences = await self.sql_driver.execute_query(""" - SELECT - n.nspname AS table_schema, - c.relname AS table, - attname AS column, - format_type(a.atttypid, a.atttypmod) AS column_type, - pg_get_expr(d.adbin, d.adrelid) AS default_value - FROM - pg_catalog.pg_attribute a - INNER JOIN - pg_catalog.pg_class c ON c.oid = a.attrelid - INNER JOIN - pg_catalog.pg_namespace n ON n.oid = c.relnamespace - INNER JOIN - pg_catalog.pg_attrdef d ON (a.attrelid, a.attnum) = (d.adrelid, d.adnum) - WHERE - NOT a.attisdropped - AND a.attnum > 0 - AND pg_get_expr(d.adbin, d.adrelid) LIKE 'nextval%' - AND n.nspname NOT LIKE 'pg\\_temp\\_%' - """) - - if not sequences: - return [] - - result_list = [dict(x.cells) for x in sequences] - - # Process each sequence - sequence_metrics = [] - for seq in result_list: - # Parse the sequence name from default value - schema, sequence = self._parse_sequence_name(seq["default_value"]) - if not sequence: - continue - - # Determine max value based on column type - max_value = 2147483647 if seq["column_type"] == "integer" else 9223372036854775807 - - # Get sequence attributes - attrs = await SafeSqlDriver.execute_param_query( - self.sql_driver, - """ - SELECT - has_sequence_privilege('{}', 'SELECT') AS readable, - last_value - FROM {} - """, - [Identifier(schema, sequence), Identifier(schema, sequence)], - ) - - if not attrs: - continue - - result_list = [dict(x.cells) for x in attrs] - - attr = result_list[0] - sequence_metrics.append( - SequenceMetrics( - schema=schema, - table=seq["table"], - column=seq["column"], - sequence=sequence, - column_type=seq["column_type"], - last_value=attr["last_value"], - max_value=max_value, - readable=attr["readable"], - is_healthy=attr["last_value"] / max_value <= self.threshold, - ) - ) - - return sequence_metrics - - def _parse_sequence_name(self, default_value: str) -> tuple[str, str]: - """Parse schema and sequence name from default value expression.""" - # Handle both formats: - # nextval('id_seq'::regclass) - # nextval(('id_seq'::text)::regclass) - - # Remove nextval and cast parts - clean_value = default_value.replace("nextval('", "").replace("'::regclass)", "") - clean_value = clean_value.replace("('", "").replace("'::text)", "") - - # Split into schema and sequence - parts = clean_value.split(".") - if len(parts) == 1: - return "public", parts[0] # Default to public schema - return parts[0], parts[1] diff --git a/src/postgres_mcp/database_health/vacuum_health_calc.py b/src/postgres_mcp/database_health/vacuum_health_calc.py deleted file mode 100644 index 0dd24167..00000000 --- a/src/postgres_mcp/database_health/vacuum_health_calc.py +++ /dev/null @@ -1,102 +0,0 @@ -from dataclasses import dataclass - -from ..sql import SafeSqlDriver -from ..sql import SqlDriver - - -@dataclass -class TransactionIdMetrics: - schema: str - table: str - transactions_left: int - is_healthy: bool - - -class VacuumHealthCalc: - def __init__( - self, - sql_driver: SqlDriver, - threshold: int = 10000000, - max_value: int = 2146483648, - ): - self.sql_driver = sql_driver - self.threshold = threshold - self.max_value = max_value - - async def transaction_id_danger_check(self) -> str: - """Check if any tables are approaching transaction ID wraparound.""" - metrics = await self._get_transaction_id_metrics() - - if not metrics: - return "No tables found with transaction ID wraparound danger." - - # Sort by transactions left ascending to show most critical first - metrics.sort(key=lambda x: x.transactions_left) - - unhealthy = [m for m in metrics if not m.is_healthy] - if not unhealthy: - return "All tables have healthy transaction ID age." - - result = ["Tables approaching transaction ID wraparound:"] - for metric in unhealthy: - result.append( - f"Table '{metric.schema}.{metric.table}' has {metric.transactions_left:,} transactions " - f"remaining before wraparound (threshold: {self.threshold:,})" - ) - return "\n".join(result) - - async def _get_transaction_id_metrics(self) -> list[TransactionIdMetrics]: - """Get transaction ID metrics for all tables.""" - results = await SafeSqlDriver.execute_param_query( - self.sql_driver, - """ - SELECT - n.nspname AS schema, - c.relname AS table, - {} - GREATEST(AGE(c.relfrozenxid), AGE(t.relfrozenxid)) AS transactions_left - FROM - pg_class c - INNER JOIN - pg_catalog.pg_namespace n ON n.oid = c.relnamespace - LEFT JOIN - pg_class t ON c.reltoastrelid = t.oid - WHERE - c.relkind = 'r' - AND ({} - GREATEST(AGE(c.relfrozenxid), AGE(t.relfrozenxid))) < {} - ORDER BY - 3, 1, 2 - """, - [self.max_value, self.max_value, self.threshold], - ) - - if not results: - return [] - - result_list = [dict(x.cells) for x in results] - - return [ - TransactionIdMetrics( - schema=row["schema"], - table=row["table"], - transactions_left=row["transactions_left"], - is_healthy=row["transactions_left"] >= self.threshold, - ) - for row in result_list - ] - - async def _get_vacuum_stats(self) -> dict[str, dict[str, str | None]]: - """Get vacuum statistics for the database.""" - result = await self.sql_driver.execute_query(""" - SELECT relname, last_vacuum, last_autovacuum - FROM pg_stat_user_tables - """) - if not result: - return {} - result_list = [dict(x.cells) for x in result] - return { - row["relname"]: { - "last_vacuum": row["last_vacuum"], - "last_autovacuum": row["last_autovacuum"], - } - for row in result_list - } diff --git a/src/postgres_mcp/explain/README.md b/src/postgres_mcp/explain/README.md deleted file mode 100644 index 8a0069d3..00000000 --- a/src/postgres_mcp/explain/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# PostgreSQL Explain Tools - -This module provides tools for analyzing PostgreSQL query execution plans. - -## Tools - -### ExplainPlanTool - -Provides methods for generating different types of EXPLAIN plans. - -## Usage - -The explain tool is integrated into the PostgreSQL MCP server and can be used through the MCP API via the function: - -- `explain_query` - -This function accepts parameters to control the behavior: -- `sql` - The SQL query to explain (required) -- `analyze` - When true, executes the query to get real statistics (default: false) -- `hypothetical_indexes` - Optional list of indexes to simulate without creating them - -## Benefits - -- **Query Understanding**: Helps understand how PostgreSQL executes queries -- **Performance Analysis**: Identifies bottlenecks and optimization opportunities -- **Index Testing**: Tests hypothetical indexes without actually creating them diff --git a/src/postgres_mcp/explain/__init__.py b/src/postgres_mcp/explain/__init__.py deleted file mode 100644 index cfa41a49..00000000 --- a/src/postgres_mcp/explain/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""PostgreSQL explain plan tools.""" - -from .explain_plan import ExplainPlanTool - -__all__ = [ - "ExplainPlanTool", -] diff --git a/src/postgres_mcp/explain/explain_plan.py b/src/postgres_mcp/explain/explain_plan.py deleted file mode 100644 index 3027bf06..00000000 --- a/src/postgres_mcp/explain/explain_plan.py +++ /dev/null @@ -1,241 +0,0 @@ -# ruff: noqa: E501 - -from __future__ import annotations - -import logging -import re -from typing import TYPE_CHECKING -from typing import Any - -from ..artifacts import ErrorResult -from ..artifacts import ExplainPlanArtifact -from ..sql import IndexDefinition -from ..sql import SafeSqlDriver -from ..sql import SqlBindParams -from ..sql import check_postgres_version_requirement - -logger = logging.getLogger(__name__) - -if TYPE_CHECKING: - from ..sql.sql_driver import SqlDriver - - -class ExplainPlanTool: - """Tool for generating and analyzing PostgreSQL explain plans.""" - - def __init__(self, sql_driver: SqlDriver): - self.sql_driver = sql_driver - - async def replace_query_parameters_if_needed(self, sql_query: str) -> tuple[str, bool]: - """Replace bind variables with sample values in a query.""" - use_generic_plan = False - has_bind_variables = self._has_bind_variables(sql_query) - - # If query has bind variables, check PostgreSQL version for generic plan support - if has_bind_variables: - has_like = self._has_like_expressions(sql_query) - - meets_pg_version_requirement, _message = await check_postgres_version_requirement( - self.sql_driver, min_version=16, feature_name="Generic plan with bind variables ($1, $2, etc.)" - ) - - # If PostgreSQL < 16 or the query has LIKE expressions (which don't work with GENERIC_PLAN) - if not meets_pg_version_requirement or has_like: - # Replace bind variables with sample values - logger.debug("Replacing bind variables with sample values in query") - if meets_pg_version_requirement and has_like: - logger.debug("LIKE expressions detected, using parameter replacement instead of GENERIC_PLAN") - bind_params = SqlBindParams(self.sql_driver) - modified_query = await bind_params.replace_parameters(sql_query) - logger.debug(f"Original query: {sql_query}") - logger.debug(f"Modified query: {modified_query}") - sql_query = modified_query - else: - use_generic_plan = True - - return sql_query, use_generic_plan - - async def explain(self, sql_query: str, do_analyze: bool = False) -> ExplainPlanArtifact | ErrorResult: - """ - Generate an EXPLAIN plan for a SQL query. - - Args: - sql_query: The SQL query to explain - - Returns: - ExplainPlanArtifact or ErrorResult - """ - modified_sql_query, use_generic_plan = await self.replace_query_parameters_if_needed(sql_query) - return await self._run_explain_query(modified_sql_query, analyze=do_analyze, generic_plan=use_generic_plan) - - async def explain_analyze(self, sql_query: str) -> ExplainPlanArtifact | ErrorResult: - """ - Generate an EXPLAIN ANALYZE plan for a SQL query. - - Args: - sql_query: The SQL query to explain and analyze - - Returns: - ExplainPlanArtifact or ErrorResult - """ - return await self.explain(sql_query, do_analyze=True) - - async def explain_with_hypothetical_indexes( - self, sql_query: str, hypothetical_indexes: list[dict[str, Any]] - ) -> ExplainPlanArtifact | ErrorResult: - """ - Generate an explain plan for a query as if certain indexes existed. - - Args: - sql_query: The SQL query to explain - hypothetical_indexes: List of index definitions as dictionaries - - Returns: - ExplainPlanArtifact or ErrorResult - """ - try: - # Validate index definitions format - if not isinstance(hypothetical_indexes, list): - return ErrorResult(f"Expected list of index definitions, got {type(hypothetical_indexes)}") - - for idx in hypothetical_indexes: - if not isinstance(idx, dict): - return ErrorResult(f"Expected dictionary for index definition, got {type(idx)}") - if "table" not in idx: - return ErrorResult("Missing 'table' in index definition") - if "columns" not in idx: - return ErrorResult("Missing 'columns' in index definition") - if not isinstance(idx["columns"], list): - # Try to convert to list if it's not already - try: - idx["columns"] = list(idx["columns"]) if hasattr(idx["columns"], "__iter__") else [idx["columns"]] - except Exception as e: - return ErrorResult(f"Expected list for 'columns', got {type(idx['columns'])}: {e}") - - # Convert the index definitions to IndexConfig objects - indexes = frozenset( - IndexDefinition( - table=idx["table"], - columns=tuple(idx["columns"]), - using=idx.get("using", "btree"), - ) - for idx in hypothetical_indexes - ) - - # Check if the query contains bind variables - modified_sql_query, use_generic_plan = await self.replace_query_parameters_if_needed(sql_query) - - # Generate the explain plan using the static method - plan_data = await self.generate_explain_plan_with_hypothetical_indexes(modified_sql_query, indexes, use_generic_plan) - - # Check if we got a valid plan - if not plan_data or not isinstance(plan_data, dict) or "Plan" not in plan_data: - return ErrorResult("Failed to generate a valid explain plan with the hypothetical indexes") - - try: - # Convert the plan data to an ExplainPlanArtifact - return ExplainPlanArtifact.from_json_data(plan_data) - except Exception as e: - return ErrorResult(f"Error converting explain plan: {e}") - - except Exception as e: - logger.error(f"Error in explain_with_hypothetical_indexes: {e}", exc_info=True) - return ErrorResult(f"Error generating explain plan with hypothetical indexes: {e}") - - def _has_bind_variables(self, query: str) -> bool: - """Check if a query contains bind variables ($1, $2, etc).""" - return bool(re.search(r"\$\d+", query)) - - def _has_like_expressions(self, query: str) -> bool: - """Check if a query contains LIKE expressions, which don't work with GENERIC_PLAN.""" - return bool(re.search(r"\bLIKE\b", query, re.IGNORECASE)) - - async def _run_explain_query(self, query: str, analyze: bool = False, generic_plan: bool = False) -> ExplainPlanArtifact | ErrorResult: - try: - explain_options = ["FORMAT JSON"] - if analyze: - explain_options.append("ANALYZE") - if generic_plan: - explain_options.append("GENERIC_PLAN") - - explain_q = f"EXPLAIN ({', '.join(explain_options)}) {query}" - logger.debug(f"RUNNING EXPLAIN QUERY: {explain_q}") - rows = await self.sql_driver.execute_query(explain_q) # type: ignore - if rows is None: - return ErrorResult("No results returned from EXPLAIN") - - query_plan_data = rows[0].cells["QUERY PLAN"] - - if not isinstance(query_plan_data, list): - return ErrorResult(f"Expected list from EXPLAIN, got {type(query_plan_data)}") - if len(query_plan_data) == 0: - return ErrorResult("No results returned from EXPLAIN") - - plan_dict = query_plan_data[0] - if not isinstance(plan_dict, dict): - return ErrorResult(f"Expected dict in EXPLAIN result list, got {type(plan_dict)} with value {plan_dict}") - - try: - return ExplainPlanArtifact.from_json_data(plan_dict) - except Exception as e: - return ErrorResult(f"Internal error converting explain plan - do not retry: {e}") - except Exception as e: - return ErrorResult(f"Error executing explain plan: {e}") - - async def generate_explain_plan_with_hypothetical_indexes( - self, - query_text: str, - indexes: frozenset[IndexDefinition], - use_generic_plan: bool = False, - dta=None, - ) -> dict[str, Any]: - """ - Generate an explain plan for a query with specified indexes. - - Args: - sql_driver: SQL driver to execute the query - query_text: The SQL query to explain - indexes: A frozenset of IndexConfig objects representing the indexes to enable - - Returns: - The explain plan as a dictionary - """ - try: - # Create the indexes query - create_indexes_query = "SELECT hypopg_reset();" - if len(indexes) > 0: - create_indexes_query += SafeSqlDriver.param_sql_to_query( - "SELECT hypopg_create_index({});" * len(indexes), - [idx.definition for idx in indexes], - ) - - # Execute explain with the indexes - explain_options = ["FORMAT JSON"] - if use_generic_plan: - explain_options.append("GENERIC_PLAN") - if indexes: - explain_options.append("COSTS TRUE") - - explain_plan_query = f"{create_indexes_query}EXPLAIN ({', '.join(explain_options)}) {query_text}" - plan_result = await self.sql_driver.execute_query(explain_plan_query) # type: ignore - - # Extract the plan - if plan_result and plan_result[0].cells.get("QUERY PLAN"): - plan_data = plan_result[0].cells.get("QUERY PLAN") - if isinstance(plan_data, list) and len(plan_data) > 0: - return plan_data[0] - else: - dta.dta_trace( # type: ignore - f" - plan_data is an empty list with plan_data type: {type(plan_data)}" - ) # type: ignore - - dta.dta_trace(" - returning empty plan") # type: ignore - # Return empty plan if no result - return {"Plan": {"Total Cost": float("inf")}} - - except Exception as e: - logger.error( - f"Error getting explain plan for query: {query_text} with error: {e}", - exc_info=True, - ) - raise e diff --git a/src/postgres_mcp/index/dta_calc.py b/src/postgres_mcp/index/dta_calc.py deleted file mode 100644 index c10e2e19..00000000 --- a/src/postgres_mcp/index/dta_calc.py +++ /dev/null @@ -1,837 +0,0 @@ -import logging -import time -from itertools import combinations -from typing import Any -from typing import override - -import humanize -from pglast.ast import ColumnRef -from pglast.ast import JoinExpr -from pglast.ast import Node -from pglast.ast import SelectStmt - -from ..sql import ColumnCollector -from ..sql import SafeSqlDriver -from ..sql import SqlDriver -from ..sql import TableAliasVisitor -from .index_opt_base import IndexRecommendation -from .index_opt_base import IndexTuningBase -from .index_opt_base import candidate_str -from .index_opt_base import pp_list - -logger = logging.getLogger(__name__) - -# --- Data Classes --- - -logger = logging.getLogger(__name__) - - -class DatabaseTuningAdvisor(IndexTuningBase): - def __init__( - self, - sql_driver: SqlDriver, - budget_mb: int = -1, # no limit by default - max_runtime_seconds: int = 30, # 30 seconds - max_index_width: int = 3, - min_column_usage: int = 1, # skip columns used in fewer than this many queries - seed_columns_count: int = 3, # how many single-col seeds to pick - pareto_alpha: float = 2.0, - min_time_improvement: float = 0.1, - ): - """ - :param sql_driver: Database access - :param budget_mb: Storage budget - :param max_runtime_seconds: Time limit for entire analysis (anytime approach) - :param max_index_width: Maximum columns in an index - :param min_column_usage: skip columns that appear in fewer than X queries - :param seed_columns_count: how many top single-column indexes to pick as seeds - :param pareto_alpha: stop when relative improvement falls below this threshold - :param min_time_improvement: stop when relative improvement falls below this threshold - """ - super().__init__(sql_driver) - self.budget_mb = budget_mb - self.max_runtime_seconds = max_runtime_seconds - self.max_index_width = max_index_width - self.min_column_usage = min_column_usage - self.seed_columns_count = seed_columns_count - self._analysis_start_time = 0.0 - self.pareto_alpha = pareto_alpha - self.min_time_improvement = min_time_improvement - - def _check_time(self) -> bool: - """Return True if we have exceeded max_runtime_seconds.""" - if self.max_runtime_seconds <= 0: - return False - elapsed = time.time() - self._analysis_start_time - return elapsed > self.max_runtime_seconds - - @override - async def _generate_recommendations(self, query_weights: list[tuple[str, SelectStmt, float]]) -> tuple[set[IndexRecommendation], float]: - """Generate index recommendations using a hybrid 'seed + greedy' approach with a time cutoff.""" - - # Get existing indexes - existing_index_defs: set[str] = {idx["definition"] for idx in await self._get_existing_indexes()} - - logger.debug(f"Existing indexes ({len(existing_index_defs)}): {pp_list(list(existing_index_defs))}") - - # generate initial candidates - all_candidates = await self.generate_candidates(query_weights, existing_index_defs) - - self.dta_trace(f"All candidates ({len(all_candidates)}): {candidate_str(all_candidates)}") - - # TODO: Remove this once we have a better way to generate seeds - # # produce seeds if desired - # seeds = set() - # if self.seed_columns_count > 0 and not self._check_time(): - # seeds = self._quick_pass_seeds(query_weights, all_candidates) - - # unify seeds with an empty set - # we treat seeds as "starting points" - # in the real DTA approach, they'd enumerate many seeds, - # but let's just do: [seeds, empty] - # Because we do a small scale approach, we only do these 2 seeds - seeds_list = [ - # seeds, - set(), - ] - - best_config: tuple[set[IndexRecommendation], float] = (set(), float("inf")) - - # Evaluate each seed - for seed in seeds_list: - if self._check_time(): - break - - self.dta_trace("Evaluating seed:") - current_cost = await self._evaluate_configuration_cost(query_weights, frozenset(seed)) - candidate_indexes = set( - { - IndexRecommendation( - c.table, - tuple(c.columns), - c.using, - ) - for c in all_candidates - } - ) - final_indexes, final_cost = await self._enumerate_greedy(query_weights, seed.copy(), current_cost, candidate_indexes - seed) - - if final_cost < best_config[1]: - best_config = (final_indexes, final_cost) - - # Sort recs by benefit desc - return best_config - - async def generate_candidates(self, workload: list[tuple[str, SelectStmt, float]], existing_defs: set[str]) -> list[IndexRecommendation]: - """Generates index candidates from queries, with batch creation.""" - table_columns_usage = {} # table -> {col -> usage_count} - # Extract columns from all queries - for _q, stmt, _ in workload: - columns_per_table = self._sql_bind_params.extract_stmt_columns(stmt) - for tbl, cols in columns_per_table.items(): - if tbl not in table_columns_usage: - table_columns_usage[tbl] = {} - for c in cols: - table_columns_usage[tbl][c] = table_columns_usage[tbl].get(c, 0) + 1 - - # Filter out rarely used columns - # e.g. skip columns that appear in fewer than self.min_column_usage queries - table_columns: dict[str, set[str]] = {} - for tbl, usage_map in table_columns_usage.items(): - kept_cols = {c for c, usage in usage_map.items() if usage >= self.min_column_usage} - if kept_cols: - table_columns[tbl] = kept_cols - - candidates = [] - for table, cols in table_columns.items(): - # TODO: Optimize by prioritizing columns from filters/joins; current approach generates all combinations - col_list = list(cols) - for width in range(1, min(self.max_index_width, len(cols)) + 1): - for combo in combinations(col_list, width): - candidates.append(IndexRecommendation(table=table, columns=tuple(combo))) - - # filter out duplicates with existing indexes - filtered_candidates = [c for c in candidates if not self._index_exists(c, existing_defs)] - - # filter out candidates with columns not used in query conditions - condition_filtered1 = self._filter_candidates_by_query_conditions(workload, filtered_candidates) - - # filter out long text columns - condition_filtered = await self._filter_long_text_columns(condition_filtered1) - - self.dta_trace(f"Generated {len(candidates)} total candidates") - self.dta_trace(f"Filtered to {len(filtered_candidates)} after removing existing indexes.") - self.dta_trace(f"Filtered to {len(condition_filtered1)} after removing unused columns.") - self.dta_trace(f"Filtered to {len(condition_filtered)} after removing long text columns.") - # Batch create all hypothetical indexes and store their size estimates - if len(condition_filtered) > 0: - query = "SELECT hypopg_create_index({});" * len(condition_filtered) - await SafeSqlDriver.execute_param_query( - self.sql_driver, - query, - [idx.definition for idx in condition_filtered], - ) - - # Get estimated sizes without resetting indexes yet - result = await self.sql_driver.execute_query( - "SELECT index_name, hypopg_relation_size(indexrelid) as index_size FROM hypopg_list_indexes;" - ) - if result is not None: - index_map = {r.cells["index_name"]: r.cells["index_size"] for r in result} - for idx in condition_filtered: - if idx.name in index_map: - idx.estimated_size_bytes = index_map[idx.name] - - await self.sql_driver.execute_query("SELECT hypopg_reset();") - return condition_filtered - - async def _enumerate_greedy( - self, - queries: list[tuple[str, SelectStmt, float]], - current_indexes: set[IndexRecommendation], - current_cost: float, - candidate_indexes: set[IndexRecommendation], - ) -> tuple[set[IndexRecommendation], float]: - """ - Pareto optimal greedy approach using cost/benefit analysis: - - Cost: Size of base relation plus size of indexes (in bytes) - - Benefit: Inverse of query execution time (1/time) - - Objective function: log(time) + alpha * log(space) - - We want to minimize this function, with alpha=2 for 2x emphasis on performance - - Primary stopping criterion: minimum relative time improvement threshold - """ - import math - - # Parameters - alpha = self.pareto_alpha - min_time_improvement = self.min_time_improvement # 5% default - - self.dta_trace("\n[GREEDY SEARCH] Starting enumeration") - self.dta_trace(f" - Parameters: alpha={alpha}, min_time_improvement={min_time_improvement}") - self.dta_trace(f" - Initial indexes: {len(current_indexes)}, Candidates: {len(candidate_indexes)}") - - # Get the tables involved in this analysis - tables = set() - for idx in candidate_indexes: - tables.add(idx.table) - - # Estimate base relation size for each table - base_relation_size = sum([await self._get_table_size(table) for table in tables]) - - self.dta_trace(f" - Base relation size: {humanize.naturalsize(base_relation_size)}") - - # Calculate current indexes size - indexes_size = sum([await self._estimate_index_size(idx.table, list(idx.columns)) for idx in current_indexes]) - - # Total space is base relation plus indexes - current_space = base_relation_size + indexes_size - current_time = current_cost - current_objective = math.log(current_time) + alpha * math.log(current_space) if current_cost > 0 and current_space > 0 else float("inf") - - self.dta_trace( - f" - Initial configuration: Time={current_time:.2f}, " - f"Space={humanize.naturalsize(current_space)} (Base: {humanize.naturalsize(base_relation_size)}, " - f"Indexes: {humanize.naturalsize(indexes_size)}), " - f"Objective={current_objective:.4f}" - ) - - added_indexes = [] # Keep track of added indexes in order - iteration = 1 - - while True: - self.dta_trace(f"\n[ITERATION {iteration}] Evaluating candidates") - best_index = None - best_time = current_time - best_space = current_space - best_objective = current_objective - best_time_improvement = 0 - - for candidate in candidate_indexes: - self.dta_trace(f"Evaluating candidate: {candidate_str([candidate])}") - # Calculate additional size from this index - index_size = await self._estimate_index_size(candidate.table, list(candidate.columns)) - self.dta_trace(f" + Index size: {humanize.naturalsize(index_size)}") - # Total space with this index = current space + new index size - test_space = current_space + index_size - self.dta_trace(f" + Total space: {humanize.naturalsize(test_space)}") - - # Check budget constraint - if self.budget_mb > 0 and (test_space - base_relation_size) > self.budget_mb * 1024 * 1024: - self.dta_trace( - f" - Skipping candidate: {candidate_str([candidate])} because total " - f"index size ({humanize.naturalsize(test_space - base_relation_size)}) exceeds " - f"budget ({humanize.naturalsize(self.budget_mb * 1024 * 1024)})" - ) - continue - - # Calculate new time (cost) with this index - test_time = await self._evaluate_configuration_cost(queries, frozenset(idx.index_definition for idx in current_indexes | {candidate})) - self.dta_trace(f" + Eval cost (time): {test_time}") - - # Calculate relative time improvement - time_improvement = (current_time - test_time) / current_time - - # Skip if time improvement is below threshold - if time_improvement < min_time_improvement: - self.dta_trace(f" - Skipping candidate: {candidate_str([candidate])} because time improvement is below threshold") - continue - - # Calculate objective for this configuration - test_objective = math.log(test_time) + alpha * math.log(test_space) - - # Select the index with the best time improvement that meets our threshold - if test_objective < best_objective and time_improvement > best_time_improvement: - self.dta_trace(f" - Updating best candidate: {candidate_str([candidate])}") - best_index = candidate - best_time = test_time - best_space = test_space - best_objective = test_objective - best_time_improvement = time_improvement - else: - self.dta_trace(f" - Skipping candidate: {candidate_str([candidate])} because it doesn't have the best objective improvement") - - # If no improvement or no valid candidates, stop - if best_index is None: - self.dta_trace(f"STOPPED SEARCH: No indexes found with time improvement >= {min_time_improvement:.2%}") - break - - # Calculate improvements/changes - time_improvement = (current_time - best_time) / current_time - space_increase = (best_space - current_space) / current_space - objective_improvement = current_objective - best_objective - - # Log this step - self.dta_trace( - f" - Selected index: {candidate_str([best_index])}" - f"\n + Time improvement: {time_improvement:.2%}" - f"\n + Space increase: {space_increase:.2%}" - f"\n + New objective: {best_objective:.4f} (improvement: {objective_improvement:.4f})" - ) - - # Add the best index and update metrics - current_indexes.add(best_index) - candidate_indexes.remove(best_index) - added_indexes.append(best_index) - - # Update current metrics - current_time = best_time - current_space = best_space - current_objective = best_objective - - iteration += 1 - - # Check if we've exceeded the time limit after doing at least one iteration - if self._check_time(): - self.dta_trace("STOPPED SEARCH: Time limit reached") - break - - # Log final configuration - self.dta_trace("\n[SEARCH COMPLETE]") - if added_indexes: - indexes_size = sum([await self._estimate_index_size(idx.table, list(idx.columns)) for idx in current_indexes]) - self.dta_trace( - f" - Final configuration: {len(added_indexes)} indexes added" - f"\n + Final time: {current_time:.2f}" - f"\n + Final space: {humanize.naturalsize(current_space)} (Base: {humanize.naturalsize(base_relation_size)}, " - f"Indexes: {humanize.naturalsize(indexes_size)})" - f"\n + Final objective: {current_objective:.4f}" - ) - else: - self.dta_trace("No indexes added - baseline configuration is optimal") - - return current_indexes, current_time - - def _filter_candidates_by_query_conditions( - self, workload: list[tuple[str, SelectStmt, float]], candidates: list[IndexRecommendation] - ) -> list[IndexRecommendation]: - """Filter out index candidates that contain columns not used in query conditions.""" - if not workload or not candidates: - return candidates - - # Extract all columns used in conditions across all queries - condition_columns = {} # Dictionary of table -> set of columns - - for _, stmt, _ in workload: - try: - # Use our enhanced collector to extract condition columns - collector = ConditionColumnCollector() - collector(stmt) - query_condition_columns = collector.condition_columns - - # Merge with overall condition columns - for table, cols in query_condition_columns.items(): - if table not in condition_columns: - condition_columns[table] = set() - condition_columns[table].update(cols) - - except Exception as e: - raise ValueError("Error extracting condition columns from query") from e - - # Filter candidates - keep only those where all columns are in condition_columns - filtered_candidates = [] - for candidate in candidates: - table = candidate.table - if table not in condition_columns: - continue - - # Check if all columns in the index are used in conditions - all_columns_used = all(col in condition_columns[table] for col in candidate.columns) - if all_columns_used: - filtered_candidates.append(candidate) - - return filtered_candidates - - async def _filter_long_text_columns(self, candidates: list[IndexRecommendation], max_text_length: int = 100) -> list[IndexRecommendation]: - """Filter out indexes that contain long text columns based on catalog information. - - Args: - candidates: List of candidate indexes - max_text_length: Maximum allowed text length (default: 100) - - Returns: - Filtered list of indexes - """ - if not candidates: - return [] - - # First, get all unique table.column combinations - table_columns = set() - for candidate in candidates: - for column in candidate.columns: - table_columns.add((candidate.table, column)) - - # Create a list of table names for the query - tables_array = ",".join(f"'{table}'" for table, _ in table_columns) - columns_array = ",".join(f"'{col}'" for _, col in table_columns) - - # Query to get column types and their length limits from catalog - type_query = f""" - SELECT - c.table_name, - c.column_name, - c.data_type, - c.character_maximum_length, - pg_stats.avg_width, - CASE - WHEN c.data_type = 'text' THEN true - WHEN (c.data_type = 'character varying' OR c.data_type = 'varchar' OR - c.data_type = 'character' OR c.data_type = 'char') AND - (c.character_maximum_length IS NULL OR c.character_maximum_length > {max_text_length}) - THEN true - ELSE false - END as potential_long_text - FROM information_schema.columns c - LEFT JOIN pg_stats ON - pg_stats.tablename = c.table_name AND - pg_stats.attname = c.column_name - WHERE c.table_name IN ({tables_array}) - AND c.column_name IN ({columns_array}) - """ - - result = await self.sql_driver.execute_query(type_query) # type: ignore - - logger.debug(f"Column types and length limits: {result}") - - if not result: - logger.debug("No column types and length limits found") - return [] - - # Process results and identify problematic columns - problematic_columns = set() - potential_problematic_columns = set() - - for row in result: - table = row.cells["table_name"] - column = row.cells["column_name"] - potential_long = row.cells["potential_long_text"] - avg_width = row.cells.get("avg_width") - - # Use avg_width from pg_stats as a heuristic - if it's high, likely contains long text - if potential_long and (avg_width is None or avg_width > max_text_length * 0.4): - problematic_columns.add((table, column)) - logger.debug(f"Identified potentially long text column: {table}.{column} (avg_width: {avg_width})") - elif potential_long: - potential_problematic_columns.add((table, column)) - - # Filter candidates based on column information - filtered_candidates = [] - for candidate in candidates: - valid = True - for column in candidate.columns: - if (candidate.table, column) in problematic_columns: - valid = False - logger.debug(f"Skipping index candidate with long text column: {candidate.table}.{column}") - break - elif (candidate.table, column) in potential_problematic_columns: - candidate.potential_problematic_reason = "long_text_column" - - if valid: - filtered_candidates.append(candidate) - - return filtered_candidates - - async def _get_existing_indexes(self) -> list[dict[str, Any]]: - """Get all existing indexes""" - # TODO: we should get the indexes that are relevant to the query - query = """ - SELECT schemaname as schema, - tablename as table, - indexname as name, - indexdef as definition - FROM pg_indexes - WHERE schemaname NOT IN ('pg_catalog', 'information_schema') - ORDER BY schemaname, tablename, indexname - """ - result = await self.sql_driver.execute_query(query) - if result is not None: - return [dict(row.cells) for row in result] - return [] - - def _index_exists(self, index: IndexRecommendation, existing_defs: set[str]) -> bool: - """Check if an index with the same table, columns, and type already exists in the database. - - Uses pglast to parse index definitions and compare their structure rather than - doing simple string matching. - """ - from pglast import parser - - try: - # Parse the candidate index - candidate_stmt = parser.parse_sql(index.definition)[0] - candidate_node = candidate_stmt.stmt - - # Extract key information from candidate index - candidate_info = self._extract_index_info(candidate_node) - - # If we couldn't parse the candidate index, fall back to string comparison - if not candidate_info: - return index.definition in existing_defs - - # Check each existing index - for existing_def in existing_defs: - try: - # Skip if it's obviously not an index - if not ("CREATE INDEX" in existing_def.upper() or "CREATE UNIQUE INDEX" in existing_def.upper()): - continue - - # Parse the existing index - existing_stmt = parser.parse_sql(existing_def)[0] - existing_node = existing_stmt.stmt - - # Extract key information - existing_info = self._extract_index_info(existing_node) - - # Compare the key components - if existing_info and self._is_same_index(candidate_info, existing_info): - return True - except Exception as e: - raise ValueError("Error parsing existing index") from e - - return False - except Exception as e: - raise ValueError("Error in robust index comparison") from e - - def _extract_index_info(self, node) -> dict[str, Any] | None: - """Extract key information from a parsed index node.""" - try: - # Handle differences in node structure between pglast versions - if hasattr(node, "IndexStmt"): - index_stmt = node.IndexStmt - else: - index_stmt = node - - # Extract table name - if hasattr(index_stmt.relation, "relname"): - table_name = index_stmt.relation.relname - else: - # Extract from RangeVar - table_name = index_stmt.relation.RangeVar.relname - - # Extract columns - columns = [] - for idx_elem in index_stmt.indexParams: - if hasattr(idx_elem, "name") and idx_elem.name: - columns.append(idx_elem.name) - elif hasattr(idx_elem, "IndexElem") and idx_elem.IndexElem: - columns.append(idx_elem.IndexElem.name) - elif hasattr(idx_elem, "expr") and idx_elem.expr: - # Convert the expression to a proper string representation - expr_str = self._ast_expr_to_string(idx_elem.expr) - columns.append(expr_str) - # Extract index type - index_type = "btree" # default - if hasattr(index_stmt, "accessMethod") and index_stmt.accessMethod: - index_type = index_stmt.accessMethod - - # Check if unique - is_unique = False - if hasattr(index_stmt, "unique"): - is_unique = index_stmt.unique - - return { - "table": table_name.lower(), - "columns": [col.lower() for col in columns], - "type": index_type.lower(), - "unique": is_unique, - } - except Exception as e: - self.dta_trace(f"Error extracting index info: {e}") - raise ValueError("Error extracting index info") from e - - def _ast_expr_to_string(self, expr) -> str: - """Convert an AST expression (like FuncCall) to a proper string representation. - - For example, converts a FuncCall node representing lower(name) to "lower(name)" - """ - try: - # Import FuncCall and ColumnRef for type checking - from pglast.ast import ColumnRef - from pglast.ast import FuncCall - - # Check for FuncCall type directly - if isinstance(expr, FuncCall): - # Extract function name - if hasattr(expr, "funcname") and expr.funcname: - func_name = ".".join([name.sval for name in expr.funcname if hasattr(name, "sval")]) - else: - func_name = "unknown_func" - - # Extract arguments - args = [] - if hasattr(expr, "args") and expr.args: - for arg in expr.args: - args.append(self._ast_expr_to_string(arg)) - - # Format as function call - return f"{func_name}({','.join(args)})" - - # Check for ColumnRef type directly - elif isinstance(expr, ColumnRef): - if hasattr(expr, "fields") and expr.fields: - return ".".join([field.sval for field in expr.fields if hasattr(field, "sval")]) - return "unknown_column" - - # Try to handle direct values - elif hasattr(expr, "sval"): # String value - return expr.sval - elif hasattr(expr, "ival"): # Integer value - return str(expr.ival) - elif hasattr(expr, "fval"): # Float value - return expr.fval - - # Fallback for other expression types - return str(expr) - except Exception as e: - raise ValueError("Error converting expression to string") from e - - def _is_same_index(self, index1: dict[str, Any], index2: dict[str, Any]) -> bool: - """Check if two indexes are functionally equivalent.""" - if not index1 or not index2: - return False - - # Same table? - if index1["table"] != index2["table"]: - return False - - # Same index type? - if index1["type"] != index2["type"]: - return False - - # Same columns (order matters for most index types)? - if index1["columns"] != index2["columns"]: - # For hash indexes, order doesn't matter - if index1["type"] == "hash" and set(index1["columns"]) == set(index2["columns"]): - return True - return False - - # If one is unique and the other is not, they're different - # Except when a primary key (which is unique) exists and we're considering a non-unique index on same column - if index1["unique"] and not index2["unique"]: - return False - - # Same core definition - return True - - -class ConditionColumnCollector(ColumnCollector): - """ - A specialized version of ColumnCollector that only collects columns used in - WHERE, JOIN, HAVING conditions, and properly resolves column aliases. - """ - - def __init__(self) -> None: - super().__init__() - self.condition_columns = {} # Specifically for columns in conditions - self.in_condition = False # Flag to track if we're inside a condition - - def __call__(self, node): - super().__call__(node) - return self.condition_columns - - def visit_SelectStmt(self, ancestors: list[Node], node: Node) -> None: # noqa: N802 - """ - Visit a SelectStmt node but focus on condition-related clauses, - while still collecting column aliases. - """ - if isinstance(node, SelectStmt): - self.inside_select = True - self.current_query_level += 1 - query_level = self.current_query_level - - # Get table aliases first - alias_visitor = TableAliasVisitor() - if hasattr(node, "fromClause") and node.fromClause: - for from_item in node.fromClause: - alias_visitor(from_item) - tables = alias_visitor.tables - aliases = alias_visitor.aliases - - # Store the context for this query - self.context_stack.append((tables, aliases)) - - # First pass: collect column aliases from targetList - if hasattr(node, "targetList") and node.targetList: - self.target_list = node.targetList - for target_entry in self.target_list: - if hasattr(target_entry, "name") and target_entry.name: - # This is a column alias - col_alias = target_entry.name - # Store the expression node for this alias - if hasattr(target_entry, "val"): - self.column_aliases[col_alias] = { - "node": target_entry.val, - "level": query_level, - } - - # Process WHERE clause - if node.whereClause: - in_condition_cache = self.in_condition - self.in_condition = True - self(node.whereClause) - self.in_condition = in_condition_cache - - # Process JOIN conditions in fromClause - if node.fromClause: - for item in node.fromClause: - if isinstance(item, JoinExpr) and item.quals: - in_condition_cache = self.in_condition - self.in_condition = True - self(item.quals) - self.in_condition = in_condition_cache - - # Process HAVING clause - may reference aliases - if node.havingClause: - in_condition_cache = self.in_condition - self.in_condition = True - self._process_having_with_aliases(node.havingClause) - self.in_condition = in_condition_cache - - # Process ORDER BY clause - also important for indexes - if hasattr(node, "sortClause") and node.sortClause: - in_condition_cache = self.in_condition - self.in_condition = True - for sort_item in node.sortClause: - self._process_node_with_aliases(sort_item.node) - self.in_condition = in_condition_cache - - # # Process GROUP BY clause - can also benefit from indexes - # if hasattr(node, "groupClause") and node.groupClause: - # in_condition_cache = self.in_condition - # self.in_condition = True - # for group_item in node.groupClause: - # self._process_node_with_aliases(group_item) - # self.in_condition = in_condition_cache - - # Clean up the context stack - self.context_stack.pop() - self.inside_select = False - self.current_query_level -= 1 - - def _process_having_with_aliases(self, having_clause): - """Process HAVING clause with special handling for column aliases.""" - self._process_node_with_aliases(having_clause) - - def _process_node_with_aliases(self, node): - """Process a node, resolving any column aliases it contains.""" - if node is None: - return - - # If node is a column reference, it might be an alias - if isinstance(node, ColumnRef) and hasattr(node, "fields") and node.fields: - fields = [f.sval for f in node.fields if hasattr(f, "sval")] if node.fields else [] - if len(fields) == 1: - col_name = fields[0] - # Check if this is a known alias - if col_name in self.column_aliases: - # Process the original expression instead - alias_info = self.column_aliases[col_name] - if alias_info["level"] == self.current_query_level: - self(alias_info["node"]) - return - - # For non-alias nodes, process normally - self(node) - - def visit_ColumnRef(self, ancestors: list[Node], node: Node) -> None: # noqa: N802 - """ - Process column references, but only if we're in a condition context. - Skip known column aliases but process their underlying expressions. - """ - if not self.in_condition: - return # Skip if not in a condition context - - if not isinstance(node, ColumnRef) or not self.context_stack: - return - - # Get the current query context - tables, aliases = self.context_stack[-1] - - # Extract table and column names - fields = [f.sval for f in node.fields if hasattr(f, "sval")] if node.fields else [] - - # Check if this is a reference to a column alias - if len(fields) == 1 and fields[0] in self.column_aliases: - # Process the original expression node instead - alias_info = self.column_aliases[fields[0]] - if alias_info["level"] == self.current_query_level: - self.in_condition = True # Ensure we collect from the aliased expression - self(alias_info["node"]) - return - - if len(fields) == 2: # Table.column format - table_or_alias, column = fields - # Resolve alias to actual table - table = aliases.get(table_or_alias, table_or_alias) - - # Add to condition columns - if table not in self.condition_columns: - self.condition_columns[table] = set() - self.condition_columns[table].add(column) - - elif len(fields) == 1: # Unqualified column - column = fields[0] - - # For unqualified columns, check all tables in context - found_match = False - for table in tables: - # Skip schema qualification if present - if "." in table: - _, table = table.split(".", 1) - - # Add column to all tables that have it - if self._column_exists(table, column): - if table not in self.condition_columns: - self.condition_columns[table] = set() - self.condition_columns[table].add(column) - found_match = True - - if not found_match: - logger.debug(f"Could not resolve unqualified column '{column}' to any table") - - def _column_exists(self, table: str, column: str) -> bool: - """Check if column exists in table.""" - # TODO - # This would normally query the database - # For now, we'll return True to collect all possible matches - # The actual filtering will happen later - return True diff --git a/src/postgres_mcp/index/index_opt_base.py b/src/postgres_mcp/index/index_opt_base.py deleted file mode 100644 index 7ff00971..00000000 --- a/src/postgres_mcp/index/index_opt_base.py +++ /dev/null @@ -1,671 +0,0 @@ -import json -import logging -import time -from abc import ABC -from abc import abstractmethod -from dataclasses import dataclass -from dataclasses import field -from typing import Any -from typing import Iterable - -from pglast import parse_sql -from pglast.ast import SelectStmt - -from ..artifacts import calculate_improvement_multiple -from ..explain import ExplainPlanTool -from ..sql import IndexDefinition -from ..sql import SafeSqlDriver -from ..sql import SqlBindParams -from ..sql import SqlDriver -from ..sql import TableAliasVisitor -from ..sql import check_hypopg_installation_status - -logger = logging.getLogger(__name__) - -MAX_NUM_INDEX_TUNING_QUERIES = 10 - - -def pp_list(lst: list[Any]) -> str: - """Pretty print a list for debugging.""" - return ("\n - " if len(lst) > 0 else "") + "\n - ".join([str(item) for item in lst]) - - -@dataclass -class IndexRecommendation: - """Represents a database index with size estimation and definition.""" - - _definition: IndexDefinition - estimated_size_bytes: int = 0 - potential_problematic_reason: str | None = None - - def __init__( - self, - table: str, - columns: tuple[str, ...], - using: str = "btree", - estimated_size_bytes: int = 0, - potential_problematic_reason: str | None = None, - ): - self._definition = IndexDefinition(table, columns, using) - self.estimated_size_bytes = estimated_size_bytes - self.potential_problematic_reason = potential_problematic_reason - - @property - def index_definition(self) -> IndexDefinition: - return self._definition - - @property - def definition(self) -> str: - return self._definition.definition - - @property - def name(self) -> str: - return self._definition.name - - @property - def columns(self) -> tuple[str, ...]: - return self._definition.columns - - @property - def table(self) -> str: - return self._definition.table - - @property - def using(self) -> str: - return self._definition.using - - def __hash__(self) -> int: - return self._definition.__hash__() - - def __eq__(self, other: Any) -> bool: - return self._definition.__eq__(other.index_config) - - def __str__(self) -> str: - return self._definition.__str__() + f" (estimated_size_bytes: {self.estimated_size_bytes})" - - def __repr__(self) -> str: - return self._definition.__repr__() + f" (estimated_size_bytes: {self.estimated_size_bytes})" - - -@dataclass -class IndexRecommendationAnalysis: - """Represents a recommended index with benefit estimation.""" - - index_recommendation: IndexRecommendation - - progressive_base_cost: float - progressive_recommendation_cost: float - individual_base_cost: float - individual_recommendation_cost: float - queries: list[str] - definition: str - - @property - def table(self) -> str: - return self.index_recommendation.table - - @property - def columns(self) -> tuple[str, ...]: - return self.index_recommendation.columns - - @property - def using(self) -> str: - return self.index_recommendation.using - - @property - def progressive_improvement_multiple(self) -> float: - """Calculate the progressive percentage improvement from this recommendation.""" - return calculate_improvement_multiple(self.progressive_base_cost, self.progressive_recommendation_cost) - - @property - def potential_problematic_reason(self) -> str | None: - return self.index_recommendation.potential_problematic_reason - - @property - def estimated_size_bytes(self) -> int: - return self.index_recommendation.estimated_size_bytes - - @property - def individual_improvement_multiple(self) -> float: - """Calculate the individual percentage improvement from this recommendation.""" - return calculate_improvement_multiple(self.individual_base_cost, self.individual_recommendation_cost) - - def to_index(self) -> IndexRecommendation: - return self.index_recommendation - - -@dataclass -class IndexTuningResult: - """Results of index tuning analysis.""" - - # Session ID for tracing - session_id: str - - # Input parameters - budget_mb: int # Tuning budget in MB - workload_source: str = "n/a" # 'args', 'query_list', 'query_store', 'sql_file' - workload: list[dict[str, Any]] | None = None - - # Output results - recommendations: list[IndexRecommendationAnalysis] = field(default_factory=list) - error: str | None = None - dta_traces: list[str] = field(default_factory=list) - - -def candidate_str(indexes: Iterable[IndexDefinition] | Iterable[IndexRecommendation] | Iterable[IndexRecommendationAnalysis]) -> str: - return ", ".join(f"{idx.table}({','.join(idx.columns)})" for idx in indexes) if indexes else "(no indexes)" - - -class IndexTuningBase(ABC): - def __init__( - self, - sql_driver: SqlDriver, - ): - """ - :param sql_driver: Database access - """ - self.sql_driver = sql_driver - - # Add memoization caches - self.cost_cache: dict[frozenset[IndexDefinition], float] = {} - self._size_estimate_cache: dict[tuple[str, frozenset[str]], int] = {} - self._table_size_cache = {} - self._estimate_table_size_cache = {} - self._explain_plans_cache = {} - self._sql_bind_params = SqlBindParams(self.sql_driver) - - # Add trace accumulator - self._dta_traces: list[str] = [] - - async def analyze_workload( - self, - workload: list[dict[str, Any]] | None = None, - sql_file: str | None = None, - query_list: list[str] | None = None, - min_calls: int = 50, - min_avg_time_ms: float = 5.0, - limit: int = MAX_NUM_INDEX_TUNING_QUERIES, - max_index_size_mb: int = -1, - ) -> IndexTuningResult: - """ - Analyze query workload and recommend indexes. - - This method can analyze workload from three different sources (in order of priority): - 1. Explicit workload passed as a parameter - 2. Direct list of SQL queries passed as query_list - 3. SQL file with queries - 4. Query statistics from pg_stat_statements - - Args: - workload: Optional explicit workload data - sql_file: Optional path to a file containing SQL queries - query_list: Optional list of SQL query strings to analyze - min_calls: Minimum number of calls for a query to be considered (for pg_stat_statements) - min_avg_time_ms: Minimum average execution time in ms (for pg_stat_statements) - limit: Maximum number of queries to analyze (for pg_stat_statements) - max_index_size_mb: Maximum total size of recommended indexes in MB - - Returns: - IndexTuningResult with analysis results - """ - session_id = str(int(time.time())) - self._analysis_start_time = time.time() - self._dta_traces = [] # Reset traces at start of analysis - - # Clear the cache at the beginning of each analysis - self._size_estimate_cache = {} - - if max_index_size_mb > 0: - self.budget_mb = max_index_size_mb - - session = IndexTuningResult( - session_id=session_id, - budget_mb=max_index_size_mb, - ) - - try: - # Run pre-checks - precheck_result = await self._run_prechecks(session) - if precheck_result: - return precheck_result - - # First try to use explicit workload if provided - if workload: - logger.debug(f"Using explicit workload with {len(workload)} queries") - session.workload_source = "args" - session.workload = workload - # Then try direct query list if provided - elif query_list: - logger.debug(f"Using provided query list with {len(query_list)} queries") - session.workload_source = "query_list" - session.workload = [] - for i, query in enumerate(query_list): - # Create a synthetic workload entry for each query - session.workload.append( - { - "query": query, - "queryid": f"direct-{i}", - } - ) - - # Then try SQL file if provided - elif sql_file: - logger.debug(f"Reading queries from file: {sql_file}") - session.workload_source = "sql_file" - session.workload = self._get_workload_from_file(sql_file) - - # Finally fall back to query stats - else: - logger.debug("Using query statistics from the database") - session.workload_source = "query_store" - session.workload = await self._get_query_stats(min_calls, min_avg_time_ms, limit) - - if not session.workload: - logger.warning("No workload to analyze") - return session - - session.workload = await self._validate_and_parse_workload(session.workload) - - query_weights = self._covert_workload_to_query_weights(session.workload) - - if query_weights is None or len(query_weights) == 0: - self.dta_trace("No query provided") - session.recommendations = [] - else: - # Gather queries as strings - workload_queries = [q for q, _, _ in query_weights] - - self.dta_trace(f"Workload queries ({len(workload_queries)}): {pp_list(workload_queries)}") - - # Generate and evaluate index recommendations - recommendations: tuple[set[IndexRecommendation], float] = await self._generate_recommendations(query_weights) - session.recommendations = await self._format_recommendations(query_weights, recommendations) - - # Reset HypoPG only once at the end - await self.sql_driver.execute_query("SELECT hypopg_reset();") - - except Exception as e: - logger.error(f"Error in workload analysis: {e}", exc_info=True) - session.error = f"Error in workload analysis: {e}" - - session.dta_traces = self._dta_traces - return session - - async def _run_prechecks(self, session: IndexTuningResult) -> IndexTuningResult | None: - """ - Run pre-checks before analysis and return a session with error if any check fails. - - Args: - session: The current DTASession object - - Returns: - The DTASession with error information if any check fails, None if all checks pass - """ - # Pre-check 1: Check HypoPG with more granular feedback - # Use our new utility function to check HypoPG status - is_hypopg_installed, hypopg_message = await check_hypopg_installation_status(self.sql_driver) - - # If hypopg is not installed or not available, add error to session - if not is_hypopg_installed: - session.error = hypopg_message - return session - - # Pre-check 2: Check if ANALYZE has been run at least once - result = await self.sql_driver.execute_query("SELECT s.last_analyze FROM pg_stat_user_tables s ORDER BY s.last_analyze LIMIT 1;") - if not result or not any(row.cells.get("last_analyze") is not None for row in result): - error_message = ( - "Statistics are not up-to-date. The database needs to be analyzed first. " - "Please run 'ANALYZE;' on your database before using the tuning advisor. " - "Without up-to-date statistics, the index recommendations may be inaccurate." - ) - session.error = error_message - logger.error(error_message) - return session - - # All checks passed - return None - - async def _validate_and_parse_workload(self, workload: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Validate the workload to ensure it is analyzable.""" - validated_workload = [] - for q in workload: - query_text = q["query"] - if not query_text: - logger.debug("Skipping empty query") - continue - query_text = query_text.strip().lower() - - # Replace parameter placeholders with dummy values - query_text = await self._sql_bind_params.replace_parameters(query_text) - - parsed = parse_sql(query_text) - if not parsed: - logger.debug(f"Skipping non-parseable query: {query_text[:50]}...") - continue - stmt = parsed[0].stmt - if not self._is_analyzable_stmt(stmt): - logger.debug(f"Skipping non-analyzable query: {query_text[:50]}...") - continue - - q["query"] = query_text - q["stmt"] = stmt - validated_workload.append(q) - return validated_workload - - def _covert_workload_to_query_weights(self, workload: list[dict[str, Any]]) -> list[tuple[str, SelectStmt, float]]: - """Convert workload to query weights based on query frequency.""" - return [(q["query"], q["stmt"], self.convert_query_info_to_weight(q)) for q in workload] - - def convert_query_info_to_weight(self, query_info: dict[str, Any]) -> float: - """Convert query info to weight based on query frequency.""" - return query_info.get("calls", 1.0) * query_info.get("avg_exec_time", 1.0) - - async def get_explain_plan_with_indexes(self, query_text: str, indexes: frozenset[IndexDefinition]) -> dict[str, Any]: - """ - Get the explain plan for a query with a specific set of indexes. - Results are memoized to avoid redundant explain operations. - - Args: - query_text: The SQL query to explain - indexes: A frozenset of IndexConfig objects representing the indexes to enable - - Returns: - The explain plan as a dictionary - """ - # Create a cache key from the query and indexes - cache_key = (query_text, indexes) - - # Return cached result if available - existing_plan = self._explain_plans_cache.get(cache_key) - if existing_plan: - return existing_plan - - # Generate the plan using the static method - explain_plan_tool = ExplainPlanTool(self.sql_driver) - plan = await explain_plan_tool.generate_explain_plan_with_hypothetical_indexes(query_text, indexes, False, self) - - # Cache the result - self._explain_plans_cache[cache_key] = plan - return plan - - def _get_workload_from_file(self, file_path: str) -> list[dict[str, Any]]: - """Load queries from an SQL file.""" - try: - with open(file_path) as f: - content = f.read() - - # Split the file content by semicolons to get individual queries - query_texts = [q.strip() for q in content.split(";") if q.strip()] - queries = [] - - for i, text in enumerate(query_texts): - queries.append( - { - "queryid": i, - "query": text, - } - ) - - return queries - except Exception as e: - raise ValueError(f"Error loading queries from file {file_path}") from e - - async def _get_query_stats(self, min_calls: int, min_avg_time_ms: float, limit: int) -> list[dict[str, Any]]: - """Get query statistics from pg_stat_statements""" - - # Reference to original implementation - return await self._get_query_stats_direct(min_calls, min_avg_time_ms, limit) - - async def _get_query_stats_direct(self, min_calls: int = 50, min_avg_time_ms: float = 5.0, limit: int = 100) -> list[dict[str, Any]]: - """Direct implementation of query stats collection.""" - query = """ - SELECT queryid, query, calls, total_exec_time/calls as avg_exec_time - FROM pg_stat_statements - WHERE calls >= {} - AND total_exec_time/calls >= {} - ORDER BY total_exec_time DESC - LIMIT {} - """ - result = await SafeSqlDriver.execute_param_query( - self.sql_driver, - query, - [min_calls, min_avg_time_ms, limit], - ) - return [dict(row.cells) for row in result] if result else [] - - def _is_analyzable_stmt(self, stmt: Any) -> bool: - """Check if a statement can be analyzed for index recommendations.""" - # It should be a SelectStmt - if not isinstance(stmt, SelectStmt): - return False - - visitor = TableAliasVisitor() - visitor(stmt) - - # Skip queries that only access system tables - if all(table.startswith("pg_") or table.startswith("aurora_") for table in visitor.tables): - return False - return True - - def dta_trace(self, message: Any, exc_info: bool = False): - """Convenience function to log DTA thinking process.""" - - # Always log to debug - if exc_info: - logger.debug(message, exc_info=True) - else: - logger.debug(message) - - self._dta_traces.append(message) - - async def _evaluate_configuration_cost( - self, - weighted_workload: list[tuple[str, SelectStmt, float]], - indexes: frozenset[IndexDefinition], - ) -> float: - """Evaluate total cost with selective enabling and caching.""" - # Use indexes as cache key - if indexes in self.cost_cache: - self.dta_trace(f" - Using cached cost for configuration: {candidate_str(indexes)}") - return self.cost_cache[indexes] - - self.dta_trace(f" - Evaluating cost for configuration: {candidate_str(indexes)}") - - total_cost = 0.0 - valid_queries = 0 - - try: - # Calculate cost for all queries with this configuration - for query_text, _stmt, weight in weighted_workload: - try: - # Get the explain plan using our memoized helper - plan_data = await self.get_explain_plan_with_indexes(query_text, indexes) - - # Extract cost from the plan data - cost = self.extract_cost_from_json_plan(plan_data) - total_cost += cost * weight - valid_queries += 1 - except Exception as e: - raise ValueError(f"Error executing explain for query: {query_text}") from e - - if valid_queries == 0: - self.dta_trace(" + no valid queries found for cost evaluation") - return float("inf") - - avg_cost = total_cost / valid_queries - self.cost_cache[indexes] = avg_cost - self.dta_trace(f" + config cost: {avg_cost:.2f} (from {valid_queries} queries)") - return avg_cost - - except Exception as e: - self.dta_trace(f" + error evaluating configuration: {e}") - raise ValueError("Error evaluating configuration") from e - - async def _estimate_index_size(self, table: str, columns: list[str]) -> int: - # Create a hashable key for the cache - cache_key = (table, frozenset(columns)) - - # Check if we already have a cached result - if cache_key in self._size_estimate_cache: - return self._size_estimate_cache[cache_key] - - try: - # Use parameterized query instead of f-string for security - stats_query = """ - SELECT COALESCE(SUM(avg_width), 0) AS total_width, - COALESCE(SUM(n_distinct), 0) AS total_distinct - FROM pg_stats - WHERE tablename = {} AND attname = ANY({}) - """ - result = await SafeSqlDriver.execute_param_query( - self.sql_driver, - stats_query, - [table, columns], - ) - if result and result[0].cells: - size_estimate = self._estimate_index_size_internal(dict(result[0].cells)) - - # Cache the result - self._size_estimate_cache[cache_key] = size_estimate - return size_estimate - return 0 - except Exception as e: - raise ValueError("Error estimating index size") from e - - def _estimate_index_size_internal(self, stats: dict[str, Any]) -> int: - width = (stats["total_width"] or 0) + 8 # 8 bytes for the heap TID - ndistinct = stats["total_distinct"] or 1.0 - ndistinct = ndistinct if ndistinct > 0 else 1.0 - # simplistic formula - size_estimate = int(width * ndistinct * 2.0) - return size_estimate - - async def _format_recommendations( - self, query_weights: list[tuple[str, SelectStmt, float]], best_config: tuple[set[IndexRecommendation], float] - ) -> list[IndexRecommendationAnalysis]: - """Format recommendations into a list of IndexRecommendation objects.""" - # build final recommendations from best_config - recommendations: list[IndexRecommendationAnalysis] = [] - total_size = 0 - budget_bytes = self.budget_mb * 1024 * 1024 - individual_base_cost = await self._evaluate_configuration_cost(query_weights, frozenset()) or 1.0 - progressive_base_cost = individual_base_cost - indexes_so_far: list[IndexRecommendation] = [] - for index_config in best_config[0]: - indexes_so_far.append(index_config) - # Calculate the cost with only this index - progressive_cost = await self._evaluate_configuration_cost( - query_weights, - frozenset(idx.index_definition for idx in indexes_so_far), # Indexes so far - ) - individual_cost = await self._evaluate_configuration_cost( - query_weights, - frozenset([index_config.index_definition]), # Only this index - ) - - size = await self._estimate_index_size(index_config.table, list(index_config.columns)) - if budget_bytes < 0 or total_size + size <= budget_bytes: - self.dta_trace(f"Adding index: {candidate_str([index_config])}") - rec = IndexRecommendationAnalysis( - index_recommendation=IndexRecommendation( - table=index_config.table, - columns=index_config.columns, - using=index_config.using, - potential_problematic_reason=index_config.potential_problematic_reason, - estimated_size_bytes=size, - ), - progressive_base_cost=progressive_base_cost, - progressive_recommendation_cost=progressive_cost, - individual_base_cost=individual_base_cost, - individual_recommendation_cost=individual_cost, - queries=[q for q, _, _ in query_weights], - definition=index_config.definition, - ) - progressive_base_cost = progressive_cost - recommendations.append(rec) - total_size += size - else: - self.dta_trace(f"Skipping index: {candidate_str([index_config])} because it exceeds budget") - - return recommendations - - @staticmethod - def extract_cost_from_json_plan(plan_data: dict[str, Any]) -> float: - """Extract total cost from JSON EXPLAIN plan data.""" - try: - if not plan_data: - return float("inf") - - # Parse JSON plan - top_plan = plan_data.get("Plan") - if not top_plan: - logger.error("No top plan found in plan data: %s", plan_data) - return float("inf") - - # Extract total cost from top plan - total_cost = top_plan.get("Total Cost") - if total_cost is None: - logger.error("Total Cost not found in top plan: %s", top_plan) - return float("inf") - - return float(total_cost) - except (IndexError, KeyError, ValueError, json.JSONDecodeError) as e: - raise ValueError("Error extracting cost from plan") from e - - async def _get_table_size(self, table: str) -> int: - """ - Get the total size of a table including indexes and toast tables. - Uses memoization to avoid repeated database queries. - - Args: - table: The name of the table - - Returns: - Size of the table in bytes - """ - # Check if we have a cached result - if table in self._table_size_cache: - return self._table_size_cache[table] - - # Try to get table size from the database using proper quoting - try: - # Use the proper way to calculate table size with quoted identifiers - query = "SELECT pg_total_relation_size(quote_ident({})) as rel_size" - result = await SafeSqlDriver.execute_param_query(self.sql_driver, query, [table]) - - if result and len(result) > 0 and len(result[0].cells) > 0: - size = int(result[0].cells["rel_size"]) - # Cache the result - self._table_size_cache[table] = size - return size - else: - # If query fails, use our estimation method - size = await self._estimate_table_size(table) - self._table_size_cache[table] = size - return size - except Exception as e: - logger.warning(f"Error getting table size for {table}: {e}") - # Use estimation method - size = await self._estimate_table_size(table) - self._table_size_cache[table] = size - return size - - async def _estimate_table_size(self, table: str) -> int: - """Estimate the size of a table if we can't get it from the database.""" - try: - # Try a simple query to get row count and then estimate size - result = await SafeSqlDriver.execute_param_query(self.sql_driver, "SELECT count(*) as row_count FROM {}", [table]) - if result and len(result) > 0 and len(result[0].cells) > 0: - row_count = int(result[0].cells["row_count"]) - # Rough estimate: assume 1KB per row - return row_count * 1024 - except Exception as e: - logger.warning(f"Error estimating table size for {table}: {e}") - - # Default size if we can't estimate - return 10 * 1024 * 1024 # 10MB default - - @abstractmethod - async def _generate_recommendations(self, query_weights: list[tuple[str, SelectStmt, float]]) -> tuple[set[IndexRecommendation], float]: - """Generate index tuning queries.""" - pass diff --git a/src/postgres_mcp/index/llm_opt.py b/src/postgres_mcp/index/llm_opt.py deleted file mode 100644 index 651f6f02..00000000 --- a/src/postgres_mcp/index/llm_opt.py +++ /dev/null @@ -1,384 +0,0 @@ -import logging -import math -from dataclasses import dataclass -from typing import Any -from typing import override - -import instructor -from openai import OpenAI -from pglast.ast import SelectStmt -from pydantic import BaseModel - -from postgres_mcp.artifacts import ErrorResult -from postgres_mcp.explain.explain_plan import ExplainPlanTool -from postgres_mcp.sql import TableAliasVisitor - -from ..sql import IndexDefinition -from ..sql import SqlDriver -from .index_opt_base import IndexRecommendation -from .index_opt_base import IndexTuningBase - -logger = logging.getLogger(__name__) - - -# We introduce a Pydantic index class to facilitate communication with the LLM -# via the instructor library. -class Index(BaseModel): - table_name: str - columns: tuple[str, ...] - - def __hash__(self): - return hash((self.table_name, self.columns)) - - def __eq__(self, other): - if not isinstance(other, Index): - return False - return self.table_name == other.table_name and self.columns == other.columns - - def to_index_recommendation(self) -> IndexRecommendation: - return IndexRecommendation(table=self.table_name, columns=self.columns) - - def to_index_definition(self) -> IndexDefinition: - return IndexDefinition(table=self.table_name, columns=self.columns) - - -class IndexingAlternative(BaseModel): - alternatives: list[set[Index]] - - -@dataclass -class ScoredIndexes: - indexes: set[Index] - execution_cost: float - index_size: float - objective_score: float - - -class LLMOptimizerTool(IndexTuningBase): - def __init__( - self, - sql_driver: SqlDriver, - max_no_progress_attempts: int = 5, - pareto_alpha: float = 2.0, - ): - super().__init__(sql_driver) - self.sql_driver = sql_driver - self.max_no_progress_attempts = max_no_progress_attempts - self.pareto_alpha = pareto_alpha - logger.info("Initialized LLMOptimizerTool with max_no_progress_attempts=%d", max_no_progress_attempts) - - def score(self, execution_cost: float, index_size: float) -> float: - return math.log(execution_cost) + self.pareto_alpha * math.log(index_size) - - @override - async def _generate_recommendations(self, query_weights: list[tuple[str, SelectStmt, float]]) -> tuple[set[IndexRecommendation], float]: - """Generate index tuning queries using optimization by LLM.""" - # For now we support only one table at a time - if len(query_weights) > 1: - logger.error("LLM optimization currently supports only one query at a time") - raise ValueError("Optimization by LLM supports only one query at a time.") - - query = query_weights[0][0] - parsed_query = query_weights[0][1] - logger.info("Generating index recommendations for query: %s", query) - - # Extract tables from the parsed query - table_visitor = TableAliasVisitor() - table_visitor(parsed_query) - tables = table_visitor.tables - logger.info("Extracted tables from query: %s", tables) - - # Get the size of the tables - table_sizes = {} - for table in tables: - table_sizes[table] = await self._get_table_size(table) - total_table_size = sum(table_sizes.values()) - logger.info("Total table size: %s", total_table_size) - - # Generate explain plan for the query - explain_tool = ExplainPlanTool(self.sql_driver) - explain_result = await explain_tool.explain(query) - if isinstance(explain_result, ErrorResult): - logger.error("Failed to generate explain plan: %s", explain_result.to_text()) - raise ValueError(f"Failed to generate explain plan: {explain_result.to_text()}") - - # Get the explain plan JSON - explain_plan_json = explain_result.value - logger.debug("Generated explain plan: %s", explain_plan_json) - - # Extract indexes used in the explain plan - indexes_used: set[Index] = await self._extract_indexes_from_explain_plan_with_columns(explain_plan_json) - - # Get the current cost - original_cost = await self._evaluate_configuration_cost(query_weights, frozenset()) - logger.info("Original query cost: %f", original_cost) - - original_config = ScoredIndexes( - indexes=indexes_used, - execution_cost=original_cost, - index_size=total_table_size, - objective_score=self.score(original_cost, total_table_size), - ) - - best_config = original_config - - # Initialize attempt history for this run - attempt_history: list[ScoredIndexes] = [original_config] - - no_progress_count = 0 - client = instructor.from_openai(OpenAI()) - - # Starting cost - # TODO should include the size of the starting indexes - score = self.score(original_cost, total_table_size) - logger.info("Starting score: %f", score) - - while no_progress_count < self.max_no_progress_attempts: - logger.info("Requesting index recommendations from LLM") - - # Build history of past attempts - history_prompt = "" - if attempt_history: - history_prompt = "\nPrevious attempts and their costs:\n" - for attempt in attempt_history: - indexes_str = ";".join(idx.to_index_definition().definition for idx in attempt.indexes) - history_prompt += f"- Indexes: {indexes_str}, Cost: {attempt.execution_cost}, Index Size: {attempt.index_size}, " - history_prompt += f"Objective Score: {attempt.objective_score}\n" - - if no_progress_count > 0: - remaining_attempts_prompt = f"You have made {no_progress_count} attempts without progress. " - if self.max_no_progress_attempts - no_progress_count < self.max_no_progress_attempts / 2: - remaining_attempts_prompt += "Get creative and suggest indexes that are not obvious." - else: - remaining_attempts_prompt = "" - - response = client.chat.completions.create( - model="gpt-4o", - response_model=IndexingAlternative, - temperature=1.2, - messages=[ - {"role": "system", "content": "You are a helpful assistant that generates index recommendations for a given workload."}, - { - "role": "user", - "content": f"Here is the query we are optimizing: {query}\n" - f"Here is the explain plan: {explain_plan_json}\n" - f"Here are the existing indexes: {';'.join(idx.to_index_definition().definition for idx in indexes_used)}\n" - f"{history_prompt}\n" - "Each indexing suggestion that you provide is a combination of indexes. You can provide multiple alternative suggestions. " - "We will evaluate each alternative using hypopg to see how the optimizer will be behave with those indexes in place. " - "The overall score is based on a combination of execution cost and index size. In all cases, lower is better. " - "Prefer fewer indexes to more indexes. Prefer indexes with fewer columns to indexes with more columns. " - f"{remaining_attempts_prompt}", - }, - ], - ) - - # Convert the response to IndexConfig objects - index_alternatives: list[set[Index]] = response.alternatives - logger.info("Received %d alternative index configurations from LLM", len(index_alternatives)) - - # If no alternatives were generated, break the loop - if not index_alternatives: - logger.warning("No index alternatives were generated by the LLM") - break - - # Try each alternative - found_improvement = False - for i, index_set in enumerate(index_alternatives): - try: - logger.info("Evaluating alternative %d/%d with %d indexes", i + 1, len(index_alternatives), len(index_set)) - # Evaluate this index configuration - execution_cost_estimate = await self._evaluate_configuration_cost( - query_weights, frozenset({index.to_index_definition() for index in index_set}) - ) - logger.info( - "Alternative %d cost: %f (reduction: %.2f%%)", - i + 1, - execution_cost_estimate, - ((best_config.execution_cost - execution_cost_estimate) / best_config.execution_cost) * 100, - ) - - # Estimate the size of the indexes - index_size_estimate = await self._estimate_index_size_2({index.to_index_definition() for index in index_set}, 1024 * 1024) - logger.info("Estimated index size: %f", index_size_estimate) - - # Score based on a balance of size and performance - score = math.log(execution_cost_estimate) + self.pareto_alpha * math.log(total_table_size + index_size_estimate) - - # Record this attempt in history - latest_config = ScoredIndexes( - indexes={Index(table_name=index.table_name, columns=index.columns) for index in index_set}, - execution_cost=execution_cost_estimate, - index_size=index_size_estimate, - objective_score=score, - ) - attempt_history.append(latest_config) - logger.info("Latest config: %s", latest_config) - - # If this is better than what we've seen so far, update our best - # Minimum 2% improvement required - if latest_config.objective_score < best_config.objective_score: - best_config = latest_config - found_improvement = True - except Exception as e: - # We discard the alternative. We are seeing this happen due to invalid index definitions. - logger.error("Error evaluating alternative %d/%d: %s", i + 1, len(index_alternatives), str(e)) - - # Keep only the 5 best results in the attempt history - attempt_history.sort(key=lambda x: x.objective_score) - attempt_history = attempt_history[:5] - - if found_improvement: - no_progress_count = 0 - else: - no_progress_count += 1 - logger.info( - "No improvement found in this iteration. Attempts without progress: %d/%d", no_progress_count, self.max_no_progress_attempts - ) - - if best_config != original_config: - logger.info( - "Selected best index configuration with %d indexes, cost reduction: %.2f%%, indexes: %s", - len(best_config.indexes), - ((original_cost - best_config.execution_cost) / original_cost) * 100, - ", ".join(f"{idx.table_name}.({','.join(idx.columns)})" for idx in best_config.indexes), - ) - else: - logger.info("No better index configuration found") - - # Convert Index objects to IndexConfig objects for return - best_index_config_set = {index.to_index_recommendation() for index in best_config.indexes} - return (best_index_config_set, best_config.execution_cost) - - async def _estimate_index_size_2(self, index_set: set[IndexDefinition], min_size_penalty: float = 1024 * 1024) -> float: - """ - Estimate the size of a set of indexes using hypopg. - - Args: - index_set: Set of IndexConfig objects representing the indexes to estimate - - Returns: - Total estimated size of all indexes in bytes - """ - if not index_set: - return 0.0 - - total_size = 0.0 - - for index_config in index_set: - try: - # Create a hypothetical index using hypopg - # Using a tuple to avoid LiteralString type error - create_index_query = ( - "WITH hypo_index AS (SELECT indexrelid FROM hypopg_create_index(%s)) " - "SELECT hypopg_relation_size(indexrelid) as size, hypopg_drop_index(indexrelid) FROM hypo_index;" - ) - - # Execute the query to get the index size - result = await self.sql_driver.execute_query(create_index_query, params=[index_config.definition]) - - if result and len(result) > 0: - # Extract the size from the result - size = result[0].cells.get("size", 0) - total_size += max(float(size), min_size_penalty) - logger.debug(f"Estimated size for index {index_config.name}: {size} bytes") - else: - logger.warning(f"Failed to estimate size for index {index_config.name}") - - except Exception as e: - logger.error(f"Error estimating size for index {index_config.name}: {e!s}") - - return total_size - - def _extract_indexes_from_explain_plan(self, explain_plan_json: Any) -> set[tuple[str, str]]: - """ - Extract indexes used in the explain plan JSON. - - Args: - explain_plan_json: The explain plan JSON from PostgreSQL - - Returns: - A set of tuples (table_name, index_name) representing the indexes used in the plan - """ - indexes_used = set() - if isinstance(explain_plan_json, dict): - plan_data = explain_plan_json.get("Plan") - if plan_data is not None: - - def extract_indexes_from_node(node): - # Check if this is an index scan node - if node.get("Node Type") in ["Index Scan", "Index Only Scan", "Bitmap Index Scan"]: - if "Index Name" in node and "Relation Name" in node: - # Add the table name and index name - indexes_used.add((node["Relation Name"], node["Index Name"])) - - # Recursively process child plans - if "Plans" in node: - for child in node["Plans"]: - extract_indexes_from_node(child) - - # Start extraction from the root plan - extract_indexes_from_node(plan_data) - logger.info("Extracted %d indexes from explain plan", len(indexes_used)) - - return indexes_used - - async def _extract_indexes_from_explain_plan_with_columns(self, explain_plan_json: Any) -> set[Index]: - """ - Extract indexes used in the explain plan JSON and populate their columns. - - Args: - explain_plan_json: The explain plan JSON from PostgreSQL - - Returns: - A set of Index objects representing the indexes used in the plan with their columns - """ - # First extract the indexes without columns - index_tuples = self._extract_indexes_from_explain_plan(explain_plan_json) - - # Now populate the columns for each index - indexes_with_columns = set() - for table_name, index_name in index_tuples: - # Get the columns for this index - columns = await self._get_index_columns(index_name) - - # Create a new Index object with the columns - index_with_columns = Index(table_name=table_name, columns=columns) - indexes_with_columns.add(index_with_columns) - - return indexes_with_columns - - async def _get_index_columns(self, index_name: str) -> tuple[str, ...]: - """ - Get the columns for a specific index by querying the database. - - Args: - index_name: The name of the index - - Returns: - A tuple of column names in the index - """ - try: - # Query to get index columns - query = """ - SELECT a.attname - FROM pg_index i - JOIN pg_class c ON c.oid = i.indexrelid - JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) - WHERE c.relname = %s - ORDER BY array_position(i.indkey, a.attnum) - """ - - result = await self.sql_driver.execute_query(query, [index_name]) - - if result and len(result) > 0: - # Extract column names from the result - columns = [row.cells.get("attname", "") for row in result if row.cells.get("attname")] - return tuple(columns) - else: - logger.warning(f"No columns found for index {index_name}") - return tuple() - - except Exception as e: - logger.error(f"Error getting columns for index {index_name}: {e!s}") - return tuple() diff --git a/src/postgres_mcp/index/presentation.py b/src/postgres_mcp/index/presentation.py deleted file mode 100644 index 5e806c2a..00000000 --- a/src/postgres_mcp/index/presentation.py +++ /dev/null @@ -1,266 +0,0 @@ -"""Database Tuning Advisor (DTA) tool for Postgres MCP.""" - -import logging -import os -from typing import Any -from typing import Dict -from typing import List - -import humanize - -from ..artifacts import ExplainPlanArtifact -from ..artifacts import calculate_improvement_multiple -from ..sql import SqlDriver -from .dta_calc import IndexTuningBase -from .index_opt_base import IndexDefinition -from .index_opt_base import IndexTuningResult - -logger = logging.getLogger(__name__) - - -class TextPresentation: - """Text-based presentation of index tuning recommendations.""" - - def __init__(self, sql_driver: SqlDriver, index_tuning: IndexTuningBase): - """ - Initialize the presentation. - - Args: - conn: The PostgreSQL connection object - """ - self.sql_driver = sql_driver - self.index_tuning = index_tuning - - async def analyze_workload(self, max_index_size_mb=10000): - """ - Analyze SQL workload and recommend indexes. - - This method analyzes queries from database query history, examining - frequently executed and costly queries to recommend the most beneficial indexes. - - Args: - max_index_size_mb: Maximum total size for recommended indexes in MB - - Returns: - Dict with recommendations or error - """ - return await self._execute_analysis( - min_calls=50, - min_avg_time_ms=5.0, - limit=100, - max_index_size_mb=max_index_size_mb, - ) - - async def analyze_queries(self, queries, max_index_size_mb=10000): - """ - Analyze a list of SQL queries and recommend indexes. - - This method examines the provided SQL queries and recommends - indexes that would improve their performance. - - Args: - queries: List of SQL queries to analyze - max_index_size_mb: Maximum total size for recommended indexes in MB - - Returns: - Dict with recommendations or error - """ - if not queries: - return {"error": "No queries provided for analysis"} - - return await self._execute_analysis( - query_list=queries, - min_calls=0, # Ignore min calls for explicit query list - min_avg_time_ms=0, # Ignore min time for explicit query list - limit=0, # Ignore limit for explicit query list - max_index_size_mb=max_index_size_mb, - ) - - async def analyze_single_query(self, query, max_index_size_mb=10000): - """ - Analyze a single SQL query and recommend indexes. - - This method examines the provided SQL query and recommends - indexes that would improve its performance. - - Args: - query: SQL query to analyze - max_index_size_mb: Maximum total size for recommended indexes in MB - - Returns: - Dict with recommendations or error - """ - return await self._execute_analysis( - query_list=[query], - min_calls=0, # Ignore min calls for explicit query - min_avg_time_ms=0, # Ignore min time for explicit query - limit=0, # Ignore limit for explicit query - max_index_size_mb=max_index_size_mb, - ) - - async def _execute_analysis( - self, - query_list=None, - min_calls=50, - min_avg_time_ms=5.0, - limit=100, - max_index_size_mb=10000, - ): - """ - Execute indexing analysis - - Returns: - Dict with recommendations or dict with error - """ - try: - # Run the index tuning analysis - session = await self.index_tuning.analyze_workload( - query_list=query_list, - min_calls=min_calls, - min_avg_time_ms=min_avg_time_ms, - limit=limit, - max_index_size_mb=max_index_size_mb, - ) - - # Prepare the response to send back to the caller - include_langfuse_trace = os.environ.get("POSTGRES_MCP_INCLUDE_LANGFUSE_TRACE", "true").lower() == "true" - langfuse_trace = {"_langfuse_trace": session.dta_traces} if include_langfuse_trace else {} - - if session.error: - return { - "error": session.error, - **langfuse_trace, - } - - if not session.recommendations: - return { - "recommendations": "No index recommendations found.", - **langfuse_trace, - } - - # Calculate overall statistics - total_size_bytes = sum(rec.estimated_size_bytes for rec in session.recommendations) - - # Calculate overall performance improvement - initial_cost = session.recommendations[0].progressive_base_cost if session.recommendations else 0 - new_cost = session.recommendations[-1].progressive_recommendation_cost if session.recommendations else 1.0 - improvement_multiple = calculate_improvement_multiple(initial_cost, new_cost) - - # Build recommendations list - recommendations = self._build_recommendations_list(session) - - # Generate query impact section using helper function - query_impact = await self._generate_query_impact(session) - - # Create the result JSON object with summary, recommendations, and query impact - return { - "summary": { - "total_recommendations": len(session.recommendations), - "base_cost": f"{initial_cost:.1f}", - "new_cost": f"{new_cost:.1f}", - "total_size_bytes": humanize.naturalsize(total_size_bytes), - "improvement_multiple": f"{improvement_multiple:.1f}", - }, - "recommendations": recommendations, - "query_impact": query_impact, - **langfuse_trace, - } - except Exception as e: - logger.error(f"Error analyzing queries: {e}", exc_info=True) - return {"error": f"Error analyzing queries: {e}"} - - def _build_recommendations_list(self, session: IndexTuningResult) -> List[Dict[str, Any]]: - recommendations = [] - for index_apply_order, rec in enumerate(session.recommendations): - rec_dict = { - "index_apply_order": index_apply_order + 1, - "index_target_table": rec.table, - "index_target_columns": rec.columns, - "benefit_of_this_index_only": { - "improvement_multiple": f"{rec.individual_improvement_multiple:.1f}", - "base_cost": f"{rec.individual_base_cost:.1f}", - "new_cost": f"{rec.individual_recommendation_cost:.1f}", - }, - "benefit_after_previous_indexes": { - "improvement_multiple": f"{rec.progressive_improvement_multiple:.1f}", - "base_cost": f"{rec.progressive_base_cost:.1f}", - "new_cost": f"{rec.progressive_recommendation_cost:.1f}", - }, - "index_estimated_size": humanize.naturalsize(rec.estimated_size_bytes), - "index_definition": rec.definition, - } - if rec.potential_problematic_reason == "long_text_column": - rec_dict["warning"] = ( - "This index is potentially problematic because it includes a long text column. " - "You might not be able to create this index if the index row size becomes too large " - "(i.e., more than 8191 bytes)." - ) - elif rec.potential_problematic_reason: - rec_dict["warning"] = f"This index is potentially problematic because it includes a {rec.potential_problematic_reason} column." - recommendations.append(rec_dict) - return recommendations - - async def _generate_query_impact(self, session: IndexTuningResult) -> List[Dict[str, Any]]: - """ - Generate the query impact section showing before/after explain plans. - - Args: - session: DTASession containing recommendations - - Returns: - List of dictionaries with query and explain plans - """ - query_impact = [] - - # Get workload queries from the first recommendation - # (All recommendations have the same queries) - if not session.recommendations: - return query_impact - - workload_queries = session.recommendations[0].queries - - # Remove duplicates while preserving order - seen = set() - unique_queries = [] - for q in workload_queries: - if q not in seen: - seen.add(q) - unique_queries.append(q) - - # Get before and after plans for each query - if unique_queries and self.index_tuning: - for query in unique_queries: - # Get plan with no indexes - before_plan = await self.index_tuning.get_explain_plan_with_indexes(query, frozenset()) - - # Get plan with all recommended indexes - index_configs = frozenset(IndexDefinition(rec.table, rec.columns, rec.using) for rec in session.recommendations) - after_plan = await self.index_tuning.get_explain_plan_with_indexes(query, index_configs) - - # Extract costs from plans - base_cost = self.index_tuning.extract_cost_from_json_plan(before_plan) - new_cost = self.index_tuning.extract_cost_from_json_plan(after_plan) - - # Calculate improvement multiple - improvement_multiple = "∞" # Default for cases where new_cost is zero - if new_cost > 0 and base_cost > 0: - improvement_multiple = f"{calculate_improvement_multiple(base_cost, new_cost):.1f}" - - before_plan_text = ExplainPlanArtifact.format_plan_summary(before_plan) - after_plan_text = ExplainPlanArtifact.format_plan_summary(after_plan) - diff_text = ExplainPlanArtifact.create_plan_diff(before_plan, after_plan) - - # Add to query impact with costs and improvement - query_impact.append( - { - "query": query, - "base_cost": f"{base_cost:.1f}", - "new_cost": f"{new_cost:.1f}", - "improvement_multiple": improvement_multiple, - "before_explain_plan": "```\n" + before_plan_text + "\n```", - "after_explain_plan": "```\n" + after_plan_text + "\n```", - "explain_plan_diff": "```\n" + diff_text + "\n```", - } - ) - - return query_impact diff --git a/src/postgres_mcp/server.py b/src/postgres_mcp/server.py index 37cccf6c..ebcd97a8 100644 --- a/src/postgres_mcp/server.py +++ b/src/postgres_mcp/server.py @@ -6,42 +6,22 @@ import signal import sys from enum import Enum -from typing import Any from typing import List -from typing import Literal from typing import Union import mcp.types as types from mcp.server.fastmcp import FastMCP from pydantic import Field -from pydantic import validate_call - -from postgres_mcp.index.dta_calc import DatabaseTuningAdvisor - -from .artifacts import ErrorResult -from .artifacts import ExplainPlanArtifact -from .database_health import DatabaseHealthTool -from .database_health import HealthType -from .explain import ExplainPlanTool -from .index.index_opt_base import MAX_NUM_INDEX_TUNING_QUERIES -from .index.llm_opt import LLMOptimizerTool -from .index.presentation import TextPresentation + from .sql import ConnectionRegistry -from .sql import DbConnPool from .sql import SafeSqlDriver from .sql import SqlDriver -from .sql import check_hypopg_installation_status from .sql import obfuscate_password -from .top_queries import TopQueriesCalc # Initialize FastMCP with default settings # Note: Server instructions will be updated after database connections are discovered mcp = FastMCP("postgres-mcp") -# Constants -PG_STAT_STATEMENTS = "pg_stat_statements" -HYPOPG_EXTENSION = "hypopg" - ResponseType = List[types.TextContent | types.ImageContent | types.EmbeddedResource] logger = logging.getLogger(__name__) @@ -325,87 +305,6 @@ async def get_object_details( return format_error_response(str(e)) -@mcp.tool(description="Explains the execution plan for a SQL query, showing how the database will execute it and provides detailed cost estimates.") -async def explain_query( - conn_name: str = Field(description="Connection name (e.g., 'default', 'app', 'etl')"), - sql: str = Field(description="SQL query to explain"), - analyze: bool = Field( - description="When True, actually runs the query to show real execution statistics instead of estimates. " - "Takes longer but provides more accurate information.", - default=False, - ), - hypothetical_indexes: list[dict[str, Any]] = Field( - description="""A list of hypothetical indexes to simulate. Each index must be a dictionary with these keys: - - 'table': The table name to add the index to (e.g., 'users') - - 'columns': List of column names to include in the index (e.g., ['email'] or ['last_name', 'first_name']) - - 'using': Optional index method (default: 'btree', other options include 'hash', 'gist', etc.) - -Examples: [ - {"table": "users", "columns": ["email"], "using": "btree"}, - {"table": "orders", "columns": ["user_id", "created_at"]} -] -If there is no hypothetical index, you can pass an empty list.""", - default=[], - ), -) -> ResponseType: - """ - Explains the execution plan for a SQL query. - - Args: - conn_name: Connection name to use - sql: The SQL query to explain - analyze: When True, actually runs the query for real statistics - hypothetical_indexes: Optional list of indexes to simulate - """ - try: - sql_driver = await get_sql_driver(conn_name) - explain_tool = ExplainPlanTool(sql_driver=sql_driver) - result: ExplainPlanArtifact | ErrorResult | None = None - - # If hypothetical indexes are specified, check for HypoPG extension - if hypothetical_indexes and len(hypothetical_indexes) > 0: - if analyze: - return format_error_response("Cannot use analyze and hypothetical indexes together") - try: - # Use the common utility function to check if hypopg is installed - ( - is_hypopg_installed, - hypopg_message, - ) = await check_hypopg_installation_status(sql_driver) - - # If hypopg is not installed, return the message - if not is_hypopg_installed: - return format_text_response(hypopg_message) - - # HypoPG is installed, proceed with explaining with hypothetical indexes - result = await explain_tool.explain_with_hypothetical_indexes(sql, hypothetical_indexes) - except Exception: - raise # Re-raise the original exception - elif analyze: - try: - # Use EXPLAIN ANALYZE - result = await explain_tool.explain_analyze(sql) - except Exception: - raise # Re-raise the original exception - else: - try: - # Use basic EXPLAIN - result = await explain_tool.explain(sql) - except Exception: - raise # Re-raise the original exception - - if result and isinstance(result, ExplainPlanArtifact): - return format_text_response(result.to_text()) - else: - error_message = "Error processing explain plan" - if isinstance(result, ErrorResult): - error_message = result.to_text() - return format_error_response(error_message) - except Exception as e: - logger.error(f"Error explaining query: {e}") - return format_error_response(str(e)) - - # Query function declaration without the decorator - we'll add it dynamically based on access mode async def execute_sql( conn_name: str = Field(description="Connection name (e.g., 'default', 'app', 'etl')"), @@ -423,118 +322,6 @@ async def execute_sql( return format_error_response(str(e)) -@mcp.tool(description="Analyze frequently executed queries in the database and recommend optimal indexes") -@validate_call -async def analyze_workload_indexes( - conn_name: str = Field(description="Connection name (e.g., 'default', 'app', 'etl')"), - max_index_size_mb: int = Field(description="Max index size in MB", default=10000), - method: Literal["dta", "llm"] = Field(description="Method to use for analysis", default="dta"), -) -> ResponseType: - """Analyze frequently executed queries in the database and recommend optimal indexes.""" - try: - sql_driver = await get_sql_driver(conn_name) - if method == "dta": - index_tuning = DatabaseTuningAdvisor(sql_driver) - else: - index_tuning = LLMOptimizerTool(sql_driver) - dta_tool = TextPresentation(sql_driver, index_tuning) - result = await dta_tool.analyze_workload(max_index_size_mb=max_index_size_mb) - return format_text_response(result) - except Exception as e: - logger.error(f"Error analyzing workload: {e}") - return format_error_response(str(e)) - - -@mcp.tool(description="Analyze a list of (up to 10) SQL queries and recommend optimal indexes") -@validate_call -async def analyze_query_indexes( - conn_name: str = Field(description="Connection name (e.g., 'default', 'app', 'etl')"), - queries: list[str] = Field(description="List of Query strings to analyze"), - max_index_size_mb: int = Field(description="Max index size in MB", default=10000), - method: Literal["dta", "llm"] = Field(description="Method to use for analysis", default="dta"), -) -> ResponseType: - """Analyze a list of SQL queries and recommend optimal indexes.""" - if len(queries) == 0: - return format_error_response("Please provide a non-empty list of queries to analyze.") - if len(queries) > MAX_NUM_INDEX_TUNING_QUERIES: - return format_error_response(f"Please provide a list of up to {MAX_NUM_INDEX_TUNING_QUERIES} queries to analyze.") - - try: - sql_driver = await get_sql_driver(conn_name) - if method == "dta": - index_tuning = DatabaseTuningAdvisor(sql_driver) - else: - index_tuning = LLMOptimizerTool(sql_driver) - dta_tool = TextPresentation(sql_driver, index_tuning) - result = await dta_tool.analyze_queries(queries=queries, max_index_size_mb=max_index_size_mb) - return format_text_response(result) - except Exception as e: - logger.error(f"Error analyzing queries: {e}") - return format_error_response(str(e)) - - -@mcp.tool( - description="Analyzes database health. Here are the available health checks:\n" - "- index - checks for invalid, duplicate, and bloated indexes\n" - "- connection - checks the number of connection and their utilization\n" - "- vacuum - checks vacuum health for transaction id wraparound\n" - "- sequence - checks sequences at risk of exceeding their maximum value\n" - "- replication - checks replication health including lag and slots\n" - "- buffer - checks for buffer cache hit rates for indexes and tables\n" - "- constraint - checks for invalid constraints\n" - "- all - runs all checks\n" - "You can optionally specify a single health check or a comma-separated list of health checks. The default is 'all' checks." -) -async def analyze_db_health( - conn_name: str = Field(description="Connection name (e.g., 'default', 'app', 'etl')"), - health_type: str = Field( - description=f"Optional. Valid values are: {', '.join(sorted([t.value for t in HealthType]))}.", - default="all", - ), -) -> ResponseType: - """Analyze database health for specified components. - - Args: - conn_name: Connection name to use - health_type: Comma-separated list of health check types to perform. - Valid values: index, connection, vacuum, sequence, replication, buffer, constraint, all - """ - health_tool = DatabaseHealthTool(await get_sql_driver(conn_name)) - result = await health_tool.health(health_type=health_type) - return format_text_response(result) - - -@mcp.tool( - name="get_top_queries", - description=f"Reports the slowest or most resource-intensive queries using data from the '{PG_STAT_STATEMENTS}' extension.", -) -async def get_top_queries( - conn_name: str = Field(description="Connection name (e.g., 'default', 'app', 'etl')"), - sort_by: str = Field( - description="Ranking criteria: 'total_time' for total execution time or 'mean_time' for mean execution time per call, or 'resources' " - "for resource-intensive queries", - default="resources", - ), - limit: int = Field(description="Number of queries to return when ranking based on mean_time or total_time", default=10), -) -> ResponseType: - try: - sql_driver = await get_sql_driver(conn_name) - top_queries_tool = TopQueriesCalc(sql_driver=sql_driver) - - if sort_by == "resources": - result = await top_queries_tool.get_top_resource_queries() - return format_text_response(result) - elif sort_by == "mean_time" or sort_by == "total_time": - # Map the sort_by values to what get_top_queries_by_time expects - result = await top_queries_tool.get_top_queries_by_time(limit=limit, sort_by="mean" if sort_by == "mean_time" else "total") - else: - return format_error_response("Invalid sort criteria. Please use 'resources' or 'mean_time' or 'total_time'.") - return format_text_response(result) - except Exception as e: - logger.error(f"Error getting slow queries: {e}") - return format_error_response(str(e)) - - async def main(): # Parse command line arguments parser = argparse.ArgumentParser(description="PostgreSQL MCP Server") diff --git a/src/postgres_mcp/top_queries/__init__.py b/src/postgres_mcp/top_queries/__init__.py deleted file mode 100644 index c24ebec6..00000000 --- a/src/postgres_mcp/top_queries/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .top_queries_calc import PG_STAT_STATEMENTS -from .top_queries_calc import TopQueriesCalc - -__all__ = ["PG_STAT_STATEMENTS", "TopQueriesCalc"] diff --git a/src/postgres_mcp/top_queries/top_queries_calc.py b/src/postgres_mcp/top_queries/top_queries_calc.py deleted file mode 100644 index 87191073..00000000 --- a/src/postgres_mcp/top_queries/top_queries_calc.py +++ /dev/null @@ -1,211 +0,0 @@ -import logging -from typing import Literal -from typing import LiteralString -from typing import Union -from typing import cast - -from ..sql import SafeSqlDriver -from ..sql import SqlDriver -from ..sql.extension_utils import check_extension -from ..sql.extension_utils import get_postgres_version - -logger = logging.getLogger(__name__) - -PG_STAT_STATEMENTS = "pg_stat_statements" - -install_pg_stat_statements_message = ( - "The pg_stat_statements extension is required to " - "report slow queries, but it is not currently " - "installed.\n\n" - "You can install it by running: " - "`CREATE EXTENSION pg_stat_statements;`\n\n" - "**What does it do?** It records statistics (like " - "execution time, number of calls, rows returned) for " - "every query executed against the database.\n\n" - "**Is it safe?** Installing 'pg_stat_statements' is " - "generally safe and a standard practice for performance " - "monitoring. It adds overhead by tracking statistics, " - "but this is usually negligible unless under extreme load." -) - - -class TopQueriesCalc: - """Tool for retrieving the slowest SQL queries.""" - - def __init__(self, sql_driver: Union[SqlDriver, SafeSqlDriver]): - self.sql_driver = sql_driver - - async def get_top_queries_by_time(self, limit: int = 10, sort_by: Literal["total", "mean"] = "mean") -> str: - """Reports the slowest SQL queries based on execution time. - - Args: - limit: Number of slow queries to return - sort_by: Sort criteria - 'total' for total execution time or - 'mean' for mean execution time per call (default) - - Returns: - A string with the top queries or installation instructions - """ - try: - logger.debug(f"Getting top queries by time. limit={limit}, sort_by={sort_by}") - extension_status = await check_extension( - self.sql_driver, - PG_STAT_STATEMENTS, - include_messages=False, - ) - - if not extension_status.is_installed: - logger.warning(f"Extension {PG_STAT_STATEMENTS} is not installed") - # Return installation instructions if the extension is not installed - return install_pg_stat_statements_message - - # Check PostgreSQL version to determine column names - pg_version = await get_postgres_version(self.sql_driver) - logger.debug(f"PostgreSQL version: {pg_version}") - - # Column names changed in PostgreSQL 13 - if pg_version >= 13: - # PostgreSQL 13 and newer - total_time_col = "total_exec_time" - mean_time_col = "mean_exec_time" - else: - # PostgreSQL 12 and older - total_time_col = "total_time" - mean_time_col = "mean_time" - - logger.debug(f"Using time columns: total={total_time_col}, mean={mean_time_col}") - - # Determine which column to sort by based on sort_by parameter and version - order_by_column = total_time_col if sort_by == "total" else mean_time_col - - query = f""" - SELECT - query, - calls, - {total_time_col}, - {mean_time_col}, - rows - FROM pg_stat_statements - ORDER BY {order_by_column} DESC - LIMIT {{}}; - """ - logger.debug(f"Executing query: {query}") - slow_query_rows = await SafeSqlDriver.execute_param_query( - self.sql_driver, - query, - [limit], - ) - slow_queries = [row.cells for row in slow_query_rows] if slow_query_rows else [] - logger.info(f"Found {len(slow_queries)} slow queries") - - # Create result description based on sort criteria - if sort_by == "total": - criteria = "total execution time" - else: - criteria = "mean execution time per call" - - result = f"Top {len(slow_queries)} slowest queries by {criteria}:\n" - result += str(slow_queries) - return result - except Exception as e: - logger.error(f"Error getting slow queries: {e}", exc_info=True) - return f"Error getting slow queries: {e}" - - async def get_top_resource_queries(self, frac_threshold: float = 0.05) -> str: - """Reports the most time consuming queries based on a resource blend. - - Args: - frac_threshold: Fraction threshold for filtering queries (default: 0.05) - - Returns: - A string with the resource-heavy queries or error message - """ - - try: - logger.debug(f"Getting top resource queries with threshold {frac_threshold}") - extension_status = await check_extension( - self.sql_driver, - PG_STAT_STATEMENTS, - include_messages=False, - ) - - if not extension_status.is_installed: - logger.warning(f"Extension {PG_STAT_STATEMENTS} is not installed") - # Return installation instructions if the extension is not installed - return install_pg_stat_statements_message - - # Check PostgreSQL version to determine column names - pg_version = await get_postgres_version(self.sql_driver) - logger.debug(f"PostgreSQL version: {pg_version}") - - # Column names changed in PostgreSQL 13 - if pg_version >= 13: - # PostgreSQL 13 and newer - total_time_col = "total_exec_time" - mean_time_col = "mean_exec_time" - else: - # PostgreSQL 12 and older - total_time_col = "total_time" - mean_time_col = "mean_time" - - query = cast( - LiteralString, - f""" - WITH resource_fractions AS ( - SELECT - query, - calls, - rows, - {total_time_col} total_exec_time, - {mean_time_col} mean_exec_time, - stddev_exec_time, - shared_blks_hit, - shared_blks_read, - shared_blks_dirtied, - wal_bytes, - total_exec_time / SUM(total_exec_time) OVER () AS total_exec_time_frac, - (shared_blks_hit + shared_blks_read) / SUM(shared_blks_hit + shared_blks_read) OVER () AS shared_blks_accessed_frac, - shared_blks_read / SUM(shared_blks_read) OVER () AS shared_blks_read_frac, - shared_blks_dirtied / SUM(shared_blks_dirtied) OVER () AS shared_blks_dirtied_frac, - wal_bytes / SUM(wal_bytes) OVER () AS total_wal_bytes_frac - FROM pg_stat_statements - ) - SELECT - query, - calls, - rows, - total_exec_time, - mean_exec_time, - stddev_exec_time, - total_exec_time_frac, - shared_blks_accessed_frac, - shared_blks_read_frac, - shared_blks_dirtied_frac, - total_wal_bytes_frac, - shared_blks_hit, - shared_blks_read, - shared_blks_dirtied, - wal_bytes - FROM resource_fractions - WHERE - total_exec_time_frac > {frac_threshold} - OR shared_blks_accessed_frac > {frac_threshold} - OR shared_blks_read_frac > {frac_threshold} - OR shared_blks_dirtied_frac > {frac_threshold} - OR total_wal_bytes_frac > {frac_threshold} - ORDER BY total_exec_time DESC - """, - ) - - logger.debug(f"Executing query: {query}") - slow_query_rows = await SafeSqlDriver.execute_param_query( - self.sql_driver, - query, - ) - resource_queries = [row.cells for row in slow_query_rows] if slow_query_rows else [] - logger.info(f"Found {len(resource_queries)} resource-intensive queries") - - return str(resource_queries) - except Exception as e: - logger.error(f"Error getting resource-intensive queries: {e}", exc_info=True) - return f"Error resource-intensive queries: {e}" diff --git a/tests/integration/dta/test_dta_calc_integration.py b/tests/integration/dta/test_dta_calc_integration.py deleted file mode 100644 index 3f6c9174..00000000 --- a/tests/integration/dta/test_dta_calc_integration.py +++ /dev/null @@ -1,1417 +0,0 @@ -import logging -import os -import time -from functools import wraps - -import pytest -import pytest_asyncio - -from postgres_mcp.index.dta_calc import DatabaseTuningAdvisor -from postgres_mcp.index.index_opt_base import IndexTuningResult -from postgres_mcp.sql import DbConnPool -from postgres_mcp.sql import SqlDriver - -logger = logging.getLogger(__name__) - - -def retry(max_attempts=3, delay=1): - """Retry decorator with specified max attempts and delay between retries.""" - - def decorator(func): - @wraps(func) - async def wrapper(*args, **kwargs): - last_exception = None - for attempt in range(max_attempts): - try: - return await func(*args, **kwargs) - except AssertionError as e: - last_exception = e - logger.warning(f"Assertion failed on attempt {attempt + 1}/{max_attempts}: {e}") - if attempt < max_attempts - 1: - logger.info(f"Retrying in {delay} seconds...") - time.sleep(delay) - # If we get here, all attempts failed - logger.error(f"All {max_attempts} attempts failed") - if last_exception: - raise last_exception - - return wrapper - - return decorator - - -@pytest_asyncio.fixture -async def db_connection(test_postgres_connection_string): - """Create a connection to the test database.""" - connection_string, version = test_postgres_connection_string - logger.info(f"Using connection string: {connection_string}") - logger.info(f"Using version: {version}") - driver = SqlDriver(engine_url=connection_string) - - # Verify connection - result = await driver.execute_query("SELECT 1") - assert result is not None - - # Create pg_stat_statements extension if needed - try: - await driver.execute_query("CREATE EXTENSION IF NOT EXISTS pg_stat_statements", force_readonly=False) - except Exception as e: - logger.warning(f"Could not create pg_stat_statements extension: {e}") - pytest.skip("pg_stat_statements extension is not available") - - # Try to create hypopg extension, but skip the test if not available - try: - await driver.execute_query("CREATE EXTENSION IF NOT EXISTS hypopg", force_readonly=False) - except Exception as e: - logger.warning(f"Could not create hypopg extension: {e}") - pytest.skip("hypopg extension is not available - required for DTA tests") - - yield driver - - # Clean up connection after test - if isinstance(driver.conn, DbConnPool): - await driver.conn.close() - - -@pytest_asyncio.fixture -async def setup_test_tables(db_connection): - """Set up test tables with sample data.""" - # Create users table - await db_connection.execute_query( - """ - DROP TABLE IF EXISTS orders; - DROP TABLE IF EXISTS users; - - CREATE TABLE users ( - id SERIAL PRIMARY KEY, - name VARCHAR(100), - email VARCHAR(100), - status VARCHAR(20), - age INTEGER, - created_at TIMESTAMP DEFAULT NOW() - ) - """, - force_readonly=False, - ) - - # Create orders table with foreign key - await db_connection.execute_query( - """ - CREATE TABLE orders ( - id SERIAL PRIMARY KEY, - user_id INTEGER REFERENCES users(id), - order_date TIMESTAMP DEFAULT NOW(), - amount DECIMAL(10, 2), - status VARCHAR(20) - ) - """, - force_readonly=False, - ) - - # Insert sample data - users - await db_connection.execute_query( - """ - INSERT INTO users (id, name, email, status, age) - SELECT - i, - 'User ' || i, - 'user' || i || '@example.com', - CASE WHEN i % 3 = 0 THEN 'active' WHEN i % 3 = 1 THEN 'inactive' ELSE 'pending' END, - 20 + (i % 50) - FROM generate_series(1, 10000) i - """, - force_readonly=False, - ) - - # Insert sample data - orders with DETERMINISTIC values, not random - await db_connection.execute_query( - """ - INSERT INTO orders (id, user_id, order_date, amount, status) - SELECT - i, - 1 + ((i-1) % 10000), -- Deterministic user_id mapping - CURRENT_DATE - ((i % 365) || ' days')::interval, -- Deterministic date - (i % 1000)::decimal(10,2), -- Deterministic amount - CASE WHEN i % 10 < 7 THEN 'completed' ELSE 'pending' END -- Deterministic status - FROM generate_series(1, 50000) i - """, - force_readonly=False, - ) - - # Analyze tables to update statistics - await db_connection.execute_query("ANALYZE users, orders", force_readonly=False) - - yield - - # Cleanup tables - await db_connection.execute_query("DROP TABLE IF EXISTS orders", force_readonly=False) - await db_connection.execute_query("DROP TABLE IF EXISTS users", force_readonly=False) - - -@pytest_asyncio.fixture -async def create_dta(db_connection): - """Create DatabaseTuningAdvisor instance.""" - # Reset HypoPG to clean state - await db_connection.execute_query("SELECT hypopg_reset()", force_readonly=False) - - # Create DTA with reasonable settings for testing - dta = DatabaseTuningAdvisor( - sql_driver=db_connection, - budget_mb=100, - max_runtime_seconds=120, - max_index_width=3, - ) - - return dta - - -@pytest.mark.asyncio -@retry(max_attempts=3, delay=2) # Add retry decorator for flaky test -async def test_join_order_benchmark(db_connection, setup_test_tables, create_dta): - """Test DTA performance on JOIN ORDER BENCHMARK (JOB) style queries.""" - dta = create_dta - - try: - # Set up JOB-like schema (simplified movie database) - await db_connection.execute_query( - """ - DROP TABLE IF EXISTS movie_cast; - DROP TABLE IF EXISTS movie_companies; - DROP TABLE IF EXISTS movie_genres; - DROP TABLE IF EXISTS movies; - DROP TABLE IF EXISTS actors; - DROP TABLE IF EXISTS companies; - DROP TABLE IF EXISTS genres; - - CREATE TABLE movies ( - id SERIAL PRIMARY KEY, - title VARCHAR(200), - year INTEGER, - rating FLOAT, - votes INTEGER - ); - - CREATE TABLE actors ( - id SERIAL PRIMARY KEY, - name VARCHAR(100), - gender CHAR(1), - birth_year INTEGER - ); - - CREATE TABLE movie_cast ( - movie_id INTEGER REFERENCES movies(id), - actor_id INTEGER REFERENCES actors(id), - role VARCHAR(100), - PRIMARY KEY (movie_id, actor_id, role) - ); - - CREATE TABLE companies ( - id SERIAL PRIMARY KEY, - name VARCHAR(100), - country VARCHAR(50) - ); - - CREATE TABLE movie_companies ( - movie_id INTEGER REFERENCES movies(id), - company_id INTEGER REFERENCES companies(id), - production_role VARCHAR(50), - PRIMARY KEY (movie_id, company_id, production_role) - ); - - CREATE TABLE genres ( - id SERIAL PRIMARY KEY, - name VARCHAR(50) - ); - - CREATE TABLE movie_genres ( - movie_id INTEGER REFERENCES movies(id), - genre_id INTEGER REFERENCES genres(id), - PRIMARY KEY (movie_id, genre_id) - ); - """, - force_readonly=False, - ) - - # Insert sample data with DETERMINISTIC values instead of random - await db_connection.execute_query( - """ - -- Insert genres - INSERT INTO genres (id, name) VALUES - (1, 'Action'), (2, 'Comedy'), (3, 'Drama'), (4, 'Sci-Fi'), (5, 'Thriller'); - - -- Insert companies - INSERT INTO companies (id, name, country) VALUES - (1, 'Universal', 'USA'), - (2, 'Warner Bros', 'USA'), - (3, 'Paramount', 'USA'), - (4, 'Sony Pictures', 'USA'), - (5, 'Disney', 'USA'); - - -- Insert movies with deterministic values - INSERT INTO movies (id, title, year, rating, votes) - SELECT - i, - 'Movie Title ' || i, - (2000 + (i % 22)), - (5.0 + ((i % 10) * 0.5)), -- deterministic rating from 5.0 to 9.5 - (1000 + ((i % 100) * 1000)) -- deterministic votes - FROM generate_series(1, 10000) i; - - -- Insert actors - INSERT INTO actors (id, name, gender, birth_year) - SELECT - i, - 'Actor ' || i, - CASE WHEN i % 2 = 0 THEN 'M' ELSE 'F' END, - (1950 + (i % 50)) - FROM generate_series(1, 5000) i; - - -- Insert movie_cast - INSERT INTO movie_cast (movie_id, actor_id, role) - SELECT - movie_id, - actor_id, - 'Role ' || (movie_id % 10) - FROM ( - SELECT - movie_id, - actor_id, - ROW_NUMBER() OVER (PARTITION BY movie_id ORDER BY actor_id) as rn - FROM ( - SELECT - movies.id as movie_id, - actors.id as actor_id - FROM movies - CROSS JOIN actors - WHERE (movies.id + actors.id) % 50 = 0 - ) as movie_actors - ) as numbered_roles - WHERE rn <= 5; -- Up to 5 actors per movie - - -- Insert movie_companies - INSERT INTO movie_companies (movie_id, company_id, production_role) - SELECT - movie_id, - 1 + (movie_id % 5), -- Distribute across 5 companies - CASE (movie_id % 3) - WHEN 0 THEN 'Production' - WHEN 1 THEN 'Distribution' - ELSE 'Marketing' - END - FROM (SELECT id as movie_id FROM movies) as m; - - -- Insert movie_genres (each movie gets 1-3 genres) - INSERT INTO movie_genres (movie_id, genre_id) - SELECT DISTINCT - movie_id, - genre_id - FROM ( - SELECT - m.id as movie_id, - 1 + (m.id % 5) as genre_id - FROM movies m - UNION ALL - SELECT - m.id as movie_id, - 1 + ((m.id + 2) % 5) as genre_id - FROM movies m - WHERE m.id % 2 = 0 - UNION ALL - SELECT - m.id as movie_id, - 1 + ((m.id + 4) % 5) as genre_id - FROM movies m - WHERE m.id % 3 = 0 - ) as movie_genre_assignment; - """, - force_readonly=False, - ) - - # Force a thorough ANALYZE to ensure consistent statistics - await db_connection.execute_query( - "ANALYZE VERBOSE movies, actors, movie_cast, companies, movie_companies, genres, movie_genres", - force_readonly=False, - ) - - # Define JOB-style queries - job_queries = [ - { - "query": """ - SELECT m.title, a.name - FROM movies m - JOIN movie_cast mc ON m.id = mc.movie_id - JOIN actors a ON mc.actor_id = a.id - WHERE m.year > 2010 AND a.gender = 'F' - """, - "calls": 30, - }, - { - "query": """ - SELECT m.title, g.name, COUNT(a.id) as actor_count - FROM movies m - JOIN movie_genres mg ON m.id = mg.movie_id - JOIN genres g ON mg.genre_id = g.id - JOIN movie_cast mc ON m.id = mc.movie_id - JOIN actors a ON mc.actor_id = a.id - WHERE g.name = 'Action' AND m.rating > 7.5 - GROUP BY m.title, g.name - ORDER BY actor_count DESC - LIMIT 20 - """, - "calls": 25, - }, - { - "query": """ - SELECT c.name, COUNT(m.id) as movie_count, AVG(m.rating) as avg_rating - FROM companies c - JOIN movie_companies mc ON c.id = mc.company_id - JOIN movies m ON mc.movie_id = m.id - WHERE mc.production_role = 'Production' AND m.year BETWEEN 2005 AND 2015 - GROUP BY c.name - HAVING COUNT(m.id) > 5 - ORDER BY avg_rating DESC - """, - "calls": 20, - }, - { - "query": """ - SELECT a.name, COUNT(DISTINCT g.name) as genre_diversity - FROM actors a - JOIN movie_cast mc ON a.id = mc.actor_id - JOIN movies m ON mc.movie_id = m.id - JOIN movie_genres mg ON m.id = mg.movie_id - JOIN genres g ON mg.genre_id = g.id - WHERE a.gender = 'M' AND m.votes > 10000 - GROUP BY a.name - ORDER BY genre_diversity DESC, a.name - LIMIT 10 - """, - "calls": 15, - }, - { - "query": """ - SELECT m.year, g.name, COUNT(*) as movie_count - FROM movies m - JOIN movie_genres mg ON m.id = mg.movie_id - JOIN genres g ON mg.genre_id = g.id - WHERE m.rating > 6.0 - GROUP BY m.year, g.name - ORDER BY m.year DESC, movie_count DESC - """, - "calls": 10, - }, - ] - - # Clear pg_stat_statements - await db_connection.execute_query("SELECT pg_stat_statements_reset()", force_readonly=False) - - # Execute JOB workload multiple times to ensure stable stats - for _i in range(2): # Run twice to ensure stable statistics - for q in job_queries: - for _ in range(q["calls"]): - await db_connection.execute_query(q["query"]) - - # Write workload to temp file with a unique name based on pid to avoid collisions - sql_file_path = f"job_workload_queries_{os.getpid()}.sql" - with open(sql_file_path, "w") as f: - f.write(";\n\n".join(q["query"] for q in job_queries) + ";") - - try: - # Analyze the workload with relaxed thresholds - session = await dta.analyze_workload( - sql_file=sql_file_path, - min_calls=1, # Lower threshold to ensure queries are considered - min_avg_time_ms=0.1, # Lower threshold to ensure queries are considered - ) - - # Check that we got recommendations - assert isinstance(session, IndexTuningResult) - - # Allow test to continue with zero recommendations, but log it - if len(session.recommendations) == 0: - logger.warning("No recommendations generated, but continuing test") - pytest.skip("No recommendations generated - skipping performance tests") - return - - # Expected index patterns based on our JOB-style queries - expected_patterns = [ - ("movie_cast", "actor_id"), - ("movie_genres", "movie_id"), - ("actors", "gender"), - ] - - # Check that our recommendations cover at least one expected pattern - found_patterns = 0 - for pattern in expected_patterns: - table, column = pattern - for rec in session.recommendations: - if rec.table == table and column in rec.columns: - found_patterns += 1 - logger.info(f"Found expected index pattern: {table}.{column}") - break - - # We should find at least 1 of the expected patterns - # Relaxed assertion - just need one useful recommendation - if found_patterns == 0: - logger.warning(f"No expected patterns found. Recommendations: {[f'{r.table}.{r.columns}' for r in session.recommendations]}") - assert found_patterns >= 1, f"Found only {found_patterns} out of {len(expected_patterns)} expected index patterns" - - # Log recommendations for debugging - logger.info("\nRecommended indexes for JOB workload:") - for rec in session.recommendations: - logger.info( - f"{rec.definition} (benefit: {rec.progressive_improvement_multiple:.2f}x, size: {rec.estimated_size_bytes / 1024:.2f} KB)" - ) - - # Test performance improvement with recommended indexes - if len(session.recommendations) < 1: - pytest.skip("Not enough recommendations to test performance") - - top_recs = session.recommendations[:1] # Test top 1 recommendation - - # Measure baseline performance - baseline_times = {} - for q in job_queries: - query = q["query"] - start = time.time() - await db_connection.execute_query("EXPLAIN ANALYZE " + query, force_readonly=False) - baseline_times[query] = time.time() - start - - # Create the recommended indexes - for rec in top_recs: - index_def = rec.definition.replace("hypopg_", "") - await db_connection.execute_query(index_def, force_readonly=False) - - # Force stats update after index creation - await db_connection.execute_query("ANALYZE VERBOSE", force_readonly=False) - - # Measure performance with indexes - use multiple runs to reduce variance - indexed_times = {} - for q in job_queries: - query = q["query"] - # Run 3 times and take the average to reduce variance - times = [] - for _ in range(3): - start = time.time() - await db_connection.execute_query("EXPLAIN ANALYZE " + query, force_readonly=False) - times.append(time.time() - start) - indexed_times[query] = sum(times) / len(times) # Average time - - # Clean up created indexes - await db_connection.execute_query( - "DROP INDEX IF EXISTS " + ", ".join([r.definition.split()[2] for r in top_recs]), - force_readonly=False, - ) - - # Calculate improvement - allow small degradations - improvements = [] - for query, baseline in baseline_times.items(): - if query in indexed_times and baseline > 0: - improvement = (baseline - indexed_times[query]) / baseline * 100 - improvements.append(improvement) - - # Check that we have some improvement - if improvements: - avg_improvement = sum(improvements) / len(improvements) - logger.info(f"\nAverage performance improvement for JOB workload: {avg_improvement:.2f}%") - - # Find the best improvement - only need one query to show meaningful improvement - best_improvement = max(improvements) - logger.info(f"Best improvement: {best_improvement:.2f}%") - - # Relaxed assertion - at least one query should show some improvement - # Allow for small negative values due to measurement noise - assert best_improvement > -10, f"No performance improvement detected, best was {best_improvement:.2f}%" - else: - pytest.skip("Could not measure performance improvements for JOB workload") - - finally: - # Clean up SQL file - if os.path.exists(sql_file_path): - os.remove(sql_file_path) - - finally: - # Clean up tables in finally block to ensure cleanup even if test fails - cleanup_query = """ - DROP TABLE IF EXISTS movie_genres; - DROP TABLE IF EXISTS genres; - DROP TABLE IF EXISTS movie_companies; - DROP TABLE IF EXISTS companies; - DROP TABLE IF EXISTS movie_cast; - DROP TABLE IF EXISTS actors; - DROP TABLE IF EXISTS movies; - """ - try: - await db_connection.execute_query(cleanup_query, force_readonly=False) - except Exception as e: - logger.warning(f"Error during cleanup: {e}") - - -@pytest.mark.asyncio -@pytest.mark.skip(reason="Skipping multi-column indexes test for now") -async def test_multi_column_indexes(db_connection, setup_test_tables, create_dta): - """Test that DTA can recommend multi-column indexes when appropriate.""" - dta = create_dta - - # Create test tables designed to benefit from multi-column indexes - await db_connection.execute_query( - """ - DROP TABLE IF EXISTS sales; - DROP TABLE IF EXISTS customers; - DROP TABLE IF EXISTS products; - - CREATE TABLE customers ( - id SERIAL PRIMARY KEY, - region VARCHAR(50), - city VARCHAR(100), - age INTEGER, - income_level VARCHAR(20), - signup_date DATE - ); - - CREATE TABLE products ( - id SERIAL PRIMARY KEY, - category VARCHAR(50), - subcategory VARCHAR(50), - price DECIMAL(10,2), - availability BOOLEAN, - launch_date DATE - ); - - CREATE TABLE sales ( - id SERIAL PRIMARY KEY, - customer_id INTEGER REFERENCES customers(id), - product_id INTEGER REFERENCES products(id), - sale_date DATE, - quantity INTEGER, - total_amount DECIMAL(12,2), - payment_method VARCHAR(20), - status VARCHAR(20) - ); - """, - force_readonly=False, - ) - - # Create highly correlated data with strong patterns - await db_connection.execute_query( - """ - -- Insert customers with strong region-city correlation - INSERT INTO customers (region, city, age, income_level, signup_date) - SELECT - CASE WHEN i % 4 = 0 THEN 'North' - WHEN i % 4 = 1 THEN 'South' - WHEN i % 4 = 2 THEN 'East' - ELSE 'West' END, - -- Make city strongly correlated with region (each region has specific cities) - CASE WHEN i % 4 = 0 THEN (ARRAY['NYC', 'Boston', 'Chicago'])[1 + (i % 3)] - WHEN i % 4 = 1 THEN (ARRAY['Miami', 'Dallas', 'Houston'])[1 + (i % 3)] - WHEN i % 4 = 2 THEN (ARRAY['Philadelphia', 'DC', 'Atlanta'])[1 + (i % 3)] - ELSE (ARRAY['LA', 'Seattle', 'Portland'])[1 + (i % 3)] END, - 25 + (i % 50), - (ARRAY['Low', 'Medium', 'High', 'Premium'])[1 + (i % 4)], - CURRENT_DATE - ((i % 1000) || ' days')::INTERVAL - FROM generate_series(1, 5000) i; - - -- Insert products with strong category-subcategory-price correlation - INSERT INTO products (category, subcategory, price, availability, launch_date) - SELECT - CASE WHEN i % 5 = 0 THEN 'Electronics' - WHEN i % 5 = 1 THEN 'Clothing' - WHEN i % 5 = 2 THEN 'Home' - WHEN i % 5 = 3 THEN 'Sports' - ELSE 'Books' END, - -- Make subcategory strongly correlated with category - CASE WHEN i % 5 = 0 THEN (ARRAY['Phones', 'Computers', 'TVs'])[1 + (i % 3)] - WHEN i % 5 = 1 THEN (ARRAY['Shirts', 'Pants', 'Shoes'])[1 + (i % 3)] - WHEN i % 5 = 2 THEN (ARRAY['Kitchen', 'Bedroom', 'Living'])[1 + (i % 3)] - WHEN i % 5 = 3 THEN (ARRAY['Football', 'Basketball', 'Tennis'])[1 + (i % 3)] - ELSE (ARRAY['Fiction', 'NonFiction', 'Reference'])[1 + (i % 3)] END, - -- Make price bands correlated with category and subcategory - CASE WHEN i % 5 = 0 THEN 500 + (i % 5) * 100 -- Electronics: expensive - WHEN i % 5 = 1 THEN 50 + (i % 10) * 5 -- Clothing: mid-range - WHEN i % 5 = 2 THEN 100 + (i % 7) * 10 -- Home: varied - WHEN i % 5 = 3 THEN 30 + (i % 15) * 2 -- Sports: cheaper - ELSE 15 + (i % 20) -- Books: cheapest - END, - i % 10 != 0, -- 90% available - CURRENT_DATE - ((i % 500) || ' days')::INTERVAL - FROM generate_series(1, 1000) i; - - -- Insert sales with strong patterns conducive to multi-column indexes - INSERT INTO sales (customer_id, product_id, sale_date, quantity, total_amount, payment_method, status) - SELECT - 1 + (i % 5000), - 1 + (i % 1000), - CURRENT_DATE - ((i % 365) || ' days')::INTERVAL, - 1 + (i % 5), - (random() * 1000 + 50)::numeric(12,2), - CASE WHEN i % 50 < 25 THEN 'Credit' -- Make payment method and status correlated - WHEN i % 50 < 40 THEN 'Debit' - WHEN i % 50 < 48 THEN 'PayPal' - ELSE 'Cash' END, - CASE WHEN i % 50 < 25 THEN 'Completed' -- Status correlates with payment method - WHEN i % 50 < 40 THEN 'Pending' - WHEN i % 50 < 48 THEN 'Processing' - ELSE 'Canceled' END - FROM generate_series(1, 20000) i; - """, - force_readonly=False, - ) - - # Analyze tables to update statistics (CRUCIAL for correct index recommendations) - await db_connection.execute_query("ANALYZE customers, products, sales", force_readonly=False) - - # Clear pg_stat_statements - await db_connection.execute_query("SELECT pg_stat_statements_reset()", force_readonly=False) - - # Define queries that explicitly benefit from multi-column indexes - # Use more extreme selectivity patterns and include ORDER BY clauses - multi_column_queries = [ - { - "query": """ - SELECT * FROM customers - WHERE region = 'North' AND city = 'NYC' - ORDER BY age DESC - -- Needs (region, city) index - very selective - """, - "calls": 100, - }, - { - "query": """ - SELECT * FROM products - WHERE category = 'Electronics' AND subcategory = 'Phones' - AND price BETWEEN 500 AND 600 - ORDER BY launch_date - -- Needs (category, subcategory, price) index - """, - "calls": 120, - }, - { - "query": """ - SELECT s.*, c.region - FROM sales s - JOIN customers c ON s.customer_id = c.id - WHERE s.sale_date > CURRENT_DATE - INTERVAL '30 days' - AND s.status = 'Completed' - ORDER BY s.sale_date DESC LIMIT 100 - -- Needs (sale_date, status) index - """, - "calls": 150, - }, - { - "query": """ - SELECT s.sale_date, s.quantity, s.total_amount - FROM sales s - WHERE s.customer_id BETWEEN 100 AND 500 - AND s.product_id = 42 - ORDER BY s.sale_date - -- Needs (customer_id, product_id) index - """, - "calls": 80, - }, - { - "query": """ - SELECT COUNT(*), SUM(total_amount) - FROM sales - WHERE payment_method = 'Credit' AND status = 'Completed' - GROUP BY sale_date - HAVING COUNT(*) > 5 - -- Needs (payment_method, status) index - """, - "calls": 90, - }, - ] - - # Ensure each query executes multiple times to build statistics - for query_info in multi_column_queries: - # Run each query multiple times to make sure it appears in query stats - for _ in range(3): # Execute each query 3 times for better stats - try: - await db_connection.execute_query(query_info["query"]) - except Exception as e: - logger.warning(f"Query execution error (expected during testing): {e}") - - # Manually populate query stats in session class to ensure proper weighting - workload = [] - for i, query_info in enumerate(multi_column_queries): - workload.append( - { - "query": query_info["query"], - "queryid": 1000 + i, # Made-up queryid - "calls": query_info["calls"], - "avg_exec_time": 50.0, # Significant execution time - "min_exec_time": 10.0, - "max_exec_time": 100.0, - "mean_exec_time": 50.0, - "stddev_exec_time": 10.0, - "rows": 100, - } - ) - - # Analyze with both workload sources to maximize chances of finding patterns - session = await dta.analyze_workload( - workload=workload, # Use the manually populated workload - min_calls=1, # Lower threshold to ensure all queries are considered - min_avg_time_ms=1.0, - ) - - # Check that we got recommendations - assert isinstance(session, IndexTuningResult) - assert len(session.recommendations) > 0 - - # Expected multi-column index patterns - expected_patterns = [ - ("customers", ["region", "city"]), - ("products", ["category", "subcategory", "price"]), - ("sales", ["sale_date", "status"]), - ("sales", ["customer_id", "product_id"]), - ("sales", ["payment_method", "status"]), - ] - - # Check that our recommendations include at least some multi-column indexes - multi_column_indexes_found = 0 - multi_column_indexes_details = [] - - for rec in session.recommendations: - if len(rec.columns) >= 2: # Multi-column index - multi_column_indexes_found += 1 - multi_column_indexes_details.append(f"{rec.table}.{rec.columns}") - - # Check if it matches one of our expected patterns - for pattern in expected_patterns: - expected_table, expected_columns = pattern - - # Check if recommendation matches at least as a superset of the pattern - # (additional columns are ok) - if rec.table == expected_table and all(col in rec.columns for col in expected_columns): - logger.debug(f"Found expected multi-column index: {rec.table}.{rec.columns}") - - # Lower threshold requirement - if test environment consistently finds at least 1, that's enough for validation - assert multi_column_indexes_found >= 1, f"Found no multi-column indexes. Details: {multi_column_indexes_details}" - - # Print all recommendations for debugging - both single and multi-column - logger.debug("\nAll index recommendations:") - for rec in session.recommendations: - logger.debug(f"{rec.definition} (benefit: {rec.progressive_improvement_multiple:.2f}x, size: {rec.estimated_size_bytes / 1024:.2f} KB)") - - # Print multi-column recommendations separately - logger.debug("\nMulti-column index recommendations:") - multi_column_recs = [rec for rec in session.recommendations if len(rec.columns) >= 2] - for rec in multi_column_recs: - logger.debug(f"{rec.definition} (benefit: {rec.progressive_improvement_multiple:.2f}x, size: {rec.estimated_size_bytes / 1024:.2f} KB)") - - # Test performance improvement with recommended indexes - if not multi_column_recs: - pytest.skip("No multi-column index recommendations found to test performance") - - # Use the multi-column recommendations we found - top_recs = multi_column_recs[: min(3, len(multi_column_recs))] # Up to top 3 or all if fewer - - # Measure baseline performance - baseline_times = {} - for q in multi_column_queries: - query = q["query"] - start = time.time() - try: - await db_connection.execute_query("EXPLAIN ANALYZE " + query, force_readonly=False) - baseline_times[query] = time.time() - start - except Exception as e: - logger.warning(f"Error measuring baseline for query: {e}") - - # Create the recommended indexes - created_indexes = [] - for rec in top_recs: - try: - index_def = rec.definition.replace("hypopg_", "") - await db_connection.execute_query(index_def, force_readonly=False) - created_indexes.append(rec.definition.split()[2]) # Get index name for cleanup - except Exception as e: - logger.warning(f"Error creating index {rec.definition}: {e}") - - # Measure performance with indexes - indexed_times = {} - for q in multi_column_queries: - query = q["query"] - if query in baseline_times: # Only test queries that ran successfully initially - start = time.time() - try: - await db_connection.execute_query("EXPLAIN ANALYZE " + query, force_readonly=False) - indexed_times[query] = time.time() - start - except Exception as e: - logger.warning(f"Error measuring indexed performance: {e}") - - # Clean up created indexes - if created_indexes: - try: - await db_connection.execute_query( - "DROP INDEX IF EXISTS " + ", ".join(created_indexes), - force_readonly=False, - ) - except Exception as e: - logger.warning(f"Error dropping indexes: {e}") - - # Clean up tables - await db_connection.execute_query( - """ - DROP TABLE IF EXISTS sales; - DROP TABLE IF EXISTS products; - DROP TABLE IF EXISTS customers; - """, - force_readonly=False, - ) - - # Calculate improvement - improvements = [] - for query, baseline in baseline_times.items(): - if query in indexed_times and baseline > 0: - improvement = (baseline - indexed_times[query]) / baseline * 100 - improvements.append(improvement) - logger.debug(f"Query improvement: {improvement:.2f}%") - - # Success if we found at least one multi-column index - if multi_column_indexes_found >= 1: - logger.info(f"\nFound {multi_column_indexes_found} multi-column indexes") - - # Check if we measured performance improvements - if improvements: - avg_improvement = sum(improvements) / len(improvements) - logger.info(f"\nAverage performance improvement for multi-column indexes: {avg_improvement:.2f}%") - else: - logger.warning("Could not measure performance improvements for multi-column indexes") - - -@pytest.mark.asyncio -@retry(max_attempts=3, delay=2) # Add retry decorator for flaky test -async def test_diminishing_returns(db_connection, create_dta): - """Test that the DTA correctly implements the diminishing returns behavior.""" - dta = create_dta - - try: - # Clear pg_stat_statements - await db_connection.execute_query("SELECT pg_stat_statements_reset()", force_readonly=False) - - # Set up schema with tables designed to show diminishing returns - await db_connection.execute_query( - """ - DROP TABLE IF EXISTS large_table CASCADE; - - CREATE TABLE large_table ( - id SERIAL PRIMARY KEY, - high_cardinality_col1 INTEGER, - high_cardinality_col2 VARCHAR(100), - high_cardinality_col3 INTEGER, - medium_cardinality_col1 INTEGER, - medium_cardinality_col2 VARCHAR(50), - low_cardinality_col1 INTEGER, - low_cardinality_col2 VARCHAR(10) - ); - """, - force_readonly=False, - ) - - # Create data with specific cardinality patterns - fully deterministic - await db_connection.execute_query( - """ - -- Insert data with specific cardinality patterns - INSERT INTO large_table ( - high_cardinality_col1, high_cardinality_col2, high_cardinality_col3, - medium_cardinality_col1, medium_cardinality_col2, - low_cardinality_col1, low_cardinality_col2 - ) - SELECT - -- High cardinality columns (many distinct values) - i, -- almost unique - 'value-' || i, -- almost unique - (i * 37) % 10000, -- many distinct values - - -- Medium cardinality columns - (i % 100), -- 100 distinct values - 'category-' || (i % 50), -- 50 distinct values - - -- Low cardinality columns - (i % 5), -- 5 distinct values - (ARRAY['A', 'B', 'C'])[1 + (i % 3)] -- 3 distinct values - FROM generate_series(1, 50000) i; - """, - force_readonly=False, - ) - - # Force a thorough ANALYZE to ensure consistent statistics - await db_connection.execute_query("ANALYZE VERBOSE large_table", force_readonly=False) - - # Create queries with different index benefits - # Order them to show diminishing returns pattern - queries = [ - { - "query": """ - -- First query: benefits greatly from an index (30% improvement) - SELECT * FROM large_table - WHERE high_cardinality_col1 = 12345 - ORDER BY id LIMIT 100 - """, - "calls": 100, - "expected_improvement": 0.30, # 30% improvement - }, - { - "query": """ - -- Second query: good benefit from an index (20% improvement) - SELECT * FROM large_table - WHERE high_cardinality_col2 = 'value-9876' - ORDER BY id LIMIT 100 - """, - "calls": 80, - "expected_improvement": 0.20, # 20% improvement - }, - { - "query": """ - -- Third query: moderate benefit from an index (10% improvement) - SELECT * FROM large_table - WHERE high_cardinality_col3 BETWEEN 5000 AND 5100 - ORDER BY id LIMIT 100 - """, - "calls": 60, - "expected_improvement": 0.10, # 10% improvement - }, - { - "query": """ - -- Fourth query: small benefit from an index (5% improvement) - SELECT * FROM large_table - WHERE medium_cardinality_col1 = 42 - ORDER BY id LIMIT 100 - """, - "calls": 40, - "expected_improvement": 0.05, # 5% improvement - }, - { - "query": """ - -- Fifth query: minimal benefit from an index (2% improvement) - SELECT * FROM large_table - WHERE medium_cardinality_col2 = 'category-25' - ORDER BY id LIMIT 100 - """, - "calls": 20, - "expected_improvement": 0.02, # 2% improvement - }, - ] - - # Execute queries several times to build more stable statistics - for _ in range(2): # Run twice to ensure stable stats - for query_info in queries: - for _ in range(query_info["calls"]): - await db_connection.execute_query(query_info["query"]) - - # Set the diminishing returns threshold to 5% - dta.min_time_improvement = 0.05 - - # Set a reasonable pareto_alpha for the test - dta.pareto_alpha = 2.0 - - # Analyze the workload with 5% threshold and relaxed criteria - session_with_threshold = await dta.analyze_workload( - query_list=[q["query"] for q in queries], - min_calls=1, - min_avg_time_ms=0.1, # Use lower threshold to ensure queries are considered - ) - - # Check recommendations with 5% threshold - assert isinstance(session_with_threshold, IndexTuningResult) - - # Continue test even if no recommendations, but log warning - if len(session_with_threshold.recommendations) == 0: - logger.warning("No recommendations generated at 5% threshold, but continuing test") - pytest.skip("No recommendations generated - skipping further tests") - return - - # We expect only recommendations for the first 3-4 queries (those with >5% improvement) - # The fifth query with only 2% improvement should not get a recommendation - high_improvement_columns = [ - "high_cardinality_col1", - "high_cardinality_col2", - "high_cardinality_col3", - ] - low_improvement_columns = ["medium_cardinality_col2"] - - # Check that high improvement columns are recommended - high_improvement_recommendations = 0 - for rec in session_with_threshold.recommendations: - if any(col in rec.columns for col in high_improvement_columns): - high_improvement_recommendations += 1 - logger.info(f"Found high improvement recommendation: {rec.table}.{rec.columns}") - - # Check that low improvement columns are not recommended - low_improvement_recommendations = 0 - for rec in session_with_threshold.recommendations: - if any(col in rec.columns for col in low_improvement_columns): - low_improvement_recommendations += 1 - logger.info(f"Found unexpected low improvement recommendation: {rec.table}.{rec.columns}") - - # We should have found at least one recommendation for the high-improvement columns - assert high_improvement_recommendations > 0, "No recommendations for high-improvement columns" - - # We should have few or no recommendations for low-improvement columns - # Relaxed assertion to allow for occasional outliers - assert low_improvement_recommendations <= 1, ( - f"Found {low_improvement_recommendations} recommendations for low-improvement columns despite diminishing returns threshold" - ) - - # Now test with a lower threshold (1%) - dta.min_time_improvement = 0.01 - - # Analyze with 1% threshold - session_with_low_threshold = await dta.analyze_workload(query_list=[q["query"] for q in queries], min_calls=1, min_avg_time_ms=0.1) - - # With lower threshold, we should get more recommendations - # Allow equal in case the workload doesn't generate more recommendations - assert len(session_with_low_threshold.recommendations) >= len(session_with_threshold.recommendations), ( - f"Lower threshold ({dta.min_time_improvement}) didn't produce at least as many recommendations " - f"as higher threshold (0.05): {len(session_with_low_threshold.recommendations)} vs {len(session_with_threshold.recommendations)}" - ) - - finally: - # Clean up in finally block - try: - await db_connection.execute_query("DROP TABLE IF EXISTS large_table", force_readonly=False) - except Exception as e: - logger.warning(f"Error during cleanup: {e}") - - -@pytest.mark.asyncio -@retry(max_attempts=3, delay=2) # Add retry decorator for flaky test -async def test_pareto_optimization_basic(db_connection, create_dta): - """Basic test for Pareto optimal index selection with diminishing returns.""" - dta = create_dta - - try: - # Create a simple test table - await db_connection.execute_query( - """ - DROP TABLE IF EXISTS pareto_test CASCADE; - - CREATE TABLE pareto_test ( - id SERIAL PRIMARY KEY, - col1 INTEGER, - col2 VARCHAR(100), - col3 INTEGER, - col4 VARCHAR(100) - ); - """, - force_readonly=False, - ) - - # Insert a reasonable amount of data - await db_connection.execute_query( - """ - INSERT INTO pareto_test (col1, col2, col3, col4) - SELECT - i % 1000, - 'value-' || (i % 500), - (i * 2) % 2000, - 'text-' || (i % 100) - FROM generate_series(1, 10000) i; - """, - force_readonly=False, - ) - - # Force a thorough ANALYZE to ensure consistent statistics - await db_connection.execute_query("ANALYZE VERBOSE pareto_test", force_readonly=False) - - # Simple queries that should be easy to index - queries = [ - "SELECT * FROM pareto_test WHERE col1 = 42", - "SELECT * FROM pareto_test WHERE col2 = 'value-100'", - "SELECT * FROM pareto_test WHERE col3 = 500", - "SELECT * FROM pareto_test WHERE col4 = 'text-50'", - ] - - # Run each query multiple times to ensure statistics are captured - for _ in range(3): # Run more iterations for stability - for query in queries: - for _ in range(10): # More repetitions - await db_connection.execute_query(query) - - # Reset HypoPG to ensure clean state - await db_connection.execute_query("SELECT hypopg_reset()", force_readonly=False) - - # Try running DTA with clear settings - dta.min_time_improvement = 0.01 # Use minimal threshold - dta.pareto_alpha = 1.5 # Balanced setting - - # Run with relaxed thresholds - session = await dta.analyze_workload( - query_list=queries, - min_calls=1, - min_avg_time_ms=0.01, # Very low threshold to ensure queries are considered - ) - - # Just verify that we get some recommendations - if len(session.recommendations) == 0: - logger.warning("No recommendations produced, but continuing") - pytest.skip("No recommendations produced - skipping validation") - return - - logger.info(f"Number of recommendations: {len(session.recommendations)}") - - # Verify the recommendations include the columns we expect - expected_columns = ["col1", "col2", "col3", "col4"] - for rec in session.recommendations: - logger.info(f"Recommended index: {rec.definition}") - # At least one recommendation should be for a column we expect - has_expected_column = any(col in rec.columns for col in expected_columns) - assert has_expected_column, f"Recommendation {rec.definition} doesn't include any expected columns" - - finally: - # Clean up in finally block - try: - await db_connection.execute_query("DROP TABLE IF EXISTS pareto_test", force_readonly=False) - except Exception as e: - logger.warning(f"Error during cleanup: {e}") - - -@pytest.mark.asyncio -@pytest.mark.skip(reason="Skipping storage cost tradeoff test for now") -async def test_storage_cost_tradeoff(db_connection, create_dta): - """Test that the DTA correctly balances performance gains against storage costs.""" - dta = create_dta - - # Create a test table with columns of varying sizes - await db_connection.execute_query( - """ - DROP TABLE IF EXISTS wide_table CASCADE; - - CREATE TABLE wide_table ( - id SERIAL PRIMARY KEY, - small_col INTEGER, -- Small, fixed size - medium_col VARCHAR(100), -- Medium size - large_col VARCHAR(10000), -- Large size - huge_col TEXT, -- Potentially very large - - -- Additional columns to create realistic access patterns - user_id INTEGER, - created_at TIMESTAMP, - status VARCHAR(20) - ); - """, - force_readonly=False, - ) - - # Insert data with different column size profiles - await db_connection.execute_query( - """ - INSERT INTO wide_table ( - small_col, medium_col, large_col, huge_col, - user_id, created_at, status - ) - SELECT - i % 1000, -- Small column with moderate selectivity - 'medium-' || (i % 500), -- Medium column with good selectivity - 'large-' || repeat('x', 500 + (i % 500)), -- Large column with large size - CASE WHEN i % 100 = 0 -- Huge column that's occasionally needed - THEN repeat('y', 5000 + (i % 1000)) - ELSE NULL - END, - (i % 1000) + 1, -- user_id with moderate distribution - CURRENT_DATE - ((i % 365) || ' days')::INTERVAL, -- Even date distribution - (ARRAY['active', 'pending', 'completed', 'archived'])[1 + (i % 4)] -- Status distribution - FROM generate_series(1, 20000) i; - """, - force_readonly=False, - ) - - # Analyze table for accurate statistics - await db_connection.execute_query("ANALYZE wide_table") - - # Create queries that would benefit from indexes with varying size-to-benefit ratios - queries = [ - { - "query": """ - -- Small index, good benefit - SELECT * FROM wide_table - WHERE small_col = 42 - ORDER BY created_at DESC LIMIT 100 - """, - "calls": 100, - }, - { - "query": """ - -- Medium index, good benefit - SELECT * FROM wide_table - WHERE medium_col = 'medium-123' - ORDER BY created_at DESC LIMIT 100 - """, - "calls": 80, - }, - { - "query": """ - -- Large index, moderate benefit - SELECT * FROM wide_table - WHERE large_col LIKE 'large-xx%' - ORDER BY created_at DESC LIMIT 100 - """, - "calls": 50, - }, - { - "query": """ - -- Huge index, small benefit (rarely used) - SELECT * FROM wide_table - WHERE huge_col IS NOT NULL - ORDER BY created_at DESC LIMIT 100 - """, - "calls": 20, - }, - ] - - # Execute queries to build statistics - for query_info in queries: - for _ in range(query_info["calls"]): - await db_connection.execute_query(query_info["query"]) - - # First test with high storage sensitivity (alpha=5.0) - # This should favor small indexes with good benefit/cost ratio - dta.pareto_alpha = 5.0 # Very sensitive to storage costs - - session_storage_sensitive = await dta.analyze_workload(query_list=[q["query"] for q in queries], min_calls=1, min_avg_time_ms=1.0) - - # Check that we have recommendations - assert isinstance(session_storage_sensitive, IndexTuningResult) - assert len(session_storage_sensitive.recommendations) > 0 - - # Should prefer smaller indexes with good benefit/cost ratio - small_columns_recommended = any("small_col" in rec.columns for rec in session_storage_sensitive.recommendations) - huge_columns_recommended = any("huge_col" in rec.columns for rec in session_storage_sensitive.recommendations) - - assert small_columns_recommended, "Small column index not recommended despite good benefit/cost ratio" - assert not huge_columns_recommended, "Huge column index recommended despite poor benefit/cost ratio" - - # Now test with low storage sensitivity (alpha=0.5) - # This should include more indexes, even larger ones - dta.pareto_alpha = 0.5 # Less sensitive to storage costs - - session_performance_focused = await dta.analyze_workload(query_list=[q["query"] for q in queries], min_calls=1, min_avg_time_ms=1.0) - - # Should include more recommendations - assert len(session_performance_focused.recommendations) >= len(session_storage_sensitive.recommendations) - - # Calculate the total size of recommendations in each approach - def total_recommendation_size(recs): - return sum(rec.estimated_size_bytes for rec in recs) - - size_sensitive = total_recommendation_size(session_storage_sensitive.recommendations) - size_performance = total_recommendation_size(session_performance_focused.recommendations) - - # Performance-focused approach should use more storage - assert size_performance >= size_sensitive, "Performance-focused approach should use more storage for indexes" - - # Clean up - await db_connection.execute_query("DROP TABLE IF EXISTS wide_table", force_readonly=False) - - -@pytest.mark.asyncio -@pytest.mark.skip(reason="Skipping pareto optimal index selection test for now") -async def test_pareto_optimal_index_selection(db_connection, create_dta): - """Test that the DTA correctly implements Pareto optimal index selection.""" - dta = create_dta - - # Create a test table with characteristics that demonstrate Pareto optimality - await db_connection.execute_query( - """ - DROP TABLE IF EXISTS pareto_test CASCADE; - CREATE TABLE pareto_test ( - id SERIAL PRIMARY KEY, - col1 INTEGER, -- High improvement, small size - col2 VARCHAR(100), -- Medium improvement, medium size - col3 TEXT, -- Low improvement, large size - date_col DATE, - value NUMERIC(10,2) - ); - """, - force_readonly=False, - ) - - # Insert test data - await db_connection.execute_query( - """ - INSERT INTO pareto_test (col1, col2, col3, date_col, value) - SELECT - i % 10000, -- Many distinct values (high cardinality) - 'val-' || (i % 1000), -- Medium cardinality - CASE WHEN i % 100 = 0 THEN repeat('x', 1000) ELSE NULL END, -- Low cardinality, large size - CURRENT_DATE - ((i % 730) || ' days')::INTERVAL, -- Dates spanning 2 years - (random() * 1000)::numeric(10,2) -- Random values - FROM generate_series(1, 50000) i; - """, - force_readonly=False, - ) - - # Analyze the table - await db_connection.execute_query("ANALYZE pareto_test", force_readonly=False) - - # Create a workload with specific benefit profiles - queries = [ - # Query benefiting most from col1 index (small, high benefit) - "SELECT * FROM pareto_test WHERE col1 = 123 ORDER BY date_col", - # Query benefiting from col2 index (medium size, medium benefit) - "SELECT * FROM pareto_test WHERE col2 = 'val-456' ORDER BY date_col", - # Query benefiting from col3 index (large size, low benefit) - "SELECT * FROM pareto_test WHERE col3 IS NOT NULL ORDER BY date_col", - # Query that would benefit from a date index - "SELECT * FROM pareto_test WHERE date_col >= CURRENT_DATE - INTERVAL '30 days'", - # Query with a range scan - "SELECT * FROM pareto_test WHERE col1 BETWEEN 100 AND 200 ORDER BY date_col", - ] - - # Execute each query to build statistics - for query in queries: - for _ in range(10): # Run each query multiple times - await db_connection.execute_query(query) - - # Set pareto parameters - dta.pareto_alpha = 2.0 # Balance between performance and storage - dta.min_time_improvement = 0.05 # 5% minimum improvement threshold - - # Run DTA with the workload - session = await dta.analyze_workload(query_list=queries, min_calls=1, min_avg_time_ms=1.0) - - # Verify we got recommendations - assert isinstance(session, IndexTuningResult) - assert len(session.recommendations) > 0 - - # Log recommendations for manual inspection - logger.info("Pareto optimal recommendations:") - for i, rec in enumerate(session.recommendations): - logger.info( - f"{i + 1}. {rec.definition} - Size: {rec.estimated_size_bytes / 1024:.1f}KB, Benefit: {rec.progressive_improvement_multiple:.2f}x" - ) - - # Verify the recommendations follow Pareto principles - # 1. col1 (high benefit, small size) should be recommended - assert any("col1" in rec.columns for rec in session.recommendations), "col1 should be recommended (high benefit/size ratio)" - - # 2. The large, low-benefit index should not be recommended or be low priority - col3_recommendations = [rec for rec in session.recommendations if "col3" in rec.columns] - if col3_recommendations: - # If present, it should be lower priority (later in the list) - col3_position = min(i for i, rec in enumerate(session.recommendations) if "col3" in rec.columns) - col1_position = min(i for i, rec in enumerate(session.recommendations) if "col1" in rec.columns) - assert col3_position > col1_position, "col3 (low benefit/size ratio) should be lower priority than col1" - - # 3. Run again with different alpha values to show how preferences change - # With high alpha (storage sensitive) - dta.pareto_alpha = 5.0 - session_storage_sensitive = await dta.analyze_workload(query_list=queries, min_calls=1, min_avg_time_ms=1.0) - - # With low alpha (performance sensitive) - dta.pareto_alpha = 0.5 - session_performance_sensitive = await dta.analyze_workload(query_list=queries, min_calls=1, min_avg_time_ms=1.0) - - # Calculate total size of recommendations for each approach - storage_size = sum(rec.estimated_size_bytes for rec in session_storage_sensitive.recommendations) - performance_size = sum(rec.estimated_size_bytes for rec in session_performance_sensitive.recommendations) - - # Storage-sensitive should use less space - logger.info(f"Storage-sensitive total size: {storage_size / 1024:.1f}KB") - logger.info(f"Performance-sensitive total size: {performance_size / 1024:.1f}KB") - - assert storage_size <= performance_size, "Storage-sensitive recommendations should use less space" - - # Clean up - await db_connection.execute_query("DROP TABLE IF EXISTS pareto_test", force_readonly=False) diff --git a/tests/integration/test_top_queries_integration.py b/tests/integration/test_top_queries_integration.py deleted file mode 100644 index 9cb9c01a..00000000 --- a/tests/integration/test_top_queries_integration.py +++ /dev/null @@ -1,181 +0,0 @@ -import logging - -import pytest -import pytest_asyncio - -from postgres_mcp.sql import SqlDriver -from postgres_mcp.top_queries import PG_STAT_STATEMENTS -from postgres_mcp.top_queries import TopQueriesCalc - -logger = logging.getLogger(__name__) - - -@pytest_asyncio.fixture -async def local_sql_driver(test_postgres_connection_string): - """Create a SQL driver connected to a real PostgreSQL database.""" - connection_string, version = test_postgres_connection_string - logger.info(f"Using connection string: {connection_string}") - logger.info(f"Using version: {version}") - - driver = SqlDriver(engine_url=connection_string) - driver.connect() # This is not an async method, no await needed - try: - yield driver - finally: - # Cleanup - if hasattr(driver, "conn") and driver.conn is not None: - await driver.conn.close() - - -async def setup_test_data(sql_driver): - """Set up test data with sample queries to analyze.""" - # Ensure pg_stat_statements extension is available - try: - # Check if extension exists - rows = await sql_driver.execute_query("SELECT 1 FROM pg_available_extensions WHERE name = 'pg_stat_statements'") - - if rows and len(rows) > 0: - # Try to create extension if not already installed - try: - await sql_driver.execute_query("CREATE EXTENSION IF NOT EXISTS pg_stat_statements") - logger.info("pg_stat_statements extension created or already exists") - except Exception as e: - logger.warning(f"Unable to create pg_stat_statements extension: {e}") - pytest.skip("pg_stat_statements extension not available or cannot be created") - else: - pytest.skip("pg_stat_statements extension not available") - - # Create test tables - await sql_driver.execute_query(""" - DROP TABLE IF EXISTS test_items; - CREATE TABLE test_items ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL, - value INTEGER NOT NULL - ) - """) - - # Insert test data - await sql_driver.execute_query(""" - INSERT INTO test_items (name, value) - SELECT - 'Item ' || i, - (random() * 1000)::INTEGER - FROM generate_series(1, 1000) i - """) - - # Reset pg_stat_statements to ensure clean data - await sql_driver.execute_query("SELECT pg_stat_statements_reset()") - - # Run queries several times to ensure they're captured and have significant stats - # Query 1: Simple select (should be fast) - for _i in range(10): - await sql_driver.execute_query("SELECT COUNT(*) FROM test_items") - - # Query 2: More complex query (should be slower) - for _i in range(5): - await sql_driver.execute_query(""" - SELECT name, value - FROM test_items - WHERE value > 500 - ORDER BY value DESC - """) - - # Query 3: Very slow query - run more times to ensure it shows up - for _i in range(10): - await sql_driver.execute_query(""" - SELECT t1.name, t2.name - FROM test_items t1 - CROSS JOIN test_items t2 - WHERE t1.value > t2.value - LIMIT 100 - """) - - except Exception as e: - logger.error(f"Error setting up test data: {e}") - raise - - -async def cleanup_test_data(sql_driver): - """Clean up test data.""" - try: - await sql_driver.execute_query("DROP TABLE IF EXISTS test_items") - await sql_driver.execute_query("SELECT pg_stat_statements_reset()") - except Exception as e: - logger.warning(f"Error cleaning up test data: {e}") - - -@pytest.mark.asyncio -async def test_get_top_queries_integration(local_sql_driver): - """ - Integration test for get_top_queries with a real database. - """ - try: - await setup_test_data(local_sql_driver) - - # Verify pg_stat_statements has captured our queries - pg_stats = await local_sql_driver.execute_query("SELECT query FROM pg_stat_statements WHERE query LIKE '%CROSS JOIN%' LIMIT 1") - if not pg_stats or len(pg_stats) == 0: - pytest.skip("pg_stat_statements did not capture the CROSS JOIN query") - - # Create the TopQueriesCalc instance - calc = TopQueriesCalc(sql_driver=local_sql_driver) - - # Get top queries by total execution time - total_result = await calc.get_top_queries_by_time(limit=10, sort_by="total") - - # Get top queries by mean execution time - mean_result = await calc.get_top_queries_by_time(limit=10, sort_by="mean") - - # Basic verification - assert "slowest queries by total execution time" in total_result - assert "slowest queries by mean execution time" in mean_result - - # Log results for manual inspection - logger.info(f"Top queries by total time: {total_result}") - logger.info(f"Top queries by mean time: {mean_result}") - - # Check for our specific test queries - at least one should be found - # since we run many different queries - has_cross_join = "CROSS JOIN" in total_result - has_value_gt_500 = "value > 500" in total_result - has_count = "COUNT(*)" in total_result - - assert has_cross_join or has_value_gt_500 or has_count, "None of our test queries appeared in the results" - - finally: - await cleanup_test_data(local_sql_driver) - - -@pytest.mark.asyncio -async def test_extension_not_available(local_sql_driver): - """Test behavior when pg_stat_statements extension is not available.""" - # Create the TopQueriesCalc instance - calc = TopQueriesCalc(sql_driver=local_sql_driver) - - # Need to patch at the module level for proper mocking - with pytest.MonkeyPatch().context() as mp: - # Import the module we'll be monkeypatching - import postgres_mcp.sql.extension_utils - from postgres_mcp.sql.extension_utils import ExtensionStatus - - # Define our mock function with the correct type signature - async def mock_check(*args, **kwargs): - return ExtensionStatus( - is_installed=False, - is_available=True, - name=PG_STAT_STATEMENTS, - message="Extension not installed", - default_version=None, - ) - - # Replace the function with our mock - # We need to patch the actual function imported by TopQueriesCalc - mp.setattr(postgres_mcp.top_queries.top_queries_calc, "check_extension", mock_check) - - # Run the test - result = await calc.get_top_queries_by_time() - - # Check that we get installation instructions - assert "not currently installed" in result - assert "CREATE EXTENSION" in result diff --git a/tests/unit/database_health/test_database_health_tool.py b/tests/unit/database_health/test_database_health_tool.py deleted file mode 100644 index 0ac79bf3..00000000 --- a/tests/unit/database_health/test_database_health_tool.py +++ /dev/null @@ -1,155 +0,0 @@ -import logging - -import pytest - -from postgres_mcp.database_health import DatabaseHealthTool -from postgres_mcp.sql import SqlDriver - -logger = logging.getLogger(__name__) - - -@pytest.fixture -def local_sql_driver(test_postgres_connection_string): - connection_string, version = test_postgres_connection_string - logger.info(f"Using connection string: {connection_string}") - logger.info(f"Using version: {version}") - return SqlDriver(engine_url=connection_string) - - -async def setup_test_tables(sql_driver): - pool_wrapper = sql_driver.connect() - conn_pool = await pool_wrapper.pool_connect() - async with conn_pool.connection() as conn: - # Drop existing tables if they exist - await conn.execute("DROP TABLE IF EXISTS test_orders") - await conn.execute("DROP TABLE IF EXISTS test_customers") - await conn.execute("DROP SEQUENCE IF EXISTS test_seq") - - # Create test sequence - await conn.execute("CREATE SEQUENCE test_seq") - - # Create tables with various features to test health checks - await conn.execute( - """ - CREATE TABLE test_customers ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL, - email TEXT UNIQUE, - created_at TIMESTAMP DEFAULT NOW() - ) - """ - ) - - await conn.execute( - """ - CREATE TABLE test_orders ( - id SERIAL PRIMARY KEY, - customer_id INTEGER REFERENCES test_customers(id), - total DECIMAL NOT NULL CHECK (total >= 0), - status TEXT NOT NULL, - created_at TIMESTAMP DEFAULT NOW() - ) - """ - ) - - # Create some indexes to test index health - await conn.execute( - """ - CREATE INDEX idx_orders_customer ON test_orders(customer_id) - """ - ) - await conn.execute( - """ - CREATE INDEX idx_orders_status ON test_orders(status) - """ - ) - await conn.execute( - """ - CREATE INDEX idx_orders_created ON test_orders(created_at) - """ - ) - # Create a duplicate index to test duplicate index detection - await conn.execute( - """ - CREATE INDEX idx_orders_customer_dup ON test_orders(customer_id) - """ - ) - - # Insert some test data - await conn.execute( - """ - INSERT INTO test_customers (name, email) - SELECT - 'Customer ' || i, - 'customer' || i || '@example.com' - FROM generate_series(1, 100) i - """ - ) - - await conn.execute( - """ - INSERT INTO test_orders (customer_id, total, status) - SELECT - (random() * 99)::int + 1, -- Changed to ensure IDs are between 1 and 100 - (random() * 1000)::decimal, - CASE (random() * 2)::int - WHEN 0 THEN 'pending' - WHEN 1 THEN 'completed' - ELSE 'cancelled' - END - FROM generate_series(1, 1000) i - """ - ) - - # Run ANALYZE to update statistics - await conn.execute("ANALYZE test_customers") - await conn.execute("ANALYZE test_orders") - - -async def cleanup_test_tables(sql_driver): - pool_wrapper = sql_driver.connect() - conn_pool = await pool_wrapper.pool_connect() - try: - async with conn_pool.connection() as conn: - await conn.execute("DROP TABLE IF EXISTS test_orders") - await conn.execute("DROP TABLE IF EXISTS test_customers") - await conn.execute("DROP SEQUENCE IF EXISTS test_seq") - finally: - await conn_pool.close() - - -@pytest.mark.asyncio -async def test_database_health_all(local_sql_driver): - """Test that the database health tool runs without errors when performing all health checks. - This test only verifies that the tool executes successfully and returns results in the expected format. - It does not validate whether the health check results are correct.""" - await setup_test_tables(local_sql_driver) - try: - local_sql_driver.connect() - health_tool = DatabaseHealthTool(sql_driver=local_sql_driver) - - # Run health check with type "all" - result = await health_tool.health(health_type="all") - - # Verify the result - assert isinstance(result, str) - health_report = result - - # Check that all health components are present - assert "Invalid index check:" in health_report - assert "Duplicate index check:" in health_report - assert "Index bloat:" in health_report - assert "Unused index check:" in health_report - assert "Connection health:" in health_report - assert "Vacuum health:" in health_report - assert "Sequence health:" in health_report - assert "Replication health:" in health_report - assert "Buffer health for indexes:" in health_report - assert "Buffer health for tables:" in health_report - assert "Constraint health:" in health_report - - # Verify specific health issues we know should be detected - assert "idx_orders_customer_dup" in health_report # Should detect duplicate index - - finally: - await cleanup_test_tables(local_sql_driver) diff --git a/tests/unit/explain/test_explain_plan.py b/tests/unit/explain/test_explain_plan.py deleted file mode 100644 index 71451fa2..00000000 --- a/tests/unit/explain/test_explain_plan.py +++ /dev/null @@ -1,477 +0,0 @@ -import json -from unittest.mock import AsyncMock -from unittest.mock import MagicMock - -import pytest -import pytest_asyncio - -from postgres_mcp.artifacts import ErrorResult -from postgres_mcp.artifacts import ExplainPlanArtifact -from postgres_mcp.explain import ExplainPlanTool - - -class MockCell: - def __init__(self, data): - self.cells = data - - -@pytest_asyncio.fixture -async def mock_sql_driver(): - """Create a mock SQL driver for testing.""" - driver = MagicMock() - driver.execute_query = AsyncMock() - return driver - - -@pytest.mark.asyncio -async def test_explain_plan_tool_initialization(mock_sql_driver): - """Test initialization of ExplainPlanTool.""" - tool = ExplainPlanTool(sql_driver=mock_sql_driver) - assert tool.sql_driver == mock_sql_driver - - -@pytest.mark.asyncio -async def test_has_bind_variables(): - """Test the _has_bind_variables method.""" - tool = ExplainPlanTool(sql_driver=MagicMock()) - - # Test with bind variables - assert tool._has_bind_variables("SELECT * FROM users WHERE id = $1") is True # type: ignore - assert tool._has_bind_variables("INSERT INTO users VALUES ($1, $2)") is True # type: ignore - - # Test without bind variables - assert tool._has_bind_variables("SELECT * FROM users WHERE id = 1") is False # type: ignore - assert tool._has_bind_variables("INSERT INTO users VALUES (1, 'test')") is False # type: ignore - - -@pytest.mark.asyncio -async def test_has_like_expressions(): - """Test the _has_like_expressions method.""" - tool = ExplainPlanTool(sql_driver=MagicMock()) - - # Test with LIKE expressions - assert tool._has_like_expressions("SELECT * FROM users WHERE name LIKE '%John%'") is True # type: ignore - assert tool._has_like_expressions("SELECT * FROM users WHERE name like 'John%'") is True # type: ignore - assert tool._has_like_expressions("SELECT * FROM users WHERE UPPER(name) LIKE 'JOHN%'") is True # type: ignore - - # Test without LIKE expressions - assert tool._has_like_expressions("SELECT * FROM users WHERE name = 'John'") is False # type: ignore - assert tool._has_like_expressions("SELECT * FROM users WHERE id > 100") is False # type: ignore - - -@pytest.mark.asyncio -async def test_explain_success(mock_sql_driver): - """Test successful execution of explain.""" - # Prepare mock response - plan_data = { - "Plan": { - "Node Type": "Seq Scan", - "Relation Name": "users", - "Startup Cost": 0.00, - "Total Cost": 10.00, - "Plan Rows": 100, - "Plan Width": 20, - } - } - - mock_sql_driver.execute_query.return_value = [MockCell({"QUERY PLAN": [plan_data]})] - - tool = ExplainPlanTool(sql_driver=mock_sql_driver) - result = await tool.explain("SELECT * FROM users") - - # Verify query was called with expected parameters - mock_sql_driver.execute_query.assert_called_once() - call_args = mock_sql_driver.execute_query.call_args[0][0] - assert "EXPLAIN (FORMAT JSON) SELECT * FROM users" in call_args - - # Verify result is as expected - assert isinstance(result, ExplainPlanArtifact) - assert json.loads(result.value) == plan_data - - -@pytest.mark.asyncio -async def test_explain_with_bind_variables(mock_sql_driver): - """Test explain with bind variables.""" - # Prepare mock response for PostgreSQL version check - version_response = [MockCell({"server_version": "16.0"})] - # Prepare mock response for explain query - plan_data = { - "Plan": { - "Node Type": "Seq Scan", - "Relation Name": "users", - "Startup Cost": 0.00, - "Total Cost": 10.00, - "Plan Rows": 100, - "Plan Width": 20, - } - } - - # Set up the mock to return different responses for different queries - def side_effect(query): - if query == "SHOW server_version": - return version_response - else: - return [MockCell({"QUERY PLAN": [plan_data]})] - - mock_sql_driver.execute_query.side_effect = side_effect - - tool = ExplainPlanTool(sql_driver=mock_sql_driver) - result = await tool.explain("SELECT * FROM users WHERE id = $1") - - # Verify result is as expected - assert isinstance(result, ExplainPlanArtifact) - - # Find the EXPLAIN call in the call history - explain_call = None - for call in mock_sql_driver.execute_query.call_args_list: - if "EXPLAIN" in call[0][0]: - explain_call = call[0][0] - break - - assert explain_call is not None - assert "EXPLAIN (FORMAT JSON, GENERIC_PLAN) SELECT * FROM users WHERE id = $1" in explain_call - - -@pytest.mark.asyncio -async def test_explain_with_bind_variables_pg15(mock_sql_driver, monkeypatch): - """Test explain with bind variables on PostgreSQL < 16.""" - # Prepare mock response for PostgreSQL version check - version_response = [MockCell({"server_version": "15.4"})] - - # Prepare plan data for the replaced parameter query - plan_data = { - "Plan": { - "Node Type": "Seq Scan", - "Relation Name": "users", - "Startup Cost": 0.00, - "Total Cost": 10.00, - "Plan Rows": 100, - "Plan Width": 20, - } - } - - # Mock the SqlBindParams class - class MockSqlBindParams: - def __init__(self, sql_driver): - self.sql_driver = sql_driver - - async def replace_parameters(self, query): - return "SELECT * FROM users WHERE id = 42" # Replaced query - - # The correct import path for monkeypatching - monkeypatch.setattr("postgres_mcp.explain.explain_plan.SqlBindParams", MockSqlBindParams) - - # Set up the mock to return different responses for different queries - def side_effect(query): - if query == "SHOW server_version": - return version_response - elif "EXPLAIN" in query and "id = 42" in query: - # For the parameter-replaced EXPLAIN query, return mock results - return [MockCell({"QUERY PLAN": [plan_data]})] - return None - - mock_sql_driver.execute_query.side_effect = side_effect - - tool = ExplainPlanTool(sql_driver=mock_sql_driver) - result = await tool.explain("SELECT * FROM users WHERE id = $1") - - # We now expect a successful result with parameter replacement - if isinstance(result, ErrorResult): - print(f"Got error: {result.value}") - assert isinstance(result, ExplainPlanArtifact) - - # Verify that the version check was called - version_call = None - explain_call = None - - for call in mock_sql_driver.execute_query.call_args_list: - if "server_version" in call[0][0]: - version_call = call - elif "EXPLAIN" in call[0][0]: - explain_call = call - - assert version_call is not None - assert explain_call is not None - - # Make sure GENERIC_PLAN is NOT in the query - we should be using replaced values - assert "GENERIC_PLAN" not in explain_call[0][0] - # Verify the parameters were replaced - assert "id = 42" in explain_call[0][0] - - -@pytest.mark.asyncio -async def test_explain_analyze_with_bind_variables(mock_sql_driver, monkeypatch): - """Test explain analyze with bind variables uses parameter replacement.""" - # Prepare plan data for the replaced parameter query - plan_data = { - "Plan": { - "Node Type": "Seq Scan", - "Relation Name": "users", - "Startup Cost": 0.00, - "Total Cost": 10.00, - "Plan Rows": 100, - "Plan Width": 20, - "Actual Startup Time": 0.01, - "Actual Total Time": 1.23, - "Actual Rows": 95, - "Actual Loops": 1, - }, - "Planning Time": 0.05, - "Execution Time": 1.30, - } - - # Mock the SqlBindParams class - class MockSqlBindParams: - def __init__(self, sql_driver): - self.sql_driver = sql_driver - - async def replace_parameters(self, query): - return "SELECT * FROM users WHERE id = 42" # Replaced query - - # The correct import path for monkeypatching - monkeypatch.setattr("postgres_mcp.explain.explain_plan.SqlBindParams", MockSqlBindParams) - - # Set up the mock to return mock plan for the modified query - def side_effect(query): - if "EXPLAIN" in query and "id = 42" in query: - return [MockCell({"QUERY PLAN": [plan_data]})] - return None - - mock_sql_driver.execute_query.side_effect = side_effect - - tool = ExplainPlanTool(sql_driver=mock_sql_driver) - result = await tool.explain_analyze("SELECT * FROM users WHERE id = $1") - - # Should return successful result with replaced parameters - if isinstance(result, ErrorResult): - print(f"Got error: {result.value}") - assert isinstance(result, ExplainPlanArtifact) - - # Verify that the query was executed with ANALYZE but not GENERIC_PLAN - call_args = mock_sql_driver.execute_query.call_args[0][0] - assert "ANALYZE" in call_args - assert "GENERIC_PLAN" not in call_args - assert "id = 42" in call_args - - -@pytest.mark.asyncio -async def test_explain_analyze_success(mock_sql_driver): - """Test successful execution of explain analyze.""" - # Prepare mock response with execution statistics - plan_data = { - "Plan": { - "Node Type": "Seq Scan", - "Relation Name": "users", - "Startup Cost": 0.00, - "Total Cost": 10.00, - "Plan Rows": 100, - "Plan Width": 20, - "Actual Startup Time": 0.01, - "Actual Total Time": 1.23, - "Actual Rows": 95, - "Actual Loops": 1, - }, - "Planning Time": 0.05, - "Execution Time": 1.30, - } - - mock_sql_driver.execute_query.return_value = [MockCell({"QUERY PLAN": [plan_data]})] - - tool = ExplainPlanTool(sql_driver=mock_sql_driver) - result = await tool.explain_analyze("SELECT * FROM users") - - # Verify query was called with expected parameters - call_args = mock_sql_driver.execute_query.call_args[0][0] - assert "EXPLAIN (FORMAT JSON, ANALYZE) SELECT * FROM users" in call_args - - # Verify result is as expected - assert isinstance(result, ExplainPlanArtifact) - assert json.loads(result.value) == plan_data - - -@pytest.mark.asyncio -async def test_explain_with_error(mock_sql_driver): - """Test handling of error in explain.""" - # Configure mock to raise exception - mock_sql_driver.execute_query.side_effect = Exception("Database error") - - tool = ExplainPlanTool(sql_driver=mock_sql_driver) - result = await tool.explain("SELECT * FROM users") - - # Verify error handling - assert isinstance(result, ErrorResult) - assert "Database error" in result.value - - -@pytest.mark.asyncio -async def test_explain_with_invalid_response(mock_sql_driver): - """Test handling of invalid response format.""" - # Return invalid response format - mock_sql_driver.execute_query.return_value = [ - MockCell({"QUERY PLAN": "invalid"}) # Not a list - ] - - tool = ExplainPlanTool(sql_driver=mock_sql_driver) - result = await tool.explain("SELECT * FROM users") - - # Verify error handling - assert isinstance(result, ErrorResult) - assert "Expected list" in result.value - - -@pytest.mark.asyncio -async def test_explain_with_empty_result(mock_sql_driver): - """Test handling of empty result set.""" - # Return empty result - mock_sql_driver.execute_query.return_value = None - - tool = ExplainPlanTool(sql_driver=mock_sql_driver) - result = await tool.explain("SELECT * FROM users") - - # Verify error handling - assert isinstance(result, ErrorResult) - assert "No results" in result.value - - -@pytest.mark.asyncio -async def test_explain_with_empty_plan_data(mock_sql_driver): - """Test handling of empty plan data.""" - # Return empty plan data list - mock_sql_driver.execute_query.return_value = [MockCell({"QUERY PLAN": []})] - - tool = ExplainPlanTool(sql_driver=mock_sql_driver) - result = await tool.explain("SELECT * FROM users") - - # Verify error handling - assert isinstance(result, ErrorResult) - assert "No results" in result.value - - -@pytest.mark.asyncio -async def test_explain_with_like_and_bind_variables_pg16(mock_sql_driver, monkeypatch): - """Test explain with LIKE and bind variables on PostgreSQL 16.""" - # Prepare mock response for PostgreSQL version check - version_response = [MockCell({"server_version": "16.0"})] - - # Prepare plan data for the replaced parameter query - plan_data = { - "Plan": { - "Node Type": "Seq Scan", - "Relation Name": "users", - "Startup Cost": 0.00, - "Total Cost": 10.00, - "Plan Rows": 100, - "Plan Width": 20, - } - } - - # Mock the SqlBindParams class - class MockSqlBindParams: - def __init__(self, sql_driver): - self.sql_driver = sql_driver - - async def replace_parameters(self, query): - return "SELECT * FROM users WHERE name LIKE '%John%'" # Replaced query - - # The correct import path for monkeypatching - monkeypatch.setattr("postgres_mcp.explain.explain_plan.SqlBindParams", MockSqlBindParams) - - # Set up the mock to return different responses for different queries - def side_effect(query): - if query == "SHOW server_version": - return version_response - elif "EXPLAIN" in query and "LIKE '%John%'" in query: - # For the parameter-replaced EXPLAIN query, return mock results - return [MockCell({"QUERY PLAN": [plan_data]})] - return None - - mock_sql_driver.execute_query.side_effect = side_effect - - tool = ExplainPlanTool(sql_driver=mock_sql_driver) - result = await tool.explain("SELECT * FROM users WHERE name LIKE $1") - - # We expect a successful result with parameter replacement despite PostgreSQL 16 - if isinstance(result, ErrorResult): - print(f"Got error: {result.value}") - assert isinstance(result, ExplainPlanArtifact) - - # Verify that the version check was called - version_call = None - explain_call = None - - for call in mock_sql_driver.execute_query.call_args_list: - if "server_version" in call[0][0]: - version_call = call - elif "EXPLAIN" in call[0][0]: - explain_call = call - - assert version_call is not None - assert explain_call is not None - - # Make sure GENERIC_PLAN is NOT in the query - we should be using replaced values - assert "GENERIC_PLAN" not in explain_call[0][0] - # Verify the parameters were replaced - assert "LIKE '%John%'" in explain_call[0][0] - - -@pytest.mark.asyncio -async def test_explain_with_functional_hypothetical_indexes(mock_sql_driver): - """Test explain with functional expressions in hypothetical indexes.""" - # Prepare sample plan data with index scan - including all required fields - plan_data = { - "Plan": { - "Node Type": "Index Scan", - "Index Name": "hypothetical_idx", - "Relation Name": "title_basics", - "Startup Cost": 0.00, - "Total Cost": 100.00, - "Plan Rows": 100, - "Plan Width": 20, - } - } - - # Mock the hypopg_reset and hypopg_create_index calls - def side_effect(query): - if "hypopg_" in query or "EXPLAIN" in query: - return [MockCell({"QUERY PLAN": [plan_data]})] - return None - - mock_sql_driver.execute_query.side_effect = side_effect - - # Sample query with ILIKE and functional indexes - sql_query = """ - SELECT * FROM title_basics - WHERE primary_title ILIKE '%star%' OR original_title ILIKE '%star%' - ORDER BY start_year DESC - LIMIT 20 OFFSET 0; - """ - - # Complex functional expressions in the hypothetical indexes - hypothetical_indexes = [ - {"table": "title_basics", "columns": ["LOWER(primary_title)"]}, - {"table": "title_basics", "columns": ["LOWER(original_title)"]}, - {"table": "title_basics", "columns": ["start_year DESC"]}, - ] - - tool = ExplainPlanTool(sql_driver=mock_sql_driver) - result = await tool.explain_with_hypothetical_indexes(sql_query, hypothetical_indexes) - - # Verify the result is successful - assert not isinstance(result, ErrorResult), f"Got error: {result.value if isinstance(result, ErrorResult) else ''}" - assert isinstance(result, ExplainPlanArtifact) - - # Check that explain query was called correctly - # The important part is that the expression is properly included in the CREATE INDEX statement - # We need to ensure "LOWER(primary_title)" isn't broken up or mishandled - calls = [call[0][0] for call in mock_sql_driver.execute_query.call_args_list] - explain_calls = [call for call in calls if "EXPLAIN" in call] - - assert len(explain_calls) == 1 - explain_call = explain_calls[0] - - # Verify the hypothetical indexes are created correctly with the expressions - assert "SELECT hypopg_reset();" in explain_call - assert "hypopg_create_index" in explain_call - assert "LOWER(primary_title)" in explain_call - assert "LOWER(original_title)" in explain_call - assert "start_year DESC" in explain_call diff --git a/tests/unit/explain/test_explain_plan_real_db.py b/tests/unit/explain/test_explain_plan_real_db.py deleted file mode 100644 index 973311f7..00000000 --- a/tests/unit/explain/test_explain_plan_real_db.py +++ /dev/null @@ -1,265 +0,0 @@ -import json -import logging - -import pytest - -from postgres_mcp.artifacts import ErrorResult -from postgres_mcp.artifacts import ExplainPlanArtifact -from postgres_mcp.explain import ExplainPlanTool -from postgres_mcp.sql import SqlDriver - -logger = logging.getLogger(__name__) - - -@pytest.fixture -def local_sql_driver(test_postgres_connection_string): - connection_string, version = test_postgres_connection_string - logger.info(f"Using connection string: {connection_string}") - logger.info(f"Using version: {version}") - return SqlDriver(engine_url=connection_string) - - -async def setup_test_tables(sql_driver): - pool_wrapper = sql_driver.connect() - conn_pool = await pool_wrapper.pool_connect() - async with conn_pool.connection() as conn: - # Drop existing tables if they exist - await conn.execute("DROP TABLE IF EXISTS test_orders") - await conn.execute("DROP TABLE IF EXISTS test_customers") - - # Create tables with various features for testing explain plan - await conn.execute( - """ - CREATE TABLE test_customers ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL, - email TEXT UNIQUE, - created_at TIMESTAMP DEFAULT NOW() - ) - """ - ) - - await conn.execute( - """ - CREATE TABLE test_orders ( - id SERIAL PRIMARY KEY, - customer_id INTEGER REFERENCES test_customers(id), - total DECIMAL NOT NULL, - status TEXT NOT NULL, - created_at TIMESTAMP DEFAULT NOW() - ) - """ - ) - - # Create some indexes to test explain plans - await conn.execute( - """ - CREATE INDEX idx_orders_customer ON test_orders(customer_id) - """ - ) - await conn.execute( - """ - CREATE INDEX idx_orders_status ON test_orders(status) - """ - ) - - # Insert some test data - await conn.execute( - """ - INSERT INTO test_customers (name, email) - SELECT - 'Customer ' || i, - 'customer' || i || '@example.com' - FROM generate_series(1, 100) i - """ - ) - - await conn.execute( - """ - INSERT INTO test_orders (customer_id, total, status) - SELECT - (random() * 99)::int + 1, - (random() * 1000)::decimal, - CASE (random() * 2)::int - WHEN 0 THEN 'pending' - WHEN 1 THEN 'completed' - ELSE 'cancelled' - END - FROM generate_series(1, 1000) i - """ - ) - - # Run ANALYZE to update statistics - await conn.execute("ANALYZE test_customers") - await conn.execute("ANALYZE test_orders") - - -async def cleanup_test_tables(sql_driver): - pool_wrapper = sql_driver.connect() - conn_pool = await pool_wrapper.pool_connect() - try: - async with conn_pool.connection() as conn: - await conn.execute("DROP TABLE IF EXISTS test_orders") - await conn.execute("DROP TABLE IF EXISTS test_customers") - finally: - await conn_pool.close() - - -@pytest.mark.asyncio -async def test_explain_with_real_db(local_sql_driver): - """Test explain with a real database connection.""" - await setup_test_tables(local_sql_driver) - try: - # Create explain plan tool with real db connection - tool = ExplainPlanTool(sql_driver=local_sql_driver) - - # Test basic explain - query = "SELECT * FROM test_customers WHERE id = 1" - result = await tool.explain(query) - - # Verify the result - assert isinstance(result, ExplainPlanArtifact) - plan_data = json.loads(result.value) - assert isinstance(plan_data, dict) - assert "Plan" in plan_data - assert "Node Type" in plan_data["Plan"] - - # PostgreSQL may choose different scan types depending on statistics - # For small tables, sequential scan may be chosen over index scan - node_type = plan_data["Plan"]["Node Type"] - assert node_type in ["Index Scan", "Index Only Scan", "Seq Scan"] - finally: - await cleanup_test_tables(local_sql_driver) - - -@pytest.mark.asyncio -async def test_explain_analyze_with_real_db(local_sql_driver): - """Test explain analyze with a real database connection.""" - await setup_test_tables(local_sql_driver) - try: - # Create explain plan tool with real db connection - tool = ExplainPlanTool(sql_driver=local_sql_driver) - - # Test explain analyze - query = "SELECT * FROM test_customers WHERE id = 1" - result = await tool.explain_analyze(query) - - # Verify the result - assert isinstance(result, ExplainPlanArtifact) - plan_data = json.loads(result.value) - assert isinstance(plan_data, dict) - assert "Plan" in plan_data - - # Check for analyze-specific fields - assert "Execution Time" in plan_data - assert "Actual Rows" in plan_data["Plan"] - assert "Actual Total Time" in plan_data["Plan"] - finally: - await cleanup_test_tables(local_sql_driver) - - -@pytest.mark.asyncio -async def test_explain_join_query_with_real_db(local_sql_driver): - """Test explain with a join query.""" - await setup_test_tables(local_sql_driver) - try: - tool = ExplainPlanTool(sql_driver=local_sql_driver) - - # Test join query explain - query = """ - SELECT c.name, o.total, o.status - FROM test_customers c - JOIN test_orders o ON c.id = o.customer_id - WHERE o.status = 'completed' - """ - result = await tool.explain(query) - - # Verify the result - assert isinstance(result, ExplainPlanArtifact) - plan_data = json.loads(result.value) - assert isinstance(plan_data, dict) - assert "Plan" in plan_data - - # Verify this is a join plan - assert "Plans" in plan_data["Plan"] - finally: - await cleanup_test_tables(local_sql_driver) - - -@pytest.mark.asyncio -async def test_explain_with_bind_variables_real_db(local_sql_driver): - """Test explain with bind variables on a real database.""" - await setup_test_tables(local_sql_driver) - try: - tool = ExplainPlanTool(sql_driver=local_sql_driver) - - # Test query with bind variables - query = "SELECT * FROM test_customers WHERE id = $1" - result = await tool.explain(query) - - # Verify the result - assert isinstance(result, ExplainPlanArtifact) - plan_data = json.loads(result.value) - assert isinstance(plan_data, dict) - finally: - await cleanup_test_tables(local_sql_driver) - - -@pytest.mark.asyncio -async def test_explain_with_like_expressions_real_db(local_sql_driver): - """Test explain with LIKE expressions on a real database.""" - await setup_test_tables(local_sql_driver) - try: - tool = ExplainPlanTool(sql_driver=local_sql_driver) - - # Test query with LIKE expression - query = "SELECT * FROM test_customers WHERE name LIKE 'Customer%'" - result = await tool.explain(query) - - # Verify the result - assert isinstance(result, ExplainPlanArtifact) - plan_data = json.loads(result.value) - assert isinstance(plan_data, dict) - assert "Plan" in plan_data - # This should be a sequential scan since there's no index on name - assert plan_data["Plan"]["Node Type"] == "Seq Scan" - finally: - await cleanup_test_tables(local_sql_driver) - - -@pytest.mark.asyncio -async def test_explain_with_like_and_bind_variables_real_db(local_sql_driver): - """Test explain with both LIKE and bind variables on a real database.""" - await setup_test_tables(local_sql_driver) - try: - tool = ExplainPlanTool(sql_driver=local_sql_driver) - - # Test query with both LIKE and bind variables - query = "SELECT * FROM test_customers WHERE name LIKE $1" - result = await tool.explain(query) - - # Verify the result - assert isinstance(result, ExplainPlanArtifact) - plan_data = json.loads(result.value) - assert isinstance(plan_data, dict) - finally: - await cleanup_test_tables(local_sql_driver) - - -@pytest.mark.asyncio -async def test_explain_invalid_query_with_real_db(local_sql_driver): - """Test explain with an invalid query.""" - await setup_test_tables(local_sql_driver) - try: - tool = ExplainPlanTool(sql_driver=local_sql_driver) - - # Test invalid query - query = "SELECT * FROM nonexistent_table" - result = await tool.explain(query) - - # Verify error handling - assert isinstance(result, ErrorResult) - error_msg = result.value.lower() - assert "relation" in error_msg and "not exist" in error_msg - finally: - await cleanup_test_tables(local_sql_driver) diff --git a/tests/unit/explain/test_server.py b/tests/unit/explain/test_server.py deleted file mode 100644 index 5b4602f9..00000000 --- a/tests/unit/explain/test_server.py +++ /dev/null @@ -1,131 +0,0 @@ -import json -from unittest.mock import AsyncMock -from unittest.mock import MagicMock -from unittest.mock import patch - -import pytest -import pytest_asyncio - -import postgres_mcp.server as server - - -class MockCell: - def __init__(self, data): - self.cells = data - - -@pytest_asyncio.fixture -async def mock_db_connection(): - """Create a mock DB connection.""" - conn = MagicMock() - conn.pool_connect = AsyncMock() - conn.close = AsyncMock() - return conn - - -@pytest.mark.asyncio -async def test_server_tools_registered(): - """Test that the explain tools are properly registered in the server.""" - # Check that the explain tool is registered - assert hasattr(server, "explain_query") - - # Simply check that the tool is callable - assert callable(server.explain_query) - - -@pytest.mark.asyncio -async def test_explain_query_basic(): - """Test explain_query with basic parameters.""" - # Expected output - expected_output = {"Plan": {"Node Type": "Seq Scan"}} - - # Set up the mock responses - mock_response = MagicMock() - mock_response.text = json.dumps(expected_output) - - # Use patch to replace the actual explain_query function with our own mock - with patch.object(server, "explain_query", return_value=[mock_response]): - # Call the patched function - result = await server.explain_query(conn_name="default", sql="SELECT * FROM users") - - # Verify we get the expected result - assert isinstance(result, list) - assert len(result) == 1 - assert json.loads(result[0].text) == expected_output - - -@pytest.mark.asyncio -async def test_explain_query_analyze(): - """Test explain_query with analyze=True.""" - # Expected output with execution statistics - expected_output = { - "Plan": { - "Node Type": "Seq Scan", - "Actual Rows": 100, - "Actual Total Time": 1.23, - }, - "Execution Time": 1.30, - } - - # Set up the mock responses - mock_response = MagicMock() - mock_response.text = json.dumps(expected_output) - - # Use patch to replace the actual explain_query function with our own mock - with patch.object(server, "explain_query", return_value=[mock_response]): - # Call the patched function with analyze=True - result = await server.explain_query(conn_name="default", sql="SELECT * FROM users", analyze=True) - - # Verify we get the expected result - assert isinstance(result, list) - assert len(result) == 1 - assert json.loads(result[0].text) == expected_output - - -@pytest.mark.asyncio -async def test_explain_query_hypothetical_indexes(): - """Test explain_query with hypothetical indexes.""" - # Expected output with an index scan - expected_output = { - "Plan": { - "Node Type": "Index Scan", - "Index Name": "hypothetical_idx", - }, - } - - # Set up the mock responses - mock_response = MagicMock() - mock_response.text = json.dumps(expected_output) - - # Test data - test_sql = "SELECT * FROM users WHERE email = 'test@example.com'" - test_indexes = [{"table": "users", "columns": ["email"]}] - - # Use patch to replace the actual explain_query function with our own mock - with patch.object(server, "explain_query", return_value=[mock_response]): - # Call the patched function with hypothetical_indexes - result = await server.explain_query(conn_name="default", sql=test_sql, hypothetical_indexes=test_indexes) - - # Verify we get the expected result - assert isinstance(result, list) - assert len(result) == 1 - assert json.loads(result[0].text) == expected_output - - -@pytest.mark.asyncio -async def test_explain_query_error_handling(): - """Test explain_query error handling.""" - # Create a mock error response - error_message = "Error executing query" - mock_response = MagicMock() - mock_response.text = f"Error: {error_message}" - - # Use patch to replace the actual function with our mock that returns an error - with patch.object(server, "explain_query", return_value=[mock_response]): - # Call the patched function - result = await server.explain_query(conn_name="default", sql="INVALID SQL") - - # Verify error is formatted correctly - assert isinstance(result, list) - assert len(result) == 1 - assert error_message in result[0].text diff --git a/tests/unit/explain/test_server_integration.py b/tests/unit/explain/test_server_integration.py deleted file mode 100644 index 43c39ea5..00000000 --- a/tests/unit/explain/test_server_integration.py +++ /dev/null @@ -1,160 +0,0 @@ -import json -from unittest.mock import AsyncMock -from unittest.mock import MagicMock -from unittest.mock import patch - -import pytest -import pytest_asyncio - -from postgres_mcp.server import explain_query - - -@pytest_asyncio.fixture -async def mock_safe_sql_driver(): - """Create a mock SafeSqlDriver for testing.""" - driver = MagicMock() - return driver - - -@pytest.fixture -def mock_explain_plan_tool(): - """Create a mock ExplainPlanTool.""" - tool = MagicMock() - tool.explain = AsyncMock() - tool.explain_analyze = AsyncMock() - tool.explain_with_hypothetical_indexes = AsyncMock() - return tool - - -class MockCell: - def __init__(self, data): - self.cells = data - - -@pytest.mark.asyncio -async def test_explain_query_integration(): - """Test the entire explain_query tool end-to-end.""" - # Mock response with format_text_response - result_text = json.dumps({"Plan": {"Node Type": "Seq Scan"}}) - mock_text_result = MagicMock() - mock_text_result.text = result_text - - # Patch the format_text_response function - with patch("postgres_mcp.server.format_text_response", return_value=[mock_text_result]): - # Patch the get_sql_driver - with patch("postgres_mcp.server.get_sql_driver"): - # Patch the ExplainPlanTool - with patch("postgres_mcp.server.ExplainPlanTool"): - result = await explain_query(conn_name="default", sql="SELECT * FROM users", hypothetical_indexes=None) - - # Verify result matches our expected plan data - assert isinstance(result, list) - assert len(result) == 1 - assert result[0].text == result_text - - -@pytest.mark.asyncio -async def test_explain_query_with_analyze_integration(): - """Test the explain_query tool with analyze=True.""" - # Mock response with format_text_response - result_text = json.dumps({"Plan": {"Node Type": "Seq Scan"}, "Execution Time": 1.23}) - mock_text_result = MagicMock() - mock_text_result.text = result_text - - # Patch the format_text_response function - with patch("postgres_mcp.server.format_text_response", return_value=[mock_text_result]): - # Patch the get_sql_driver - with patch("postgres_mcp.server.get_sql_driver"): - # Patch the ExplainPlanTool - with patch("postgres_mcp.server.ExplainPlanTool"): - result = await explain_query(conn_name="default", sql="SELECT * FROM users", analyze=True, hypothetical_indexes=None) - - # Verify result matches our expected plan data - assert isinstance(result, list) - assert len(result) == 1 - assert result[0].text == result_text - - -@pytest.mark.asyncio -async def test_explain_query_with_hypothetical_indexes_integration(): - """Test the explain_query tool with hypothetical indexes.""" - # Mock response with format_text_response - result_text = json.dumps({"Plan": {"Node Type": "Index Scan"}}) - mock_text_result = MagicMock() - mock_text_result.text = result_text - - # Test data - test_sql = "SELECT * FROM users WHERE email = 'test@example.com'" - test_indexes = [{"table": "users", "columns": ["email"]}] - - # Patch the format_text_response function - with patch("postgres_mcp.server.format_text_response", return_value=[mock_text_result]): - # Create mock SafeSqlDriver that returns extension exists - mock_safe_driver = MagicMock() - mock_execute_query = AsyncMock(return_value=[MockCell({"exists": 1})]) - mock_safe_driver.execute_query = mock_execute_query - - # Patch the get_sql_driver - with patch("postgres_mcp.server.get_sql_driver", return_value=mock_safe_driver): - # Patch the ExplainPlanTool - with patch("postgres_mcp.server.ExplainPlanTool"): - result = await explain_query(conn_name="default", sql=test_sql, hypothetical_indexes=test_indexes) - - # Verify result matches our expected plan data - assert isinstance(result, list) - assert len(result) == 1 - assert result[0].text == result_text - - -@pytest.mark.asyncio -async def test_explain_query_missing_hypopg_integration(): - """Test the explain_query tool when hypopg extension is missing.""" - # Mock message about missing extension - missing_ext_message = "extension is required" - mock_text_result = MagicMock() - mock_text_result.text = missing_ext_message - - # Test data - test_sql = "SELECT * FROM users WHERE email = 'test@example.com'" - test_indexes = [{"table": "users", "columns": ["email"]}] - - # Create mock SafeSqlDriver that returns empty result (extension not exists) - mock_safe_driver = MagicMock() - mock_execute_query = AsyncMock(return_value=[]) - mock_safe_driver.execute_query = mock_execute_query - - # Patch the format_text_response function - with patch("postgres_mcp.server.format_text_response", return_value=[mock_text_result]): - # Patch the get_sql_driver - with patch("postgres_mcp.server.get_sql_driver", return_value=mock_safe_driver): - # Patch the ExplainPlanTool - with patch("postgres_mcp.server.ExplainPlanTool"): - result = await explain_query(conn_name="default", sql=test_sql, hypothetical_indexes=test_indexes) - - # Verify result - assert isinstance(result, list) - assert len(result) == 1 - assert missing_ext_message in result[0].text - - -@pytest.mark.asyncio -async def test_explain_query_error_handling_integration(): - """Test the explain_query tool's error handling.""" - # Mock error response - error_message = "Error executing query" - mock_text_result = MagicMock() - mock_text_result.text = f"Error: {error_message}" - - # Patch the format_error_response function - with patch("postgres_mcp.server.format_error_response", return_value=[mock_text_result]): - # Patch the get_sql_driver to throw an exception - with patch( - "postgres_mcp.server.get_sql_driver", - side_effect=Exception(error_message), - ): - result = await explain_query(conn_name="default", sql="INVALID SQL") - - # Verify error is correctly formatted - assert isinstance(result, list) - assert len(result) == 1 - assert error_message in result[0].text diff --git a/tests/unit/index/test_dta_calc.py b/tests/unit/index/test_dta_calc.py deleted file mode 100644 index 5c9ef0f9..00000000 --- a/tests/unit/index/test_dta_calc.py +++ /dev/null @@ -1,1289 +0,0 @@ -import asyncio -import json -from logging import getLogger -from typing import Any -from typing import Dict -from typing import Set -from unittest.mock import AsyncMock -from unittest.mock import MagicMock -from unittest.mock import patch - -import pytest -import pytest_asyncio -from pglast import parse_sql - -from postgres_mcp.artifacts import ExplainPlanArtifact -from postgres_mcp.index.dta_calc import ColumnCollector -from postgres_mcp.index.dta_calc import ConditionColumnCollector -from postgres_mcp.index.dta_calc import DatabaseTuningAdvisor -from postgres_mcp.index.dta_calc import IndexRecommendation - -logger = getLogger(__name__) - - -class MockCell: - def __init__(self, data: Dict[str, Any]): - self.cells = data - - -# Using pytest-asyncio's fixture to run async tests -@pytest_asyncio.fixture -async def async_sql_driver(): - driver = MagicMock() - driver.execute_query = AsyncMock(return_value=[]) - return driver - - -@pytest_asyncio.fixture -async def create_dta(async_sql_driver): - return DatabaseTuningAdvisor(sql_driver=async_sql_driver, budget_mb=10, max_runtime_seconds=60) - - -# Convert the unittest.TestCase class to use pytest -@pytest.mark.asyncio -async def test_extract_columns_empty_query(create_dta): - dta = create_dta - query = "SELECT 1" - columns = dta._sql_bind_params.extract_columns(query) - assert columns == {} - - -@pytest.mark.asyncio -async def test_extract_columns_invalid_sql(create_dta): - dta = create_dta - query = "INVALID SQL" - columns = dta._sql_bind_params.extract_columns(query) - assert columns == {} - - -@pytest.mark.asyncio -async def test_extract_columns_subquery(create_dta): - dta = create_dta - query = "SELECT * FROM users WHERE id IN (SELECT user_id FROM orders WHERE status = 'pending')" - columns = dta._sql_bind_params.extract_columns(query) - assert columns == {"users": {"id"}, "orders": {"user_id", "status"}} - - -@pytest.mark.asyncio -async def test_index_initialization(): - """Test Index class initialization and properties.""" - idx = IndexRecommendation( - table="users", - columns=( - "name", - "email", - ), - ) - assert idx.table == "users" - assert idx.columns == ("name", "email") - assert idx.definition == "CREATE INDEX crystaldba_idx_users_name_email_2 ON users USING btree (name, email)" - - -@pytest.mark.asyncio -async def test_index_equality(): - """Test Index equality comparison.""" - idx1 = IndexRecommendation(table="users", columns=("name",)) - idx2 = IndexRecommendation(table="users", columns=("name",)) - idx3 = IndexRecommendation(table="users", columns=("email",)) - - assert idx1.index_definition == idx2.index_definition - assert idx1.index_definition != idx3.index_definition - - -@pytest.mark.asyncio -async def test_extract_columns_from_simple_query(create_dta): - """Test column extraction from a simple SELECT query.""" - dta = create_dta - query = "SELECT * FROM users WHERE name = 'Alice' ORDER BY age" - columns = dta._sql_bind_params.extract_columns(query) - assert columns == {"users": {"name", "age"}} - - -@pytest.mark.asyncio -async def test_extract_columns_from_join_query(create_dta): - """Test column extraction from a query with JOINs.""" - dta = create_dta - query = """ - SELECT u.name, o.order_date - FROM users u - JOIN orders o ON u.id = o.user_id - WHERE o.status = 'pending' - """ - columns = dta._sql_bind_params.extract_columns(query) - assert columns == { - "users": {"id", "name"}, - "orders": {"user_id", "status", "order_date"}, - } - - -@pytest.mark.asyncio -async def test_generate_candidates(async_sql_driver, create_dta): - """Test index candidate generation.""" - global responses - responses = [ - # information_schema.columns - [ - MockCell( - { - "table_name": "users", - "column_name": "name", - "data_type": "character varying", - "character_maximum_length": 150, - "avg_width": 30, - "potential_long_text": True, - } - ) - ], - # create index users.name - [MockCell({"indexrelid": 123})], - # pg_stat_statements - [MockCell({"index_name": "crystaldba_idx_users_name_1", "index_size": 81920})], - # hypopg_reset - [], - ] - global responses_index - responses_index = 0 - - async def mock_execute_query(query): - global responses_index - responses_index += 1 - logger.info( - f"Query: {query}\n Response: { - list(json.dumps(x.cells) for x in responses[responses_index - 1]) if responses_index <= len(responses) else None - }\n--------------------------------------------------------" - ) - return responses[responses_index - 1] if responses_index <= len(responses) else None - - async_sql_driver.execute_query = AsyncMock(side_effect=mock_execute_query) - - dta = create_dta - - q1 = "SELECT * FROM users WHERE name = 'Alice'" - queries = [(q1, parse_sql(q1)[0].stmt, 1.0)] - candidates = await dta.generate_candidates(queries, set()) - - assert any(c.table == "users" and c.columns == ("name",) for c in candidates) - assert candidates[0].estimated_size_bytes == 10 * 8192 - - -@pytest.mark.asyncio -async def test_analyze_workload(async_sql_driver, create_dta): - async def mock_execute_query(query): - logger.info(f"Query: {query}") - if "pg_stat_statements" in query: - return [ - MockCell( - { - "queryid": 1, - "query": "SELECT * FROM users WHERE id IN (SELECT user_id FROM orders)", - "calls": 100, - "avg_exec_time": 10.0, - } - ) - ] - elif "EXPLAIN" in query: - if "COSTS TRUE" in query: - return [MockCell({"QUERY PLAN": [{"Plan": {"Total Cost": 80.0}}]})] # Cost with hypothetical index - else: - return [MockCell({"QUERY PLAN": [{"Plan": {"Total Cost": 100.0}}]})] # Current cost - elif "hypopg_reset" in query: - return None - elif "FROM information_schema.columns c" in query: - return [ - MockCell( - { - "table_name": "users", - "column_name": "id", - "data_type": "integer", - "character_maximum_length": None, - "avg_width": 4, - "potential_long_text": False, - } - ), - ] - elif "pg_stats" in query: - return [MockCell({"total_width": 10, "total_distinct": 100})] # For index size estimation - elif "pg_extension" in query: - return [MockCell({"exists": 1})] - elif "hypopg_disable_index" in query: - return None - elif "hypopg_enable_index" in query: - return None - elif "pg_total_relation_size" in query: - return [MockCell({"rel_size": 100000})] - elif "pg_stat_user_tables" in query: - return [MockCell({"last_analyze": "2023-01-01"})] - return None # Default response for unrecognized queries - - async_sql_driver.execute_query = AsyncMock(side_effect=mock_execute_query) - - dta = create_dta - - session = await dta.analyze_workload(min_calls=50, min_avg_time_ms=5.0) - - logger.debug(f"Recommendations: {session.recommendations}") - assert any(r.table in {"users", "orders"} for r in session.recommendations) - - -@pytest.mark.asyncio -async def test_error_handling(async_sql_driver, create_dta): - """Test error handling in critical methods.""" - # Test HypoPG setup failure - async_sql_driver.execute_query = AsyncMock(side_effect=RuntimeError("HypoPG not available")) - dta = create_dta - session = await dta.analyze_workload(min_calls=50, min_avg_time_ms=5.0) - assert "HypoPG not available" in session.error - - # Test invalid query handling - async_sql_driver.execute_query = AsyncMock(return_value=None) - dta = create_dta - - invalid_query = "INVALID SQL" - columns = dta._sql_bind_params.extract_columns(invalid_query) - assert columns == {} - - -@pytest.mark.asyncio -async def test_index_exists(create_dta): - """Test the robust index comparison functionality.""" - dta = create_dta - - # Create test cases with various index definition patterns - test_cases = [ - # Basic case - exact match - { - "candidate": IndexRecommendation("users", ("name",)), - "existing_defs": {"CREATE INDEX crystaldba_idx_users_name_1 ON users USING btree (name)"}, - "expected": True, - "description": "Exact match", - }, - # Different name but same structure - { - "candidate": IndexRecommendation("users", ("id",)), - "existing_defs": {"CREATE UNIQUE INDEX users_pkey ON public.users USING btree (id)"}, - "expected": True, - "description": "Primary key detection", - }, - # Different schema but same table and columns - { - "candidate": IndexRecommendation("users", ("email",)), - "existing_defs": {"CREATE UNIQUE INDEX users_email_key ON public.users USING btree (email)"}, - "expected": True, - "description": "Schema-qualified match", - }, - # Multi-column index with different order - { - "candidate": IndexRecommendation("orders", ("customer_id", "product_id"), "hash"), - "existing_defs": {"CREATE INDEX orders_idx ON orders USING hash (product_id, customer_id)"}, - "expected": True, - "description": "Hash index with different column order", - }, - # Partial match - not enough - { - "candidate": IndexRecommendation("products", ("category", "name", "price")), - "existing_defs": {"CREATE INDEX products_category_idx ON products USING btree (category)"}, - "expected": False, - "description": "Partial coverage - not enough", - }, - # Complete match but different type - { - "candidate": IndexRecommendation("payments", ("method", "status"), "hash"), - "existing_defs": {"CREATE INDEX payments_method_status_idx ON payments USING btree (method, status)"}, - "expected": False, - "description": "Different index type", - }, - # Different table - { - "candidate": IndexRecommendation("customers", ("id",)), - "existing_defs": {"CREATE INDEX users_id_idx ON users USING btree (id)"}, - "expected": False, - "description": "Different table", - }, - # Complex case with expression index - { - "candidate": IndexRecommendation("users", ("name",)), - "existing_defs": {"CREATE INDEX users_name_idx ON users USING btree (lower(name))"}, - "expected": False, - "description": "Expression index vs regular column", - }, - ] - - # Run all test cases - for tc in test_cases: - result = dta._index_exists(tc["candidate"], tc["existing_defs"]) - assert result == tc["expected"], ( - f"Failed: {tc['description']}\nCandidate: {tc['candidate']}\nExisting: {tc['existing_defs']}\nExpected: {tc['expected']}" - ) - - # Test fallback mechanism when parsing fails - with patch("pglast.parser.parse_sql", side_effect=Exception("Parsing error")): - # Should use fallback and return True based on substring matching - index = IndexRecommendation("users", ("name", "email")) - with pytest.raises(Exception, match="Error in robust index comparison"): - dta._index_exists( - index, - {"CREATE INDEX users_name_email_idx ON users USING btree (name, email)"}, - ) - - # Should return False when no match even with fallback - index = IndexRecommendation("users", ("address",)) - with pytest.raises(Exception, match="Error in robust index comparison"): - dta._index_exists( - index, - {"CREATE INDEX users_name_email_idx ON users USING btree (name, email)"}, - ) - - -@pytest.mark.asyncio -async def test_ndistinct_handling(create_dta): - """Test handling of ndistinct values in row estimation calculations.""" - dta = create_dta - - # Test cases with different ndistinct values - test_cases = [ - { - "stats": { - "total_width": 10.0, - "total_distinct": 5, - }, # Positive ndistinct - "expected": 180, # 18.0 * 5.0 * 2.0 - }, - { - "stats": { - "total_width": 10.0, - "total_distinct": -0.5, - }, # Negative ndistinct - "expected": 36, # 18.0 * 1.0 * 2.0 - }, - { - "stats": {"total_width": 10.0, "total_distinct": 0}, # Zero ndistinct - "expected": 36, # 18.0 * 1.0 * 2.0 - }, - ] - - for case in test_cases: - result = dta._estimate_index_size_internal(stats=case["stats"]) - assert result == case["expected"], f"Failed for n_distinct={case['stats']['total_distinct']}. Expected: {case['expected']}, Got: {result}" - - -@pytest.mark.asyncio -async def test_filter_long_text_columns(async_sql_driver, create_dta): - """Test filtering of long text columns from index candidates.""" - dta = create_dta - - # Mock the column type query results - type_query_results = [ - MockCell( - { - "table_name": "users", - "column_name": "name", - "data_type": "character varying", - "character_maximum_length": 50, # Short varchar - should keep - "avg_width": 4, - "potential_long_text": False, - } - ), - MockCell( - { - "table_name": "users", - "column_name": "bio", - "data_type": "text", # Text type - needs length check - "character_maximum_length": None, - "avg_width": 105, - "potential_long_text": True, - } - ), - MockCell( - { - "table_name": "users", - "column_name": "description", - "data_type": "character varying", - "character_maximum_length": 200, # Long varchar - should filter out - "avg_width": 70, - "potential_long_text": True, - } - ), - MockCell( - { - "table_name": "users", - "column_name": "status", - "data_type": "character varying", - "character_maximum_length": None, # Unlimited varchar - needs length check - "avg_width": 10, - "potential_long_text": True, - } - ), - ] - - async def mock_execute_query(query): - if "information_schema.columns" in query: - return type_query_results - return None - - async_sql_driver.execute_query = AsyncMock(side_effect=mock_execute_query) - - # Create test candidates - candidates = [ - IndexRecommendation("users", ("name",)), # Should keep (short varchar) - IndexRecommendation("users", ("bio",)), # Should filter out (long text) - IndexRecommendation("users", ("description",)), # Should filter out (long varchar) - IndexRecommendation("users", ("status",)), # Should keep (unlimited varchar but short actual length) - IndexRecommendation("users", ("name", "status")), # Should keep (both columns ok) - IndexRecommendation("users", ("name", "bio")), # Should filter out (contains long text) - IndexRecommendation("users", ("description", "status")), # Should filter out (contains long varchar) - ] - - # Execute the filter with max_text_length = 100 - filtered = await dta._filter_long_text_columns(candidates, max_text_length=100) - - logger.info(f"Filtered: {filtered}") - - # Check results - filtered_indexes = [(c.table, c.columns) for c in filtered] - - logger.info(f"Filtered indexes: {filtered_indexes}") - - # These should be kept - assert ("users", ("name",)) in filtered_indexes - assert ("users", ("status",)) in filtered_indexes - assert ("users", ("name", "status")) in filtered_indexes - - # These should be filtered out - assert ("users", ("bio",)) not in filtered_indexes - assert ("users", ("description",)) not in filtered_indexes - assert ("users", ("name", "bio")) not in filtered_indexes - assert ("users", ("description", "status")) not in filtered_indexes - - # Verify the number of filtered results - assert len(filtered) == 3 - - -@pytest.mark.asyncio -async def test_basic_workload_analysis(async_sql_driver): - """Test basic workload analysis functionality.""" - dta = DatabaseTuningAdvisor( - sql_driver=async_sql_driver, - budget_mb=50, # 50 MB budget - max_runtime_seconds=300, # 300 seconds limit - max_index_width=2, # Up to 2-column indexes - seed_columns_count=2, # Top 2 single-column seeds - ) - - workload = [ - {"query": "SELECT * FROM users WHERE name = 'Alice'", "calls": 100}, - {"query": "SELECT * FROM orders WHERE user_id = 123", "calls": 50}, - ] - global responses - responses = [ - # check if hypopg is enabled - [MockCell({"hypopg_enabled_result": 1})], - # check last analyze - [MockCell({"last_analyze": "2024-01-01 00:00:00"})], - # pg_stat_statements - [ - MockCell( - { - "queryid": 1, - "query": workload[0]["query"], - "calls": 100, - "avg_exec_time": 10.0, - } - ), - MockCell( - { - "queryid": 2, - "query": workload[1]["query"], - "calls": 50, - "avg_exec_time": 5.0, - } - ), - ], - # pg_indexes - [], - # information_schema.columns - [ - MockCell( - { - "table_name": "users", - "column_name": "name", - "data_type": "character varying", - "character_maximum_length": 150, - "avg_width": 30, - "potential_long_text": True, - } - ), - MockCell( - { - "table_name": "orders", - "column_name": "user_id", - "data_type": "integer", - "character_maximum_length": None, - "avg_width": 4, - "potential_long_text": False, - } - ), - ], - # hypopg_create_index (for users.name, orders.user_id) - [MockCell({"indexrelid": 1554}), MockCell({"indexrelid": 1555})], - # hypopg_list_indexes - [ - MockCell({"index_name": "crystaldba_idx_users_name_1", "index_size": 8000}), - MockCell( - { - "index_name": "crystaldba_idx_orders_user_id_1", - "index_size": 4000, - } - ), - ], - # hypopg_reset - [], - # EXPLAIN without indexes - [MockCell({"QUERY PLAN": [{"Plan": {"Total Cost": 100.0}}]})], # users.name - [MockCell({"QUERY PLAN": [{"Plan": {"Total Cost": 150.0}}]})], # orders.user_id - [MockCell({"rel_size": 10000})], # users table size - [MockCell({"rel_size": 10000})], # orders table size - # pg_stats for size (users.name, orders.user_id) - [MockCell({"total_width": 10, "total_distinct": 100})], - # EXPLAIN with users.name index - [MockCell({"QUERY PLAN": [{"Plan": {"Total Cost": 50.0}}]})], # users.name - [MockCell({"QUERY PLAN": [{"Plan": {"Total Cost": 150.0}}]})], # orders.user_id - [MockCell({"total_width": 8, "total_distinct": 50})], - # EXPLAIN without orders.user_id index - [MockCell({"QUERY PLAN": [{"Plan": {"Total Cost": 100.0}}]})], # users.name - [MockCell({"QUERY PLAN": [{"Plan": {"Total Cost": 75.0}}]})], # orders.user_id - # EXPLAIN with users.name and orders.user_id indexes - [MockCell({"QUERY PLAN": [{"Plan": {"Total Cost": 50.0}}]})], # users.name - [MockCell({"QUERY PLAN": [{"Plan": {"Total Cost": 75.0}}]})], # orders.user_id - # hypopg_reset (final cleanup) - [], - ] - global responses_index - responses_index = 0 - - async def mock_execute_query(query, *args, **kwargs): - global responses_index - responses_index += 1 - logger.info( - f"Query: {query}\n Response: { - list(json.dumps(x.cells) for x in responses[responses_index - 1]) if responses_index <= len(responses) else None - }\n--------------------------------------------------------" - ) - return responses[responses_index - 1] if responses_index <= len(responses) else None - - async_sql_driver.execute_query = AsyncMock(side_effect=mock_execute_query) - - session = await dta.analyze_workload(min_calls=50, min_avg_time_ms=5.0) - - # Verify recommendations - assert len(session.recommendations) > 0 - assert any(r.table == "users" and "name" in r.columns for r in session.recommendations) - assert any(r.table == "orders" and "user_id" in r.columns for r in session.recommendations) - - -@pytest.mark.asyncio -async def test_replace_parameters_basic(create_dta): - """Test basic parameter replacement functionality.""" - dta = create_dta - - dta._sql_bind_params._column_stats_cache = {} - dta._sql_bind_params.extract_columns = MagicMock(return_value={"users": ["name", "id", "status"]}) - dta._sql_bind_params._identify_parameter_column = MagicMock(return_value=("users", "name")) - dta._sql_bind_params._get_column_statistics = AsyncMock( - return_value={ - "data_type": "character varying", - "common_vals": ["John", "Alice"], - "histogram_bounds": None, - } - ) - - query = "SELECT * FROM users WHERE name = $1" - result = await dta._sql_bind_params.replace_parameters(query.lower()) - assert result == "select * from users where name = 'John'" - - # Verify the column was identified correctly - dta._sql_bind_params._identify_parameter_column.assert_called_once() - - -@pytest.mark.asyncio -async def test_replace_parameters_numeric(create_dta): - """Test parameter replacement for numeric columns.""" - dta = create_dta - - dta._sql_bind_params._column_stats_cache = {} - dta._sql_bind_params.extract_columns = MagicMock(return_value={"orders": ["id", "amount", "user_id"]}) - dta._sql_bind_params._identify_parameter_column = MagicMock(return_value=("orders", "amount")) - dta._sql_bind_params._get_column_statistics = AsyncMock( - return_value={ - "data_type": "numeric", - "common_vals": [99.99, 49.99], - "histogram_bounds": [10.0, 50.0, 100.0, 500.0], - } - ) - - # Range query - query = "SELECT * FROM orders WHERE amount > $1" - result = await dta._sql_bind_params.replace_parameters(query.lower()) - assert result == "select * from orders where amount > 100.0" - - # Equality query - dta._identify_parameter_column = MagicMock(return_value=("orders", "amount")) - query = "SELECT * FROM orders WHERE amount = $1" - result = await dta._sql_bind_params.replace_parameters(query.lower()) - assert result == "select * from orders where amount = 99.99" - - -@pytest.mark.asyncio -async def test_replace_parameters_date(create_dta): - """Test parameter replacement for date columns.""" - dta = create_dta - - dta._sql_bind_params._column_stats_cache = {} - dta._sql_bind_params.extract_columns = MagicMock(return_value={"orders": ["id", "order_date", "user_id"]}) - dta._sql_bind_params._identify_parameter_column = MagicMock(return_value=("orders", "order_date")) - dta._sql_bind_params._get_column_statistics = AsyncMock( - return_value={ - "data_type": "timestamp without time zone", - "common_vals": None, - "histogram_bounds": None, - } - ) - - query = "SELECT * FROM orders WHERE order_date > $1" - result = await dta._sql_bind_params.replace_parameters(query.lower()) - assert result == "select * from orders where order_date > '2023-01-15'" - - -@pytest.mark.asyncio -async def test_replace_parameters_like(create_dta): - """Test parameter replacement for LIKE patterns.""" - dta = create_dta - - dta._sql_bind_params._column_stats_cache = {} - dta._sql_bind_params.extract_columns = MagicMock(return_value={"users": ["name", "email"]}) - dta._sql_bind_params._identify_parameter_column = MagicMock(return_value=("users", "name")) - dta._sql_bind_params._get_column_statistics = AsyncMock( - return_value={ - "data_type": "character varying", - "common_vals": ["John", "Alice"], - "histogram_bounds": None, - } - ) - - query = "SELECT * FROM users WHERE name LIKE $1" - result = await dta._sql_bind_params.replace_parameters(query.lower()) - assert result == "select * from users where name like '%test%'" - - -@pytest.mark.asyncio -async def test_replace_parameters_multiple(create_dta): - """Test replacement of multiple parameters in a complex query.""" - dta = create_dta - - dta._sql_bind_params._column_stats_cache = {} - dta._sql_bind_params.extract_columns = MagicMock( - return_value={ - "users": ["id", "name", "status"], - "orders": ["id", "user_id", "amount", "order_date"], - } - ) - - # We'll need to return different values based on the context - def identify_column_side_effect(context, table_columns: Dict[str, Set[str]]): - if "status =" in context: - return ("users", "status") - elif "amount BETWEEN" in context: - return ("orders", "amount") - elif "order_date >" in context: - return ("orders", "order_date") - return None - - dta._sql_bind_params._identify_parameter_column = MagicMock(side_effect=identify_column_side_effect) - - def get_stats_side_effect(table, column): - if table == "users" and column == "status": - return { - "data_type": "character varying", - "common_vals": ["active", "inactive"], - } - elif table == "orders" and column == "amount": - return { - "data_type": "numeric", - "common_vals": [99.99], - "histogram_bounds": [10.0, 50.0, 100.0], - } - elif table == "orders" and column == "order_date": - return {"data_type": "timestamp without time zone"} - return None - - dta._sql_bind_params._get_column_statistics = AsyncMock(side_effect=get_stats_side_effect) - - query = """ - SELECT u.name, o.amount - FROM users u - JOIN orders o ON u.id = o.user_id - WHERE u.status = $1 - AND o.amount BETWEEN $2 AND $3 - AND o.order_date > $4 - """ - - result = await dta._sql_bind_params.replace_parameters(query.lower()) - assert "u.status = 'active'" in result - assert "o.amount between 10.0 and 100.0" in result - assert "o.order_date > '2023-01-15'" in result - - -@pytest.mark.asyncio -async def test_replace_parameters_fallback(create_dta): - """Test fallback behavior when column information is not available.""" - dta = create_dta - - dta._sql_bind_params.extract_columns = MagicMock(return_value={}) - - # Simple query with numeric parameter - query = "SELECT * FROM users WHERE id = $1" - result = await dta._sql_bind_params.replace_parameters(query.lower()) - assert "id = 46" in result or "id = '46'" in result - - # Complex query with various parameters - query = """ - SELECT * FROM users - WHERE status = $1 - AND created_at > $2 - AND name LIKE $3 - AND age BETWEEN $4 AND $5 - """ - result = await dta._sql_bind_params.replace_parameters(query.lower()) - assert "status = 'active'" in result or "status = 'sample_value'" in result - assert "created_at > '2023-01-01'" in result - assert "name like '%sample%'" in result or "name like '%" in result - assert "between 10 and 100" in result or "between 42 and 42" in result - - -@pytest.mark.asyncio -async def test_extract_columns(create_dta): - """Test extracting table and column information from queries.""" - dta = create_dta - - dta._sql_bind_params.extract_columns = MagicMock(return_value={"users": {"id", "name", "status"}}) - - query = "SELECT * FROM users WHERE name = $1 AND status = $2" - result = dta._sql_bind_params.extract_columns(query) - assert result == {"users": {"id", "name", "status"}} - - -@pytest.mark.asyncio -async def test_identify_parameter_column(create_dta): - """Test identifying which column a parameter belongs to.""" - dta = create_dta - table_columns = { - "users": ["id", "name", "status", "email"], - "orders": ["id", "user_id", "amount", "order_date"], - } - - # Test equality pattern - context = "SELECT * FROM users WHERE name = $1" - result = dta._sql_bind_params._identify_parameter_column(context, table_columns) - assert result == ("users", "name") - - # Test LIKE pattern - context = "SELECT * FROM users WHERE email LIKE $1" - result = dta._sql_bind_params._identify_parameter_column(context, table_columns) - assert result == ("users", "email") - - # Test range pattern - context = "SELECT * FROM orders WHERE amount > $1" - result = dta._sql_bind_params._identify_parameter_column(context, table_columns) - assert result == ("orders", "amount") - - # Test BETWEEN pattern - context = "SELECT * FROM orders WHERE order_date BETWEEN $1 AND $2" - result = dta._sql_bind_params._identify_parameter_column(context, table_columns) - assert result == ("orders", "order_date") - - # Test no match - context = "SELECT * FROM users WHERE $1" # Invalid but should handle gracefully - result = dta._sql_bind_params._identify_parameter_column(context, table_columns) - assert result is None - - -@pytest.mark.asyncio -async def test_get_replacement_value(create_dta): - """Test generating replacement values based on statistics.""" - dta = create_dta - - # String type with common values for equality - stats = { - "data_type": "character varying", - "common_vals": ["active", "pending", "completed"], - "histogram_bounds": None, - } - result = dta._sql_bind_params._get_replacement_value(stats, "status = $1") - assert result == "'active'" - - # Numeric type with histogram bounds for range - stats = { - "data_type": "numeric", - "common_vals": [10.0, 20.0], - "histogram_bounds": [5.0, 15.0, 25.0, 50.0, 100.0], - } - result = dta._sql_bind_params._get_replacement_value(stats, "amount > $1") - assert result == "25.0" - - # Date type - stats = {"data_type": "date", "common_vals": None, "histogram_bounds": None} - result = dta._sql_bind_params._get_replacement_value(stats, "created_at < $1") - assert result == "'2023-01-15'" - - # Boolean type - stats = { - "data_type": "boolean", - "common_vals": [True, False], - "histogram_bounds": None, - } - result = dta._sql_bind_params._get_replacement_value(stats, "is_active = $1") - assert result == "true" - - -@pytest.mark.asyncio -async def test_condition_column_collector_simple(async_sql_driver): - """Test basic functionality of ConditionColumnCollector.""" - query = "SELECT hobby FROM users WHERE name = 'Alice' AND age > 25" - parsed = parse_sql(query)[0].stmt - - collector = ColumnCollector() - collector(parsed) - - assert collector.columns == {"users": {"hobby", "name", "age"}} - - collector = ConditionColumnCollector() - collector(parsed) - - assert collector.condition_columns == {"users": {"name", "age"}} - - -@pytest.mark.asyncio -async def test_condition_column_collector_join(async_sql_driver): - """Test condition column collection with JOIN conditions.""" - query = """ - SELECT u.name, o.order_date - FROM users u - JOIN orders o ON u.id = o.user_id - WHERE o.status = 'pending' AND u.active = true - """ - parsed = parse_sql(query)[0].stmt - - collector = ColumnCollector() - collector(parsed) - - assert collector.columns == { - "users": {"id", "active", "name"}, - "orders": {"user_id", "status", "order_date"}, - } - - collector = ConditionColumnCollector() - collector(parsed) - - assert collector.condition_columns == { - "users": {"id", "active"}, - "orders": {"user_id", "status"}, - } - - -@pytest.mark.asyncio -async def test_condition_column_collector_with_alias(async_sql_driver): - """Test condition column collection with column aliases in conditions.""" - query = """ - SELECT u.name, u.age, COUNT(o.id) as order_count , o.order_date as begin_order_date, o.status as order_status - FROM users u - LEFT JOIN orders o ON u.id = o.user_id - WHERE u.status = 'active' - GROUP BY u.name - HAVING order_count > 5 - ORDER BY order_count DESC - """ - parsed = parse_sql(query)[0].stmt - - cond_collector = ConditionColumnCollector() - cond_collector(parsed) - - # Should extract o.id from HAVING COUNT(o.id) > 5 - # But should NOT include order_count as a table column - assert cond_collector.condition_columns == { - "users": {"id", "status"}, - "orders": {"user_id", "id"}, - } - - # Verify order_count is recognized as an alias - assert "order_count" in cond_collector.column_aliases - - collector = ColumnCollector() - collector(parsed) - - assert collector.columns == { - "users": {"id", "status", "name", "age"}, - "orders": {"user_id", "id", "status", "order_date"}, - } - - -@pytest.mark.asyncio -async def test_complex_query_with_alias_in_conditions(async_sql_driver): - """Test complex query with aliases used in multiple conditions.""" - query = """ - SELECT - u.name, - u.email, - EXTRACT(YEAR FROM u.created_at) as join_year, - COUNT(o.id) as order_count, - SUM(o.total) as revenue - FROM users u - LEFT JOIN orders o ON u.id = o.user_id - WHERE u.status = 'active' AND join_year > 2020 - GROUP BY u.name, u.email, join_year - HAVING order_count > 10 AND revenue > 1000 - ORDER BY revenue DESC - """ - parsed = parse_sql(query)[0].stmt - - collector = ColumnCollector() - collector(parsed) - - # Should extract the underlying columns from aliases used in conditions - assert collector.columns == { - "users": {"id", "status", "created_at", "name", "email"}, - "orders": {"user_id", "id", "total"}, - } - - collector = ConditionColumnCollector() - collector(parsed) - - # Should extract the underlying columns from aliases used in conditions - assert collector.condition_columns == { - "users": {"id", "status", "created_at"}, - "orders": {"user_id", "id", "total"}, - } - - # Verify aliases are recognized - assert "join_year" in collector.column_aliases - assert "order_count" in collector.column_aliases - assert "revenue" in collector.column_aliases - - -@pytest.mark.asyncio -async def test_filter_candidates_by_query_conditions(async_sql_driver, create_dta): - """Test filtering index candidates based on query conditions.""" - dta = create_dta - - # Mock the sql_driver for _column_exists - async_sql_driver.execute_query.return_value = [MockCell({"1": 1})] - - # Create test queries - q1 = "SELECT * FROM users WHERE name = 'Alice' AND age > 25" - q2 = "SELECT * FROM orders WHERE status = 'pending' AND total > 100" - queries = [(q1, parse_sql(q1)[0].stmt, 1.0), (q2, parse_sql(q2)[0].stmt, 1.0)] - - # Create test candidates (some with columns not in conditions) - candidates = [ - IndexRecommendation("users", ("name",)), - IndexRecommendation("users", ("name", "email")), # email not in conditions - IndexRecommendation("users", ("age",)), - IndexRecommendation("orders", ("status", "total")), - IndexRecommendation("orders", ("order_date",)), # order_date not in conditions - ] - - # Execute the filter - filtered = dta._filter_candidates_by_query_conditions(queries, candidates) - - # Check results - filtered_tables_columns = [(c.table, c.columns) for c in filtered] - assert ("users", ("name",)) in filtered_tables_columns - assert ("users", ("age",)) in filtered_tables_columns - assert ("orders", ("status", "total")) in filtered_tables_columns - - # These shouldn't be in the filtered list - assert ("users", ("name", "email")) not in filtered_tables_columns - assert ("orders", ("order_date",)) not in filtered_tables_columns - - -@pytest.mark.asyncio -async def test_extract_condition_columns(async_sql_driver): - """Test the _extract_condition_columns method directly.""" - query = """ - SELECT u.name, o.order_date - FROM users u, orders o - WHERE o.status = 'pending' AND u.active = true - """ - parsed = parse_sql(query)[0].stmt - - collector = ConditionColumnCollector() - collector(parsed) - - # Check results - assert collector.condition_columns == {"users": {"active"}, "orders": {"status"}} - - -@pytest.mark.asyncio -async def test_condition_collector_with_order_by(async_sql_driver): - """Test that columns used in ORDER BY are collected for indexing.""" - query = """ - SELECT u.name, o.order_date, o.amount - FROM orders o - JOIN users u ON o.user_id = u.id - WHERE o.status = 'completed' - ORDER BY o.order_date DESC - """ - parsed = parse_sql(query)[0].stmt - - collector = ConditionColumnCollector() - collector(parsed) - - # Should include o.order_date from ORDER BY clause - assert collector.condition_columns == { - "users": {"id"}, - "orders": {"user_id", "status", "order_date"}, - } - - -@pytest.mark.asyncio -async def test_condition_collector_with_order_by_alias(async_sql_driver): - """Test that columns in aliased expressions in ORDER BY are collected.""" - query = """ - SELECT u.name, COUNT(o.id) as order_count - FROM users u - LEFT JOIN orders o ON u.id = o.user_id - WHERE u.status = 'active' - GROUP BY u.name - ORDER BY order_count DESC - """ - parsed = parse_sql(query)[0].stmt - - collector = ConditionColumnCollector() - collector(parsed) - - # Should extract o.id from ORDER BY order_count DESC - # where order_count is COUNT(o.id) - assert collector.condition_columns == { - "users": {"id", "status"}, - "orders": {"user_id", "id"}, - } - - -@pytest.mark.asyncio -async def test_enumerate_greedy_pareto_cost_benefit(async_sql_driver): - """Test the Pareto optimal implementation with the specified cost/benefit analysis.""" - dta = DatabaseTuningAdvisor(sql_driver=async_sql_driver, budget_mb=1000, max_runtime_seconds=120) - - # Mock the _check_time method to always return False (no time limit reached) - dta._check_time = MagicMock(return_value=False) # type: ignore - - # Mock the _estimate_index_size method to return a fixed size - dta._estimate_index_size = AsyncMock(return_value=1024 * 1024) # type: ignore # 1MB per index - - # Create test queries - q1 = "SELECT * FROM test_table WHERE col1 = 1" - queries = [(q1, parse_sql(q1)[0].stmt, 1.0)] - - # Create candidate indexes - candidate_indexes = set() - for i in range(10): - candidate_indexes.add(IndexRecommendation(table="test_table", columns=(f"col{i}",))) - - # Base query cost - base_cost = 1000.0 - - # Define costs for different configurations - config_costs = { - 0: 1000, # No indexes - 1: 700, # With index 0: 30% improvement - 2: 560, # With indexes 0,1: 20% improvement - 3: 504, # With indexes 0,1,2: 10% improvement - 4: 479, # With indexes 0,1,2,3: 5% improvement - 5: 474, # With indexes 0,1,2,3,4: 1% improvement - 6: 469, # ~1% improvements each - 7: 464, - 8: 459, - 9: 454, - 10: 449, - } - - # Define index sizes - index_sizes = { - 0: 1 * 1024 * 1024, # 1MB - very efficient - 1: 2 * 1024 * 1024, # 2MB - efficient - 2: 2 * 1024 * 1024, # 2MB - less efficient - 3: 8 * 1024 * 1024, # 8MB - inefficient - 4: 16 * 1024 * 1024, # 16MB - very inefficient - 5: 32 * 1024 * 1024, # 32MB - 6: 32 * 1024 * 1024, - 7: 32 * 1024 * 1024, - 8: 32 * 1024 * 1024, - 9: 32 * 1024 * 1024, - } - - # Base relation size - base_relation_size = 50 * 1024 * 1024 # 50MB for test_table - - # Mock the cost evaluation - async def mock_evaluate_cost(queries, config): - return config_costs[len(config)] - - # Mock the index size calculation - async def mock_index_size(table, columns): - if len(columns) == 1 and columns[0].startswith("col"): - index_num = int(columns[0][3:]) - return index_sizes.get(index_num, 1024 * 1024) - return 1024 * 1024 - - # Mock the estimate_table_size method - async def mock_estimate_table_size(table): - return base_relation_size - - # Mock SQL driver execute_query method to simulate getting table size - async def mock_execute_query(query): - logger.info(f"mock_execute_query: {query}") - if "pg_total_relation_size" in query: - return [MockCell({"rel_size": base_relation_size})] - return [] - - dta._evaluate_configuration_cost = AsyncMock(side_effect=mock_evaluate_cost) # type: ignore - dta._estimate_index_size = AsyncMock(side_effect=mock_index_size) # type: ignore - dta._estimate_table_size = AsyncMock(side_effect=mock_estimate_table_size) # type: ignore - - # Set alpha parameter for cost/benefit analysis - dta.pareto_alpha = 2.0 - - # Set minimum time improvement threshold to stop after 3 indexes - dta.min_time_improvement = 0.05 # 5% threshold - - # Call _enumerate_greedy with cost/benefit analysis - current_indexes = set() - current_cost = base_cost - final_indexes, final_cost = await dta._enumerate_greedy( # type: ignore - queries, current_indexes, current_cost, candidate_indexes.copy() - ) - - # We expect exactly 3 indexes to be selected with 5% threshold - assert len(final_indexes) == 3 - assert final_cost == 504 # Cost after adding 3 indexes - - # Test with a lower threshold - should include more indexes - dta.min_time_improvement = 0.015 # 1.5% threshold - - current_indexes = set() - current_cost = base_cost - final_indexes_lower_threshold, final_cost_lower_threshold = await dta._enumerate_greedy( # type: ignore - queries, current_indexes, current_cost, candidate_indexes.copy() - ) - - # With 1% threshold, should include at least 3 indexes - assert len(final_indexes_lower_threshold) >= 3 - - # Test with a higher threshold - should include fewer indexes - dta.min_time_improvement = 0.25 # 25% threshold - - current_indexes = set() - current_cost = base_cost - final_indexes_higher_threshold, final_cost_higher_threshold = await dta._enumerate_greedy( # type: ignore - queries, current_indexes, current_cost, candidate_indexes.copy() - ) - - # With 25% threshold, should include only the first 1 index - assert len(final_indexes_higher_threshold) == 1 - - -def test_explain_plan_diff(): - """Test the explain plan diff functionality.""" - # Create a before plan with sequential scan - before_plan = { - "Plan": { - "Node Type": "Aggregate", - "Strategy": "Plain", - "Startup Cost": 300329.67, - "Total Cost": 300329.68, - "Plan Rows": 1, - "Plan Width": 32, - "Plans": [ - { - "Node Type": "Seq Scan", - "Relation Name": "users", - "Alias": "users", - "Startup Cost": 0.00, - "Total Cost": 286022.64, - "Plan Rows": 1280, - "Plan Width": 32, - "Filter": "email LIKE '%example.com'", - } - ], - } - } - - # Create an after plan with index scan instead - after_plan = { - "Plan": { - "Node Type": "Aggregate", - "Strategy": "Plain", - "Startup Cost": 17212.28, - "Total Cost": 17212.29, - "Plan Rows": 1, - "Plan Width": 32, - "Plans": [ - { - "Node Type": "Index Scan", - "Relation Name": "users", - "Alias": "users", - "Index Name": "users_email_idx", - "Startup Cost": 0.43, - "Total Cost": 17212.00, - "Plan Rows": 1280, - "Plan Width": 32, - "Filter": "email LIKE '%example.com'", - } - ], - } - } - - # Generate the diff - diff_output = ExplainPlanArtifact.create_plan_diff(before_plan, after_plan) - - # Verify the diff contains key expected elements - assert "PLAN CHANGES:" in diff_output - assert "Cost:" in diff_output - assert "improvement" in diff_output - - # Verify it detected the change from Seq Scan to Index Scan - assert "Seq Scan" in diff_output - assert "Index Scan" in diff_output - - # Verify it includes some form of diff notation - assert "→" in diff_output - - # Verify the cost values are shown in the diff - assert "300329" in diff_output - assert "17212" in diff_output - - # Verify it mentions the structural change - assert "sequential scans replaced" in diff_output or "new index scans" in diff_output - - # Test with invalid plan data - empty_diff = ExplainPlanArtifact.create_plan_diff({}, {}) - assert "Cannot generate diff" in empty_diff - - # Test with missing Plan field - invalid_diff = ExplainPlanArtifact.create_plan_diff({"NotAPlan": {}}, {"NotAPlan": {}}) - assert "Cannot generate diff" in invalid_diff - - -@pytest_asyncio.fixture(autouse=True) -async def cleanup_pools(): - """Fixture to ensure all connection pools are properly closed after each test.""" - # Setup - nothing to do here - yield - - # Find and close any active connection pools - tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] - if tasks: - logger.debug(f"Waiting for {len(tasks)} tasks to complete...") - await asyncio.gather(*tasks, return_exceptions=True) - - -if __name__ == "__main__": - pytest.main() diff --git a/tests/unit/top_queries/test_top_queries_calc.py b/tests/unit/top_queries/test_top_queries_calc.py deleted file mode 100644 index 9dadba34..00000000 --- a/tests/unit/top_queries/test_top_queries_calc.py +++ /dev/null @@ -1,214 +0,0 @@ -from unittest.mock import AsyncMock -from unittest.mock import MagicMock -from unittest.mock import patch - -import pytest - -import postgres_mcp.top_queries.top_queries_calc as top_queries_module -from postgres_mcp.sql import SqlDriver -from postgres_mcp.sql.extension_utils import ExtensionStatus -from postgres_mcp.top_queries import TopQueriesCalc - - -class MockSqlRowResult: - def __init__(self, cells): - self.cells = cells - - -# Fixtures for different PostgreSQL versions -@pytest.fixture -def mock_pg12_driver(): - """Create a mock for SqlDriver that simulates PostgreSQL 12.""" - driver = MagicMock(spec=SqlDriver) - - # Set up the version mock directly on the mock driver - with patch.object(top_queries_module, "get_postgres_version", autospec=True) as mock_version: - mock_version.return_value = 12 - - # Create async mock for execute_query - mock_execute = AsyncMock() - - # Configure the mock to return different results based on the query - async def side_effect(query, *args, **kwargs): - if "pg_stat_statements" in query: - # Return data in PG 12 format with total_time and mean_time columns - return [ - MockSqlRowResult(cells={"query": "SELECT * FROM users", "calls": 100, "total_time": 1000.0, "mean_time": 10.0, "rows": 1000}), - MockSqlRowResult(cells={"query": "SELECT * FROM orders", "calls": 50, "total_time": 750.0, "mean_time": 15.0, "rows": 500}), - MockSqlRowResult(cells={"query": "SELECT * FROM products", "calls": 200, "total_time": 500.0, "mean_time": 2.5, "rows": 2000}), - ] - return None - - mock_execute.side_effect = side_effect - driver.execute_query = mock_execute - - yield driver - - -@pytest.fixture -def mock_pg13_driver(): - """Create a mock for SqlDriver that simulates PostgreSQL 13.""" - driver = MagicMock(spec=SqlDriver) - - # Set up the version mock directly on the mock driver - with patch.object(top_queries_module, "get_postgres_version", autospec=True) as mock_version: - mock_version.return_value = 13 - - # Create async mock for execute_query - mock_execute = AsyncMock() - - # Configure the mock to return different results based on the query - async def side_effect(query, *args, **kwargs): - if "pg_stat_statements" in query: - # Return data in PG 13+ format with total_exec_time and mean_exec_time columns - return [ - MockSqlRowResult( - cells={"query": "SELECT * FROM users", "calls": 100, "total_exec_time": 1000.0, "mean_exec_time": 10.0, "rows": 1000} - ), - MockSqlRowResult( - cells={"query": "SELECT * FROM orders", "calls": 50, "total_exec_time": 750.0, "mean_exec_time": 15.0, "rows": 500} - ), - MockSqlRowResult( - cells={"query": "SELECT * FROM products", "calls": 200, "total_exec_time": 500.0, "mean_exec_time": 2.5, "rows": 2000} - ), - ] - return None - - mock_execute.side_effect = side_effect - driver.execute_query = mock_execute - - yield driver - - -# Patch check_extension to return different extension statuses -@pytest.fixture -def mock_extension_installed(): - """Mock check_extension to report extension is installed.""" - with patch.object(top_queries_module, "check_extension", autospec=True) as mock_check: - mock_check.return_value = ExtensionStatus( - is_installed=True, - is_available=True, - name="pg_stat_statements", - message="Extension is installed", - default_version="1.0", - ) - yield mock_check - - -@pytest.fixture -def mock_extension_not_installed(): - """Mock check_extension to report extension is not installed.""" - with patch.object(top_queries_module, "check_extension", autospec=True) as mock_check: - mock_check.return_value = ExtensionStatus( - is_installed=False, - is_available=True, - name="pg_stat_statements", - message="Extension not installed", - default_version=None, - ) - yield mock_check - - -@pytest.mark.asyncio -async def test_top_queries_pg12_total_sort(mock_pg12_driver, mock_extension_installed): - """Test top queries calculation on PostgreSQL 12 sorted by total execution time.""" - # Create the TopQueriesCalc instance with the mock driver - calc = TopQueriesCalc(sql_driver=mock_pg12_driver) - - # Get top queries sorted by total time - result = await calc.get_top_queries_by_time(limit=3, sort_by="total") - - # Check that the result contains the expected information - assert "Top 3 slowest queries by total execution time" in result - # First query should be the one with highest total_time - assert "SELECT * FROM users" in result - # Verify the query used the correct column name for PG 12 - assert "total_time" in str(mock_pg12_driver.execute_query.call_args) - assert "ORDER BY total_time DESC" in str(mock_pg12_driver.execute_query.call_args) - - -@pytest.mark.asyncio -async def test_top_queries_pg12_mean_sort(mock_pg12_driver, mock_extension_installed): - """Test top queries calculation on PostgreSQL 12 sorted by mean execution time.""" - # Create the TopQueriesCalc instance with the mock driver - calc = TopQueriesCalc(sql_driver=mock_pg12_driver) - - # Get top queries sorted by mean time - result = await calc.get_top_queries_by_time(limit=3, sort_by="mean") - - # Check that the result contains the expected information - assert "Top 3 slowest queries by mean execution time per call" in result - # First query should be the one with highest mean_time - assert "SELECT * FROM orders" in result - # Verify the query used the correct column name for PG 12 - assert "mean_time" in str(mock_pg12_driver.execute_query.call_args) - assert "ORDER BY mean_time DESC" in str(mock_pg12_driver.execute_query.call_args) - - -@pytest.mark.asyncio -async def test_top_queries_pg13_total_sort(mock_pg13_driver, mock_extension_installed): - """Test top queries calculation on PostgreSQL 13 sorted by total execution time.""" - # Create the TopQueriesCalc instance with the mock driver - calc = TopQueriesCalc(sql_driver=mock_pg13_driver) - - # Get top queries sorted by total time - result = await calc.get_top_queries_by_time(limit=3, sort_by="total") - - # Check that the result contains the expected information - assert "Top 3 slowest queries by total execution time" in result - # First query should be the one with highest total_exec_time - assert "SELECT * FROM users" in result - # Verify the query used the correct column name for PG 13+ - assert "total_exec_time" in str(mock_pg13_driver.execute_query.call_args) - assert "ORDER BY total_exec_time DESC" in str(mock_pg13_driver.execute_query.call_args) - - -@pytest.mark.asyncio -async def test_top_queries_pg13_mean_sort(mock_pg13_driver, mock_extension_installed): - """Test top queries calculation on PostgreSQL 13 sorted by mean execution time.""" - # Create the TopQueriesCalc instance with the mock driver - calc = TopQueriesCalc(sql_driver=mock_pg13_driver) - - # Get top queries sorted by mean time - result = await calc.get_top_queries_by_time(limit=3, sort_by="mean") - - # Check that the result contains the expected information - assert "Top 3 slowest queries by mean execution time per call" in result - # First query should be the one with highest mean_exec_time - assert "SELECT * FROM orders" in result - # Verify the query used the correct column name for PG 13+ - assert "mean_exec_time" in str(mock_pg13_driver.execute_query.call_args) - assert "ORDER BY mean_exec_time DESC" in str(mock_pg13_driver.execute_query.call_args) - - -@pytest.mark.asyncio -async def test_extension_not_installed(mock_pg13_driver, mock_extension_not_installed): - """Test behavior when pg_stat_statements extension is not installed.""" - # Create the TopQueriesCalc instance with the mock driver - calc = TopQueriesCalc(sql_driver=mock_pg13_driver) - - # Try to get top queries when extension is not installed - result = await calc.get_top_queries_by_time(limit=3) - - # Check that the result contains the installation instructions - assert "extension is required to report" in result - assert "CREATE EXTENSION" in result - - # Verify that execute_query was not called (since extension is not installed) - mock_pg13_driver.execute_query.assert_not_called() - - -@pytest.mark.asyncio -async def test_error_handling(mock_pg13_driver, mock_extension_installed): - """Test error handling in the TopQueriesCalc class.""" - # Configure execute_query to raise an exception - mock_pg13_driver.execute_query.side_effect = Exception("Database error") - - # Create the TopQueriesCalc instance with the mock driver - calc = TopQueriesCalc(sql_driver=mock_pg13_driver) - - # Try to get top queries - result = await calc.get_top_queries_by_time(limit=3) - - # Check that the error is properly reported - assert "Error getting slow queries: Database error" in result From 1422d8c4083cc6576e6c92151b78ecf1d45a9bf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20C=2E=20Andersen?= Date: Sat, 18 Oct 2025 13:49:53 +0200 Subject: [PATCH 03/14] Remove docker bloat. --- .github/workflows/build.yml | 6 - .github/workflows/docker-build-dockerhub.yml | 88 ----------- Dockerfile | 61 -------- README.md | 78 +--------- docker-entrypoint.sh | 109 ------------- pyproject.toml | 1 - smithery.yaml | 2 +- tests/Dockerfile.postgres-hypopg | 21 --- tests/conftest.py | 17 +- tests/utils.py | 155 ------------------- 10 files changed, 22 insertions(+), 516 deletions(-) delete mode 100644 .github/workflows/docker-build-dockerhub.yml delete mode 100644 Dockerfile delete mode 100644 docker-entrypoint.sh delete mode 100644 tests/Dockerfile.postgres-hypopg delete mode 100644 tests/utils.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c3cd1804..0ace83a6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,12 +24,6 @@ jobs: with: python-version: "3.12" - - name: Start Docker service - run: sudo service docker start || true - - - name: Verify Docker is running - run: docker info - - name: Install uv uses: astral-sh/setup-uv@v5 with: diff --git a/.github/workflows/docker-build-dockerhub.yml b/.github/workflows/docker-build-dockerhub.yml deleted file mode 100644 index 63963653..00000000 --- a/.github/workflows/docker-build-dockerhub.yml +++ /dev/null @@ -1,88 +0,0 @@ -# act -W ../.github/workflows/docker-build-dockerhub.yml -s DOCKERHUB_USERNAME -s DOCKERHUB_TOKEN -s GITHUB_TOKEN="$(gh auth token)" ---- -name: Build and Push Docker Image to DockerHub - -on: - push: - tags: - - "v*" - workflow_dispatch: - inputs: - version: - description: "Version to release (without v prefix)" - required: true - default: "" - -jobs: - prepare: - runs-on: ubuntu-latest - outputs: - version: ${{ steps.set-version.outputs.VERSION }} - steps: - - name: Set version from tag - id: set-version - run: | - if [[ "${{ github.event_name }}" == "push" && \ - "${{ github.ref_type }}" == "tag" ]]; then - VERSION="${GITHUB_REF#refs/tags/v}" - else - VERSION="${{ github.event.inputs.version }}" - fi - echo "VERSION=$VERSION" >> $GITHUB_OUTPUT - - build-and-push: - needs: prepare - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - id: buildx - uses: docker/setup-buildx-action@v3 - with: - install: true - version: latest - driver-opts: image=moby/buildkit:latest - - - name: Inspect builder - run: | - echo "Available platforms: $(docker buildx inspect --bootstrap | grep 'Platforms:')" - docker buildx ls - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - with: - platforms: arm64,amd64 - image: tonistiigi/binfmt:latest - - - name: Prepare Docker tags - id: docker_meta - uses: docker/metadata-action@v5 - with: - images: crystaldba/postgres-mcp - tags: | - type=raw,value=${{ needs.prepare.outputs.version }} - type=raw,value=latest - - - name: Check directory structure - run: | - echo "check pwd: $(pwd)" - echo "check ls: $(ls -lta)" - - - name: Login to DockerHub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Build and push - uses: docker/build-push-action@v5 - with: - context: . - file: Dockerfile - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.docker_meta.outputs.tags }} - cache-from: type=registry,ref=crystaldba/postgres-mcp:buildcache - cache-to: type=registry,ref=crystaldba/postgres-mcp:buildcache,mode=max diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 62b0f64d..00000000 --- a/Dockerfile +++ /dev/null @@ -1,61 +0,0 @@ -# First, build the application in the `/app` directory. -# See `Dockerfile` for details. -FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder -ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy - -# Disable Python downloads, because we want to use the system interpreter -# across both images. If using a managed Python version, it needs to be -# copied from the build image into the final image; see `standalone.Dockerfile` -# for an example. -ENV UV_PYTHON_DOWNLOADS=0 - -WORKDIR /app -RUN apt-get update \ - && apt-get install -y libpq-dev gcc \ - && rm -rf /var/lib/apt/lists/* -RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=bind,source=uv.lock,target=uv.lock \ - --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ - uv sync --frozen --no-install-project --no-dev -ADD . /app -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --frozen --no-dev - - -FROM python:3.12-slim-bookworm -# It is important to use the image that matches the builder, as the path to the -# Python executable must be the same, e.g., using `python:3.11-slim-bookworm` -# will fail. - -COPY --from=builder --chown=app:app /app /app - -ENV PATH="/app/.venv/bin:$PATH" - -ARG TARGETPLATFORM -ARG BUILDPLATFORM -LABEL org.opencontainers.image.description="Postgres MCP Agent - Multi-architecture container (${TARGETPLATFORM})" -LABEL org.opencontainers.image.source="https://github.com/crystaldba/postgres-mcp" -LABEL org.opencontainers.image.licenses="Apache-2.0" -LABEL org.opencontainers.image.vendor="Crystal DBA" -LABEL org.opencontainers.image.url="https://www.crystaldba.ai" - -# Install runtime system dependencies -RUN apt-get update && apt-get install -y \ - libpq-dev \ - iputils-ping \ - dnsutils \ - net-tools \ - && rm -rf /var/lib/apt/lists/* - -COPY docker-entrypoint.sh /app/ -RUN chmod +x /app/docker-entrypoint.sh - -# Expose the SSE port -EXPOSE 8000 - -# Run the postgres-mcp server -# Users can pass a database URI or individual connection arguments: -# docker run -it --rm postgres-mcp postgres://user:pass@host:port/dbname -# docker run -it --rm postgres-mcp -h myhost -p 5432 -U myuser -d mydb -ENTRYPOINT ["/app/docker-entrypoint.sh", "postgres-mcp"] -CMD [] diff --git a/README.md b/README.md index e5e30dda..6787ba63 100644 --- a/README.md +++ b/README.md @@ -64,35 +64,14 @@ https://github.com/user-attachments/assets/24e05745-65e9-4998-b877-a368f1eadc13 Before getting started, ensure you have: 1. Access credentials for your database. -2. Docker *or* Python 3.12 or higher. +2. Python 3.12 or higher. #### Access Credentials You can confirm your access credentials are valid by using `psql` or a GUI tool such as [pgAdmin](https://www.pgadmin.org/). -#### Docker or Python - -The choice to use Docker or Python is yours. -We generally recommend Docker because Python users can encounter more environment-specific issues. -However, it often makes sense to use whichever method you are most familiar with. - - ### Installation -Choose one of the following methods to install Postgres MCP Pro: - -#### Option 1: Using Docker - -Pull the Postgres MCP Pro MCP server Docker image. -This image contains all necessary dependencies, providing a reliable way to run Postgres MCP Pro in a variety of environments. - -```bash -docker pull crystaldba/postgres-mcp -``` - - -#### Option 2: Using Python - If you have `pipx` installed you can install Postgres MCP Pro with: ```bash @@ -124,36 +103,6 @@ You can also use `Settings` menu item in Claude Desktop to locate the configurat You will now edit the `mcpServers` section of the configuration file. -##### If you are using Docker - -```json -{ - "mcpServers": { - "postgres": { - "command": "docker", - "args": [ - "run", - "-i", - "--rm", - "-e", - "DATABASE_URI", - "crystaldba/postgres-mcp", - "--access-mode=unrestricted" - ], - "env": { - "DATABASE_URI": "postgresql://username:password@localhost:5432/dbname" - } - } - } -} -``` - -The Postgres MCP Pro Docker image will automatically remap the hostname `localhost` to work from inside of the container. - -- MacOS/Windows: Uses `host.docker.internal` automatically -- Linux: Uses `172.17.0.1` or the appropriate host address automatically - - ##### If you are using `pipx` ```json @@ -209,20 +158,8 @@ To configure multiple connections, define additional environment variables with { "mcpServers": { "postgres": { - "command": "docker", - "args": [ - "run", - "-i", - "--rm", - "-e", "DATABASE_URI_APP", - "-e", "DATABASE_URI_ETL", - "-e", "DATABASE_URI_ANALYTICS", - "-e", "DATABASE_DESC_APP", - "-e", "DATABASE_DESC_ETL", - "-e", "DATABASE_DESC_ANALYTICS", - "crystaldba/postgres-mcp", - "--access-mode=unrestricted" - ], + "command": "postgres-mcp", + "args": ["--access-mode=unrestricted"], "env": { "DATABASE_URI_APP": "postgresql://user:pass@localhost:5432/app_db", "DATABASE_URI_ETL": "postgresql://user:pass@localhost:5432/etl_db", @@ -274,15 +211,14 @@ Many MCP clients have similar configuration files to Claude Desktop, and you can Postgres MCP Pro supports the [SSE transport](https://modelcontextprotocol.io/docs/concepts/transports#server-sent-events-sse), which allows multiple MCP clients to share one server, possibly a remote server. To use the SSE transport, you need to start the server with the `--transport=sse` option. -For example, with Docker run: +For example, run: ```bash -docker run -p 8000:8000 \ - -e DATABASE_URI=postgresql://username:password@localhost:5432/dbname \ - crystaldba/postgres-mcp --access-mode=unrestricted --transport=sse +DATABASE_URI=postgresql://username:password@localhost:5432/dbname \ + postgres-mcp --access-mode=unrestricted --transport=sse ``` -Then update your MCP client configuration to call the the MCP server. +Then update your MCP client configuration to call the MCP server. For example, in Cursor's `mcp.json` or Cline's `cline_mcp_settings.json` you can put: ```json diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh deleted file mode 100644 index 434522c8..00000000 --- a/docker-entrypoint.sh +++ /dev/null @@ -1,109 +0,0 @@ -#!/bin/bash - -# Don't exit immediately so we can debug issues -# set -e - -# Function to replace localhost in a string with the Docker host -replace_localhost() { - local input_str="$1" - local docker_host="" - - # Try to determine Docker host address - if ping -c 1 -w 1 host.docker.internal >/dev/null 2>&1; then - docker_host="host.docker.internal" - echo "Docker Desktop detected: Using host.docker.internal for localhost" >&2 - elif ping -c 1 -w 1 172.17.0.1 >/dev/null 2>&1; then - docker_host="172.17.0.1" - echo "Docker on Linux detected: Using 172.17.0.1 for localhost" >&2 - else - echo "WARNING: Cannot determine Docker host IP. Using original address." >&2 - return 1 - fi - - # Replace localhost with Docker host - if [[ -n "$docker_host" ]]; then - local new_str="${input_str/localhost/$docker_host}" - echo " Remapping: $input_str --> $new_str" >&2 - echo "$new_str" - return 0 - fi - - # No replacement made - echo "$input_str" - return 1 -} - -# Create a new array for the processed arguments -processed_args=() -processed_args+=("$1") -shift 1 - -# Process remaining command-line arguments for postgres:// or postgresql:// URLs that contain localhost -for arg in "$@"; do - if [[ "$arg" == *"postgres"*"://"*"localhost"* ]]; then - echo "Found localhost in database connection: $arg" >&2 - new_arg=$(replace_localhost "$arg") - if [[ $? -eq 0 ]]; then - processed_args+=("$new_arg") - else - processed_args+=("$arg") - fi - else - processed_args+=("$arg") - fi -done - -# Check and replace localhost in DATABASE_URI if it exists -if [[ -n "$DATABASE_URI" && "$DATABASE_URI" == *"postgres"*"://"*"localhost"* ]]; then - echo "Found localhost in DATABASE_URI: $DATABASE_URI" >&2 - new_uri=$(replace_localhost "$DATABASE_URI") - if [[ $? -eq 0 ]]; then - export DATABASE_URI="$new_uri" - fi -fi - -# Check if SSE transport is specified and --sse-host is not already set -has_sse=false -has_sse_host=false - -for arg in "${processed_args[@]}"; do - if [[ "$arg" == "--transport" ]]; then - # Check next argument for "sse" - for next_arg in "${processed_args[@]}"; do - if [[ "$next_arg" == "sse" ]]; then - has_sse=true - break - fi - done - elif [[ "$arg" == "--transport=sse" ]]; then - has_sse=true - elif [[ "$arg" == "--sse-host"* ]]; then - has_sse_host=true - fi -done - -# Add --sse-host if needed -if [[ "$has_sse" == true ]] && [[ "$has_sse_host" == false ]]; then - echo "SSE transport detected, adding --sse-host=0.0.0.0" >&2 - processed_args+=("--sse-host=0.0.0.0") -fi - -echo "----------------" >&2 -echo "Executing command:" >&2 -echo "${processed_args[@]}" >&2 -echo "----------------" >&2 - -# Execute the command with the processed arguments -"${processed_args[@]}" - -# Capture exit code from the Python process -exit_code=$? - -# If the Python process failed, print additional debug info -if [ $exit_code -ne 0 ]; then - echo "ERROR: Command failed with exit code $exit_code" >&2 - echo "Command was: ${processed_args[@]}" >&2 -fi - -# Return the exit code from the Python process -exit $exit_code diff --git a/pyproject.toml b/pyproject.toml index bf2f7cae..26ec161e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,6 @@ asyncio_default_fixture_loop_scope = "function" [dependency-groups] dev = [ - "docker>=7.1.0", "pyright==1.1.398", "pytest-asyncio>=0.26.0", "pytest>=8.3.5", diff --git a/smithery.yaml b/smithery.yaml index 2763c205..3ab00aa1 100644 --- a/smithery.yaml +++ b/smithery.yaml @@ -18,4 +18,4 @@ startCommand: commandFunction: # A function that produces the CLI command to start the MCP on stdio. |- - (config) => ({command: '/app/docker-entrypoint.sh', args: ['postgres-mcp', '--access-mode',config.accessMode], env: {DATABASE_URI: config.databaseUri}}) \ No newline at end of file + (config) => ({command: 'postgres-mcp', args: ['--access-mode', config.accessMode], env: {DATABASE_URI: config.databaseUri}}) \ No newline at end of file diff --git a/tests/Dockerfile.postgres-hypopg b/tests/Dockerfile.postgres-hypopg deleted file mode 100644 index 3741b0ed..00000000 --- a/tests/Dockerfile.postgres-hypopg +++ /dev/null @@ -1,21 +0,0 @@ -ARG PG_VERSION=15 -FROM postgres:${PG_VERSION} - -# Install build dependencies -RUN apt-get update && apt-get install -y \ - build-essential \ - git \ - postgresql-server-dev-${PG_MAJOR} \ - && rm -rf /var/lib/apt/lists/* - -# Clone and build HypoPG -RUN git clone --depth 1 https://github.com/HypoPG/hypopg.git /tmp/hypopg \ - && cd /tmp/hypopg \ - && make \ - && make install \ - && cd / \ - && rm -rf /tmp/hypopg - -# Add initialization script to create extensions in template1 -RUN echo "CREATE EXTENSION IF NOT EXISTS pg_stat_statements; CREATE EXTENSION IF NOT EXISTS hypopg;" \ - > /docker-entrypoint-initdb.d/00-create-extensions.sql diff --git a/tests/conftest.py b/tests/conftest.py index f202ff67..581ed0dd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,9 @@ import asyncio +import os from typing import Generator import pytest from dotenv import load_dotenv -from utils import create_postgres_container from postgres_mcp.sql import reset_postgres_version_cache @@ -17,9 +17,20 @@ def event_loop_policy(): return asyncio.DefaultEventLoopPolicy() -@pytest.fixture(scope="class", params=["postgres:15", "postgres:16"]) +@pytest.fixture(scope="class") def test_postgres_connection_string(request) -> Generator[tuple[str, str], None, None]: - yield from create_postgres_container(request.param) + """ + Provides a PostgreSQL connection string for testing. + + Expects DATABASE_URI environment variable to be set. + If not set, tests will be skipped. + """ + db_uri = os.getenv("DATABASE_URI") + if not db_uri: + pytest.skip("DATABASE_URI environment variable not set. Tests require a PostgreSQL database.") + + # Return the connection string and a version identifier + yield db_uri, "local" @pytest.fixture(autouse=True) diff --git a/tests/utils.py b/tests/utils.py deleted file mode 100644 index 7780cc29..00000000 --- a/tests/utils.py +++ /dev/null @@ -1,155 +0,0 @@ -import logging -import os -import time -from pathlib import Path -from typing import Generator -from typing import Tuple - -import docker -import pytest -from docker import errors as docker_errors - -logger = logging.getLogger(__name__) - - -def create_postgres_container(version: str) -> Generator[Tuple[str, str], None, None]: - """Create a PostgreSQL container of specified version and return its connection string.""" - try: - client = docker.from_env() - client.ping() - except (docker_errors.DockerException, ConnectionError): - pytest.skip("Docker is not available") - - # Extract PostgreSQL version number - pg_version = version.split(":")[1] if ":" in version else version - - # Define custom image name with HypoPG - custom_image_name = f"postgres-hypopg:{pg_version}" - - container_name = f"postgres-crystal-test-{version.replace(':', '_')}-{os.urandom(4).hex()}" - current_dir = Path(__file__).parent.absolute() - - logger.info(f"Setting up PostgreSQL {pg_version} with HypoPG") - - # Build custom Docker image with HypoPG if it doesn't exist - try: - # Check if custom image already exists - client.images.get(custom_image_name) - logger.info(f"Using existing Docker image: {custom_image_name}") - except docker_errors.ImageNotFound: - # Build the custom image - logger.info(f"Building custom Docker image: {custom_image_name}") - try: - dockerfile_path = current_dir / "Dockerfile.postgres-hypopg" - if not dockerfile_path.exists(): - logger.error(f"Dockerfile not found at {dockerfile_path}") - pytest.skip(f"Required Dockerfile not found: {dockerfile_path}") - - # Build the image - client.images.build( - path=str(current_dir), - dockerfile="Dockerfile.postgres-hypopg", - buildargs={"PG_VERSION": pg_version, "PG_MAJOR": pg_version}, - tag=custom_image_name, - rm=True, - ) - logger.info(f"Successfully built image {custom_image_name}") - except Exception as e: - logger.error(f"Failed to build Docker image: {e}") - pytest.skip(f"Failed to build Docker image: {e}") - - postgres_password = "test_password" - postgres_db = "test_db" - - # Create container with more verbose logging - container = client.containers.run( - custom_image_name, - name=container_name, - environment={ - "POSTGRES_PASSWORD": postgres_password, - "POSTGRES_DB": postgres_db, - "POSTGRES_HOST_AUTH_METHOD": "trust", # Make authentication easier in tests - }, - ports={"5432/tcp": ("127.0.0.1", 0)}, # Let Docker assign a random port - command=[ - "-c", - "shared_preload_libraries=pg_stat_statements", - "-c", - "pg_stat_statements.track=all", - "-c", - "log_min_messages=info", # More verbose logging - "-c", - "log_statement=all", # Log all SQL statements - ], - detach=True, - ) - - logger.info(f"Container {container_name} started, waiting for PostgreSQL to be ready") - - try: - # Wait for container to start and get logs - time.sleep(2) # Give container a moment to start - container.reload() - - # Check if container is running - if container.status != "running": - logs = container.logs().decode("utf-8") - logger.error(f"Container {container_name} failed to start. Logs:\n{logs}") - pytest.skip(f"PostgreSQL container failed to start: {logs[:500]}...") - - # Get assigned port - port = container.ports["5432/tcp"][0]["HostPort"] - - # Wait for PostgreSQL to be ready - deadline = time.time() + 60 # Increased timeout to 60 seconds - is_ready = False - last_error = None - - while time.time() < deadline and not is_ready: - try: - exit_code, output = container.exec_run("pg_isready") - if exit_code == 0: - logger.info(f"PostgreSQL in container {container_name} is ready") - is_ready = True - break - else: - last_error = output.decode("utf-8") - logger.warning(f"PostgreSQL not ready yet: {last_error}") - except Exception as e: - last_error = str(e) - logger.warning(f"Error checking if PostgreSQL is ready: {e}") - - # Get container logs for debugging - if time.time() - deadline + 60 > 50: # Log when we're close to timeout - logs = container.logs().decode("utf-8") - logger.warning(f"Still waiting for PostgreSQL. Container logs:\n{logs[-2000:]}") - - time.sleep(2) - - if not is_ready: - logs = container.logs().decode("utf-8") - logger.error(f"Timeout waiting for PostgreSQL. Container logs:\n{logs[-2000:]}") - pytest.skip(f"Timeout waiting for PostgreSQL to start: {last_error}") - - connection_string = f"postgresql://postgres:{postgres_password}@localhost:{port}/{postgres_db}" - logger.info(f"PostgreSQL connection string: {connection_string}") - - yield connection_string, version - - except Exception as e: - logger.error(f"Error setting up PostgreSQL container: {e}") - # Get container logs for debugging - try: - logs = container.logs().decode("utf-8") - logger.error(f"Container logs:\n{logs}") - except Exception: - pass - raise - - finally: - logger.info(f"Stopping and removing container {container_name}") - try: - container.stop(timeout=1) - container.remove(v=True) - except Exception as e: - logger.warning(f"Error cleaning up container {container_name}: {e}") From 75e8ab8b842bfc8896d83961a63f9a68620982e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20C=2E=20Andersen?= Date: Sat, 18 Oct 2025 13:59:11 +0200 Subject: [PATCH 04/14] Use modern typing. Run uv lock. --- src/postgres_mcp/server.py | 6 +- src/postgres_mcp/sql/safe_sql.py | 3 +- src/postgres_mcp/sql/sql_driver.py | 31 +- tests/conftest.py | 2 +- uv.lock | 1095 ++++++++++++++-------------- 5 files changed, 551 insertions(+), 586 deletions(-) diff --git a/src/postgres_mcp/server.py b/src/postgres_mcp/server.py index ebcd97a8..c1e782d9 100644 --- a/src/postgres_mcp/server.py +++ b/src/postgres_mcp/server.py @@ -6,8 +6,6 @@ import signal import sys from enum import Enum -from typing import List -from typing import Union import mcp.types as types from mcp.server.fastmcp import FastMCP @@ -22,7 +20,7 @@ # Note: Server instructions will be updated after database connections are discovered mcp = FastMCP("postgres-mcp") -ResponseType = List[types.TextContent | types.ImageContent | types.EmbeddedResource] +ResponseType = list[types.TextContent | types.ImageContent | types.EmbeddedResource] logger = logging.getLogger(__name__) @@ -40,7 +38,7 @@ class AccessMode(str, Enum): shutdown_in_progress = False -async def get_sql_driver(conn_name: str) -> Union[SqlDriver, SafeSqlDriver]: +async def get_sql_driver(conn_name: str) -> SqlDriver | SafeSqlDriver: """ Get the appropriate SQL driver based on the current access mode. diff --git a/src/postgres_mcp/sql/safe_sql.py b/src/postgres_mcp/sql/safe_sql.py index 2aa07ee4..fb4b7daf 100644 --- a/src/postgres_mcp/sql/safe_sql.py +++ b/src/postgres_mcp/sql/safe_sql.py @@ -5,7 +5,6 @@ import re from typing import Any from typing import ClassVar -from typing import Optional import pglast from pglast.ast import A_ArrayExpr @@ -979,7 +978,7 @@ async def execute_query( query: LiteralString, params: list[Any] | None = None, force_readonly: bool = True, # do not use value passed in - ) -> Optional[list[SqlDriver.RowResult]]: # noqa: UP007 + ) -> list[SqlDriver.RowResult] | None: """Execute a query after validating it is safe""" self._validate(query) diff --git a/src/postgres_mcp/sql/sql_driver.py b/src/postgres_mcp/sql/sql_driver.py index f15a69b5..45291750 100644 --- a/src/postgres_mcp/sql/sql_driver.py +++ b/src/postgres_mcp/sql/sql_driver.py @@ -6,9 +6,6 @@ import re from dataclasses import dataclass from typing import Any -from typing import Dict -from typing import List -from typing import Optional from urllib.parse import urlparse from urllib.parse import urlunparse @@ -64,13 +61,13 @@ def obfuscate_password(text: str | None) -> str | None: class DbConnPool: """Database connection manager using psycopg's connection pool.""" - def __init__(self, connection_url: Optional[str] = None): + def __init__(self, connection_url: str | None = None): self.connection_url = connection_url self.pool: AsyncConnectionPool | None = None self._is_valid = False self._last_error = None - async def pool_connect(self, connection_url: Optional[str] = None) -> AsyncConnectionPool: + async def pool_connect(self, connection_url: str | None = None) -> AsyncConnectionPool: """Initialize connection pool with retry logic.""" # If we already have a valid pool, return it if self.pool and self._is_valid: @@ -133,7 +130,7 @@ def is_valid(self) -> bool: return self._is_valid @property - def last_error(self) -> Optional[str]: + def last_error(self) -> str | None: """Get the last error message.""" return self._last_error @@ -142,11 +139,11 @@ class ConnectionRegistry: """Registry for managing multiple database connections.""" def __init__(self): - self.connections: Dict[str, DbConnPool] = {} - self._connection_urls: Dict[str, str] = {} - self._connection_descriptions: Dict[str, str] = {} + self.connections: dict[str, DbConnPool] = {} + self._connection_urls: dict[str, str] = {} + self._connection_descriptions: dict[str, str] = {} - def discover_connections(self) -> Dict[str, str]: + def discover_connections(self) -> dict[str, str]: """ Discover all DATABASE_URI_* environment variables. @@ -169,7 +166,7 @@ def discover_connections(self) -> Dict[str, str]: return discovered - def discover_descriptions(self) -> Dict[str, str]: + def discover_descriptions(self) -> dict[str, str]: """ Discover all DATABASE_DESC_* environment variables. @@ -215,7 +212,7 @@ async def discover_and_connect(self) -> None: self.connections[conn_name] = DbConnPool(url) # Connect to all databases in parallel - async def connect_single(conn_name: str, pool: DbConnPool) -> tuple[str, bool, Optional[str]]: + async def connect_single(conn_name: str, pool: DbConnPool) -> tuple[str, bool, str | None]: """Connect to a single database and return status.""" try: await pool.pool_connect() @@ -282,11 +279,11 @@ async def close_all(self) -> None: self._connection_urls.clear() self._connection_descriptions.clear() - def get_connection_names(self) -> List[str]: + def get_connection_names(self) -> list[str]: """Get list of all connection names.""" return list(self.connections.keys()) - def get_connection_info(self) -> List[Dict[str, str]]: + def get_connection_info(self) -> list[dict[str, str]]: """ Get information about all configured connections. @@ -309,7 +306,7 @@ class SqlDriver: class RowResult: """Simple class to match the Griptape RowResult interface.""" - cells: Dict[str, Any] + cells: dict[str, Any] def __init__( self, @@ -350,7 +347,7 @@ async def execute_query( query: LiteralString, params: list[Any] | None = None, force_readonly: bool = False, - ) -> Optional[List[RowResult]]: + ) -> list[RowResult] | None: """ Execute a query and return results. @@ -377,7 +374,7 @@ async def execute_query( # Direct connection approach return await self._execute_with_connection(self.conn, query, params, force_readonly=force_readonly) - async def _execute_with_connection(self, connection, query, params, force_readonly) -> Optional[List[RowResult]]: + async def _execute_with_connection(self, connection, query, params, force_readonly) -> list[RowResult] | None: """Execute query with the given connection.""" transaction_started = False try: diff --git a/tests/conftest.py b/tests/conftest.py index 581ed0dd..d1a47db6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,6 @@ import asyncio import os -from typing import Generator +from collections.abc import Generator import pytest from dotenv import load_dotenv diff --git a/uv.lock b/uv.lock index 037a8d2f..21c90705 100644 --- a/uv.lock +++ b/uv.lock @@ -1,14 +1,14 @@ version = 1 -revision = 1 +revision = 3 requires-python = ">=3.12" [[package]] name = "aiohappyeyeballs" version = "2.6.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760 } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265 }, + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, ] [[package]] @@ -24,40 +24,40 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/d9/1c4721d143e14af753f2bf5e3b681883e1f24b592c0482df6fa6e33597fa/aiohttp-3.11.16.tar.gz", hash = "sha256:16f8a2c9538c14a557b4d309ed4d0a7c60f0253e8ed7b6c9a2859a7582f8b1b8", size = 7676826 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/38/100d01cbc60553743baf0fba658cb125f8ad674a8a771f765cdc155a890d/aiohttp-3.11.16-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:911a6e91d08bb2c72938bc17f0a2d97864c531536b7832abee6429d5296e5b27", size = 704881 }, - { url = "https://files.pythonhosted.org/packages/21/ed/b4102bb6245e36591209e29f03fe87e7956e54cb604ee12e20f7eb47f994/aiohttp-3.11.16-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac13b71761e49d5f9e4d05d33683bbafef753e876e8e5a7ef26e937dd766713", size = 464564 }, - { url = "https://files.pythonhosted.org/packages/3b/e1/a9ab6c47b62ecee080eeb33acd5352b40ecad08fb2d0779bcc6739271745/aiohttp-3.11.16-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fd36c119c5d6551bce374fcb5c19269638f8d09862445f85a5a48596fd59f4bb", size = 456548 }, - { url = "https://files.pythonhosted.org/packages/80/ad/216c6f71bdff2becce6c8776f0aa32cb0fa5d83008d13b49c3208d2e4016/aiohttp-3.11.16-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d489d9778522fbd0f8d6a5c6e48e3514f11be81cb0a5954bdda06f7e1594b321", size = 1691749 }, - { url = "https://files.pythonhosted.org/packages/bd/ea/7df7bcd3f4e734301605f686ffc87993f2d51b7acb6bcc9b980af223f297/aiohttp-3.11.16-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:69a2cbd61788d26f8f1e626e188044834f37f6ae3f937bd9f08b65fc9d7e514e", size = 1736874 }, - { url = "https://files.pythonhosted.org/packages/51/41/c7724b9c87a29b7cfd1202ec6446bae8524a751473d25e2ff438bc9a02bf/aiohttp-3.11.16-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd464ba806e27ee24a91362ba3621bfc39dbbb8b79f2e1340201615197370f7c", size = 1786885 }, - { url = "https://files.pythonhosted.org/packages/86/b3/f61f8492fa6569fa87927ad35a40c159408862f7e8e70deaaead349e2fba/aiohttp-3.11.16-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ce63ae04719513dd2651202352a2beb9f67f55cb8490c40f056cea3c5c355ce", size = 1698059 }, - { url = "https://files.pythonhosted.org/packages/ce/be/7097cf860a9ce8bbb0e8960704e12869e111abcd3fbd245153373079ccec/aiohttp-3.11.16-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09b00dd520d88eac9d1768439a59ab3d145065c91a8fab97f900d1b5f802895e", size = 1626527 }, - { url = "https://files.pythonhosted.org/packages/1d/1d/aaa841c340e8c143a8d53a1f644c2a2961c58cfa26e7b398d6bf75cf5d23/aiohttp-3.11.16-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7f6428fee52d2bcf96a8aa7b62095b190ee341ab0e6b1bcf50c615d7966fd45b", size = 1644036 }, - { url = "https://files.pythonhosted.org/packages/2c/88/59d870f76e9345e2b149f158074e78db457985c2b4da713038d9da3020a8/aiohttp-3.11.16-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:13ceac2c5cdcc3f64b9015710221ddf81c900c5febc505dbd8f810e770011540", size = 1685270 }, - { url = "https://files.pythonhosted.org/packages/2b/b1/c6686948d4c79c3745595efc469a9f8a43cab3c7efc0b5991be65d9e8cb8/aiohttp-3.11.16-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fadbb8f1d4140825069db3fedbbb843290fd5f5bc0a5dbd7eaf81d91bf1b003b", size = 1650852 }, - { url = "https://files.pythonhosted.org/packages/fe/94/3e42a6916fd3441721941e0f1b8438e1ce2a4c49af0e28e0d3c950c9b3c9/aiohttp-3.11.16-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:6a792ce34b999fbe04a7a71a90c74f10c57ae4c51f65461a411faa70e154154e", size = 1704481 }, - { url = "https://files.pythonhosted.org/packages/b1/6d/6ab5854ff59b27075c7a8c610597d2b6c38945f9a1284ee8758bc3720ff6/aiohttp-3.11.16-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f4065145bf69de124accdd17ea5f4dc770da0a6a6e440c53f6e0a8c27b3e635c", size = 1735370 }, - { url = "https://files.pythonhosted.org/packages/73/2a/08a68eec3c99a6659067d271d7553e4d490a0828d588e1daa3970dc2b771/aiohttp-3.11.16-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fa73e8c2656a3653ae6c307b3f4e878a21f87859a9afab228280ddccd7369d71", size = 1697619 }, - { url = "https://files.pythonhosted.org/packages/61/d5/fea8dbbfb0cd68fbb56f0ae913270a79422d9a41da442a624febf72d2aaf/aiohttp-3.11.16-cp312-cp312-win32.whl", hash = "sha256:f244b8e541f414664889e2c87cac11a07b918cb4b540c36f7ada7bfa76571ea2", size = 411710 }, - { url = "https://files.pythonhosted.org/packages/33/fb/41cde15fbe51365024550bf77b95a4fc84ef41365705c946da0421f0e1e0/aiohttp-3.11.16-cp312-cp312-win_amd64.whl", hash = "sha256:23a15727fbfccab973343b6d1b7181bfb0b4aa7ae280f36fd2f90f5476805682", size = 438012 }, - { url = "https://files.pythonhosted.org/packages/52/52/7c712b2d9fb4d5e5fd6d12f9ab76e52baddfee71e3c8203ca7a7559d7f51/aiohttp-3.11.16-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a3814760a1a700f3cfd2f977249f1032301d0a12c92aba74605cfa6ce9f78489", size = 698005 }, - { url = "https://files.pythonhosted.org/packages/51/3e/61057814f7247666d43ac538abcd6335b022869ade2602dab9bf33f607d2/aiohttp-3.11.16-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9b751a6306f330801665ae69270a8a3993654a85569b3469662efaad6cf5cc50", size = 461106 }, - { url = "https://files.pythonhosted.org/packages/4f/85/6b79fb0ea6e913d596d5b949edc2402b20803f51b1a59e1bbc5bb7ba7569/aiohttp-3.11.16-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ad497f38a0d6c329cb621774788583ee12321863cd4bd9feee1effd60f2ad133", size = 453394 }, - { url = "https://files.pythonhosted.org/packages/4b/04/e1bb3fcfbd2c26753932c759593a32299aff8625eaa0bf8ff7d9c0c34a36/aiohttp-3.11.16-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca37057625693d097543bd88076ceebeb248291df9d6ca8481349efc0b05dcd0", size = 1666643 }, - { url = "https://files.pythonhosted.org/packages/0e/27/97bc0fdd1f439b8f060beb3ba8fb47b908dc170280090801158381ad7942/aiohttp-3.11.16-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a5abcbba9f4b463a45c8ca8b7720891200658f6f46894f79517e6cd11f3405ca", size = 1721948 }, - { url = "https://files.pythonhosted.org/packages/2c/4f/bc4c5119e75c05ef15c5670ef1563bbe25d4ed4893b76c57b0184d815e8b/aiohttp-3.11.16-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f420bfe862fb357a6d76f2065447ef6f484bc489292ac91e29bc65d2d7a2c84d", size = 1774454 }, - { url = "https://files.pythonhosted.org/packages/73/5b/54b42b2150bb26fdf795464aa55ceb1a49c85f84e98e6896d211eabc6670/aiohttp-3.11.16-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58ede86453a6cf2d6ce40ef0ca15481677a66950e73b0a788917916f7e35a0bb", size = 1677785 }, - { url = "https://files.pythonhosted.org/packages/10/ee/a0fe68916d3f82eae199b8535624cf07a9c0a0958c7a76e56dd21140487a/aiohttp-3.11.16-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fdec0213244c39973674ca2a7f5435bf74369e7d4e104d6c7473c81c9bcc8c4", size = 1608456 }, - { url = "https://files.pythonhosted.org/packages/8b/48/83afd779242b7cf7e1ceed2ff624a86d3221e17798061cf9a79e0b246077/aiohttp-3.11.16-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72b1b03fb4655c1960403c131740755ec19c5898c82abd3961c364c2afd59fe7", size = 1622424 }, - { url = "https://files.pythonhosted.org/packages/6f/27/452f1d5fca1f516f9f731539b7f5faa9e9d3bf8a3a6c3cd7c4b031f20cbd/aiohttp-3.11.16-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:780df0d837276276226a1ff803f8d0fa5f8996c479aeef52eb040179f3156cbd", size = 1660943 }, - { url = "https://files.pythonhosted.org/packages/d6/e1/5c7d63143b8d00c83b958b9e78e7048c4a69903c760c1e329bf02bac57a1/aiohttp-3.11.16-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ecdb8173e6c7aa09eee342ac62e193e6904923bd232e76b4157ac0bfa670609f", size = 1622797 }, - { url = "https://files.pythonhosted.org/packages/46/9e/2ac29cca2746ee8e449e73cd2fcb3d454467393ec03a269d50e49af743f1/aiohttp-3.11.16-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6db7458ab89c7d80bc1f4e930cc9df6edee2200127cfa6f6e080cf619eddfbd", size = 1687162 }, - { url = "https://files.pythonhosted.org/packages/ad/6b/eaa6768e02edebaf37d77f4ffb74dd55f5cbcbb6a0dbf798ccec7b0ac23b/aiohttp-3.11.16-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2540ddc83cc724b13d1838026f6a5ad178510953302a49e6d647f6e1de82bc34", size = 1718518 }, - { url = "https://files.pythonhosted.org/packages/e5/18/dda87cbad29472a51fa058d6d8257dfce168289adaeb358b86bd93af3b20/aiohttp-3.11.16-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3b4e6db8dc4879015b9955778cfb9881897339c8fab7b3676f8433f849425913", size = 1675254 }, - { url = "https://files.pythonhosted.org/packages/32/d9/d2fb08c614df401d92c12fcbc60e6e879608d5e8909ef75c5ad8d4ad8aa7/aiohttp-3.11.16-cp313-cp313-win32.whl", hash = "sha256:493910ceb2764f792db4dc6e8e4b375dae1b08f72e18e8f10f18b34ca17d0979", size = 410698 }, - { url = "https://files.pythonhosted.org/packages/ce/ed/853e36d5a33c24544cfa46585895547de152dfef0b5c79fa675f6e4b7b87/aiohttp-3.11.16-cp313-cp313-win_amd64.whl", hash = "sha256:42864e70a248f5f6a49fdaf417d9bc62d6e4d8ee9695b24c5916cb4bb666c802", size = 436395 }, +sdist = { url = "https://files.pythonhosted.org/packages/f1/d9/1c4721d143e14af753f2bf5e3b681883e1f24b592c0482df6fa6e33597fa/aiohttp-3.11.16.tar.gz", hash = "sha256:16f8a2c9538c14a557b4d309ed4d0a7c60f0253e8ed7b6c9a2859a7582f8b1b8", size = 7676826, upload-time = "2025-04-02T02:17:44.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/38/100d01cbc60553743baf0fba658cb125f8ad674a8a771f765cdc155a890d/aiohttp-3.11.16-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:911a6e91d08bb2c72938bc17f0a2d97864c531536b7832abee6429d5296e5b27", size = 704881, upload-time = "2025-04-02T02:16:09.26Z" }, + { url = "https://files.pythonhosted.org/packages/21/ed/b4102bb6245e36591209e29f03fe87e7956e54cb604ee12e20f7eb47f994/aiohttp-3.11.16-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac13b71761e49d5f9e4d05d33683bbafef753e876e8e5a7ef26e937dd766713", size = 464564, upload-time = "2025-04-02T02:16:10.781Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e1/a9ab6c47b62ecee080eeb33acd5352b40ecad08fb2d0779bcc6739271745/aiohttp-3.11.16-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fd36c119c5d6551bce374fcb5c19269638f8d09862445f85a5a48596fd59f4bb", size = 456548, upload-time = "2025-04-02T02:16:12.764Z" }, + { url = "https://files.pythonhosted.org/packages/80/ad/216c6f71bdff2becce6c8776f0aa32cb0fa5d83008d13b49c3208d2e4016/aiohttp-3.11.16-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d489d9778522fbd0f8d6a5c6e48e3514f11be81cb0a5954bdda06f7e1594b321", size = 1691749, upload-time = "2025-04-02T02:16:14.304Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ea/7df7bcd3f4e734301605f686ffc87993f2d51b7acb6bcc9b980af223f297/aiohttp-3.11.16-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:69a2cbd61788d26f8f1e626e188044834f37f6ae3f937bd9f08b65fc9d7e514e", size = 1736874, upload-time = "2025-04-02T02:16:16.538Z" }, + { url = "https://files.pythonhosted.org/packages/51/41/c7724b9c87a29b7cfd1202ec6446bae8524a751473d25e2ff438bc9a02bf/aiohttp-3.11.16-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd464ba806e27ee24a91362ba3621bfc39dbbb8b79f2e1340201615197370f7c", size = 1786885, upload-time = "2025-04-02T02:16:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/86/b3/f61f8492fa6569fa87927ad35a40c159408862f7e8e70deaaead349e2fba/aiohttp-3.11.16-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ce63ae04719513dd2651202352a2beb9f67f55cb8490c40f056cea3c5c355ce", size = 1698059, upload-time = "2025-04-02T02:16:20.234Z" }, + { url = "https://files.pythonhosted.org/packages/ce/be/7097cf860a9ce8bbb0e8960704e12869e111abcd3fbd245153373079ccec/aiohttp-3.11.16-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09b00dd520d88eac9d1768439a59ab3d145065c91a8fab97f900d1b5f802895e", size = 1626527, upload-time = "2025-04-02T02:16:22.092Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1d/aaa841c340e8c143a8d53a1f644c2a2961c58cfa26e7b398d6bf75cf5d23/aiohttp-3.11.16-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7f6428fee52d2bcf96a8aa7b62095b190ee341ab0e6b1bcf50c615d7966fd45b", size = 1644036, upload-time = "2025-04-02T02:16:23.707Z" }, + { url = "https://files.pythonhosted.org/packages/2c/88/59d870f76e9345e2b149f158074e78db457985c2b4da713038d9da3020a8/aiohttp-3.11.16-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:13ceac2c5cdcc3f64b9015710221ddf81c900c5febc505dbd8f810e770011540", size = 1685270, upload-time = "2025-04-02T02:16:25.874Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b1/c6686948d4c79c3745595efc469a9f8a43cab3c7efc0b5991be65d9e8cb8/aiohttp-3.11.16-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fadbb8f1d4140825069db3fedbbb843290fd5f5bc0a5dbd7eaf81d91bf1b003b", size = 1650852, upload-time = "2025-04-02T02:16:27.556Z" }, + { url = "https://files.pythonhosted.org/packages/fe/94/3e42a6916fd3441721941e0f1b8438e1ce2a4c49af0e28e0d3c950c9b3c9/aiohttp-3.11.16-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:6a792ce34b999fbe04a7a71a90c74f10c57ae4c51f65461a411faa70e154154e", size = 1704481, upload-time = "2025-04-02T02:16:29.573Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6d/6ab5854ff59b27075c7a8c610597d2b6c38945f9a1284ee8758bc3720ff6/aiohttp-3.11.16-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f4065145bf69de124accdd17ea5f4dc770da0a6a6e440c53f6e0a8c27b3e635c", size = 1735370, upload-time = "2025-04-02T02:16:31.191Z" }, + { url = "https://files.pythonhosted.org/packages/73/2a/08a68eec3c99a6659067d271d7553e4d490a0828d588e1daa3970dc2b771/aiohttp-3.11.16-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fa73e8c2656a3653ae6c307b3f4e878a21f87859a9afab228280ddccd7369d71", size = 1697619, upload-time = "2025-04-02T02:16:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/61/d5/fea8dbbfb0cd68fbb56f0ae913270a79422d9a41da442a624febf72d2aaf/aiohttp-3.11.16-cp312-cp312-win32.whl", hash = "sha256:f244b8e541f414664889e2c87cac11a07b918cb4b540c36f7ada7bfa76571ea2", size = 411710, upload-time = "2025-04-02T02:16:34.525Z" }, + { url = "https://files.pythonhosted.org/packages/33/fb/41cde15fbe51365024550bf77b95a4fc84ef41365705c946da0421f0e1e0/aiohttp-3.11.16-cp312-cp312-win_amd64.whl", hash = "sha256:23a15727fbfccab973343b6d1b7181bfb0b4aa7ae280f36fd2f90f5476805682", size = 438012, upload-time = "2025-04-02T02:16:36.103Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/7c712b2d9fb4d5e5fd6d12f9ab76e52baddfee71e3c8203ca7a7559d7f51/aiohttp-3.11.16-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a3814760a1a700f3cfd2f977249f1032301d0a12c92aba74605cfa6ce9f78489", size = 698005, upload-time = "2025-04-02T02:16:37.923Z" }, + { url = "https://files.pythonhosted.org/packages/51/3e/61057814f7247666d43ac538abcd6335b022869ade2602dab9bf33f607d2/aiohttp-3.11.16-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9b751a6306f330801665ae69270a8a3993654a85569b3469662efaad6cf5cc50", size = 461106, upload-time = "2025-04-02T02:16:39.961Z" }, + { url = "https://files.pythonhosted.org/packages/4f/85/6b79fb0ea6e913d596d5b949edc2402b20803f51b1a59e1bbc5bb7ba7569/aiohttp-3.11.16-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ad497f38a0d6c329cb621774788583ee12321863cd4bd9feee1effd60f2ad133", size = 453394, upload-time = "2025-04-02T02:16:41.562Z" }, + { url = "https://files.pythonhosted.org/packages/4b/04/e1bb3fcfbd2c26753932c759593a32299aff8625eaa0bf8ff7d9c0c34a36/aiohttp-3.11.16-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca37057625693d097543bd88076ceebeb248291df9d6ca8481349efc0b05dcd0", size = 1666643, upload-time = "2025-04-02T02:16:43.62Z" }, + { url = "https://files.pythonhosted.org/packages/0e/27/97bc0fdd1f439b8f060beb3ba8fb47b908dc170280090801158381ad7942/aiohttp-3.11.16-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a5abcbba9f4b463a45c8ca8b7720891200658f6f46894f79517e6cd11f3405ca", size = 1721948, upload-time = "2025-04-02T02:16:45.617Z" }, + { url = "https://files.pythonhosted.org/packages/2c/4f/bc4c5119e75c05ef15c5670ef1563bbe25d4ed4893b76c57b0184d815e8b/aiohttp-3.11.16-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f420bfe862fb357a6d76f2065447ef6f484bc489292ac91e29bc65d2d7a2c84d", size = 1774454, upload-time = "2025-04-02T02:16:48.562Z" }, + { url = "https://files.pythonhosted.org/packages/73/5b/54b42b2150bb26fdf795464aa55ceb1a49c85f84e98e6896d211eabc6670/aiohttp-3.11.16-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58ede86453a6cf2d6ce40ef0ca15481677a66950e73b0a788917916f7e35a0bb", size = 1677785, upload-time = "2025-04-02T02:16:50.367Z" }, + { url = "https://files.pythonhosted.org/packages/10/ee/a0fe68916d3f82eae199b8535624cf07a9c0a0958c7a76e56dd21140487a/aiohttp-3.11.16-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fdec0213244c39973674ca2a7f5435bf74369e7d4e104d6c7473c81c9bcc8c4", size = 1608456, upload-time = "2025-04-02T02:16:52.158Z" }, + { url = "https://files.pythonhosted.org/packages/8b/48/83afd779242b7cf7e1ceed2ff624a86d3221e17798061cf9a79e0b246077/aiohttp-3.11.16-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72b1b03fb4655c1960403c131740755ec19c5898c82abd3961c364c2afd59fe7", size = 1622424, upload-time = "2025-04-02T02:16:54.386Z" }, + { url = "https://files.pythonhosted.org/packages/6f/27/452f1d5fca1f516f9f731539b7f5faa9e9d3bf8a3a6c3cd7c4b031f20cbd/aiohttp-3.11.16-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:780df0d837276276226a1ff803f8d0fa5f8996c479aeef52eb040179f3156cbd", size = 1660943, upload-time = "2025-04-02T02:16:56.887Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e1/5c7d63143b8d00c83b958b9e78e7048c4a69903c760c1e329bf02bac57a1/aiohttp-3.11.16-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ecdb8173e6c7aa09eee342ac62e193e6904923bd232e76b4157ac0bfa670609f", size = 1622797, upload-time = "2025-04-02T02:16:58.676Z" }, + { url = "https://files.pythonhosted.org/packages/46/9e/2ac29cca2746ee8e449e73cd2fcb3d454467393ec03a269d50e49af743f1/aiohttp-3.11.16-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6db7458ab89c7d80bc1f4e930cc9df6edee2200127cfa6f6e080cf619eddfbd", size = 1687162, upload-time = "2025-04-02T02:17:01.076Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6b/eaa6768e02edebaf37d77f4ffb74dd55f5cbcbb6a0dbf798ccec7b0ac23b/aiohttp-3.11.16-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2540ddc83cc724b13d1838026f6a5ad178510953302a49e6d647f6e1de82bc34", size = 1718518, upload-time = "2025-04-02T02:17:03.388Z" }, + { url = "https://files.pythonhosted.org/packages/e5/18/dda87cbad29472a51fa058d6d8257dfce168289adaeb358b86bd93af3b20/aiohttp-3.11.16-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3b4e6db8dc4879015b9955778cfb9881897339c8fab7b3676f8433f849425913", size = 1675254, upload-time = "2025-04-02T02:17:05.579Z" }, + { url = "https://files.pythonhosted.org/packages/32/d9/d2fb08c614df401d92c12fcbc60e6e879608d5e8909ef75c5ad8d4ad8aa7/aiohttp-3.11.16-cp313-cp313-win32.whl", hash = "sha256:493910ceb2764f792db4dc6e8e4b375dae1b08f72e18e8f10f18b34ca17d0979", size = 410698, upload-time = "2025-04-02T02:17:07.499Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ed/853e36d5a33c24544cfa46585895547de152dfef0b5c79fa675f6e4b7b87/aiohttp-3.11.16-cp313-cp313-win_amd64.whl", hash = "sha256:42864e70a248f5f6a49fdaf417d9bc62d6e4d8ee9695b24c5916cb4bb666c802", size = 436395, upload-time = "2025-04-02T02:17:09.566Z" }, ] [[package]] @@ -67,18 +67,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "frozenlist" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/b5/6d55e80f6d8a08ce22b982eafa278d823b541c925f11ee774b0b9c43473d/aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54", size = 19424 } +sdist = { url = "https://files.pythonhosted.org/packages/ba/b5/6d55e80f6d8a08ce22b982eafa278d823b541c925f11ee774b0b9c43473d/aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54", size = 19424, upload-time = "2024-12-13T17:10:40.86Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597 }, + { url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597, upload-time = "2024-12-13T17:10:38.469Z" }, ] [[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 } +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 }, + { 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]] @@ -90,62 +90,62 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 } +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 }, + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, ] [[package]] name = "attrs" version = "25.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032 } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815 }, + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, ] [[package]] name = "certifi" version = "2025.1.31" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } +sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577, upload-time = "2025-01-31T02:16:47.166Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, + { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393, upload-time = "2025-01-31T02:16:45.015Z" }, ] [[package]] name = "charset-normalizer" version = "3.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 }, - { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 }, - { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 }, - { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 }, - { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 }, - { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 }, - { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 }, - { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 }, - { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 }, - { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 }, - { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 }, - { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 }, - { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 }, - { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, - { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, - { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, - { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 }, - { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 }, - { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 }, - { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 }, - { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 }, - { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 }, - { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 }, - { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 }, - { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 }, - { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 }, - { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, +sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188, upload-time = "2024-12-24T18:12:35.43Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105, upload-time = "2024-12-24T18:10:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404, upload-time = "2024-12-24T18:10:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423, upload-time = "2024-12-24T18:10:45.492Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184, upload-time = "2024-12-24T18:10:47.898Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268, upload-time = "2024-12-24T18:10:50.589Z" }, + { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601, upload-time = "2024-12-24T18:10:52.541Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098, upload-time = "2024-12-24T18:10:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520, upload-time = "2024-12-24T18:10:55.048Z" }, + { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852, upload-time = "2024-12-24T18:10:57.647Z" }, + { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488, upload-time = "2024-12-24T18:10:59.43Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192, upload-time = "2024-12-24T18:11:00.676Z" }, + { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550, upload-time = "2024-12-24T18:11:01.952Z" }, + { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785, upload-time = "2024-12-24T18:11:03.142Z" }, + { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698, upload-time = "2024-12-24T18:11:05.834Z" }, + { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162, upload-time = "2024-12-24T18:11:07.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263, upload-time = "2024-12-24T18:11:08.374Z" }, + { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966, upload-time = "2024-12-24T18:11:09.831Z" }, + { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992, upload-time = "2024-12-24T18:11:12.03Z" }, + { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162, upload-time = "2024-12-24T18:11:13.372Z" }, + { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972, upload-time = "2024-12-24T18:11:14.628Z" }, + { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095, upload-time = "2024-12-24T18:11:17.672Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668, upload-time = "2024-12-24T18:11:18.989Z" }, + { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073, upload-time = "2024-12-24T18:11:21.507Z" }, + { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732, upload-time = "2024-12-24T18:11:22.774Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391, upload-time = "2024-12-24T18:11:24.139Z" }, + { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702, upload-time = "2024-12-24T18:11:26.535Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767, upload-time = "2024-12-24T18:12:32.852Z" }, ] [[package]] @@ -155,119 +155,105 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, ] [[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 } +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 }, + { 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 = "distro" version = "1.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 }, -] - -[[package]] -name = "docker" -version = "7.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pywin32", marker = "sys_platform == 'win32'" }, - { name = "requests" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834 } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774 }, + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] [[package]] name = "docstring-parser" version = "0.16" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/08/12/9c22a58c0b1e29271051222d8906257616da84135af9ed167c9e28f85cb3/docstring_parser-0.16.tar.gz", hash = "sha256:538beabd0af1e2db0146b6bd3caa526c35a34d61af9fd2887f3a8a27a739aa6e", size = 26565 } +sdist = { url = "https://files.pythonhosted.org/packages/08/12/9c22a58c0b1e29271051222d8906257616da84135af9ed167c9e28f85cb3/docstring_parser-0.16.tar.gz", hash = "sha256:538beabd0af1e2db0146b6bd3caa526c35a34d61af9fd2887f3a8a27a739aa6e", size = 26565, upload-time = "2024-03-15T10:39:44.419Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/7c/e9fcff7623954d86bdc17782036cbf715ecab1bec4847c008557affe1ca8/docstring_parser-0.16-py3-none-any.whl", hash = "sha256:bf0a1387354d3691d102edef7ec124f219ef639982d096e26e3b60aeffa90637", size = 36533 }, + { url = "https://files.pythonhosted.org/packages/d5/7c/e9fcff7623954d86bdc17782036cbf715ecab1bec4847c008557affe1ca8/docstring_parser-0.16-py3-none-any.whl", hash = "sha256:bf0a1387354d3691d102edef7ec124f219ef639982d096e26e3b60aeffa90637", size = 36533, upload-time = "2024-03-15T10:39:41.527Z" }, ] [[package]] name = "frozenlist" version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/f4/d744cba2da59b5c1d88823cf9e8a6c74e4659e2b27604ed973be2a0bf5ab/frozenlist-1.6.0.tar.gz", hash = "sha256:b99655c32c1c8e06d111e7f41c06c29a5318cb1835df23a45518e02a47c63b68", size = 42831 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/8a/289b7d0de2fbac832ea80944d809759976f661557a38bb8e77db5d9f79b7/frozenlist-1.6.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c5b9e42ace7d95bf41e19b87cec8f262c41d3510d8ad7514ab3862ea2197bfb1", size = 160193 }, - { url = "https://files.pythonhosted.org/packages/19/80/2fd17d322aec7f430549f0669f599997174f93ee17929ea5b92781ec902c/frozenlist-1.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ca9973735ce9f770d24d5484dcb42f68f135351c2fc81a7a9369e48cf2998a29", size = 123831 }, - { url = "https://files.pythonhosted.org/packages/99/06/f5812da431273f78c6543e0b2f7de67dfd65eb0a433978b2c9c63d2205e4/frozenlist-1.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ac40ec76041c67b928ca8aaffba15c2b2ee3f5ae8d0cb0617b5e63ec119ca25", size = 121862 }, - { url = "https://files.pythonhosted.org/packages/d0/31/9e61c6b5fc493cf24d54881731204d27105234d09878be1a5983182cc4a5/frozenlist-1.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95b7a8a3180dfb280eb044fdec562f9b461614c0ef21669aea6f1d3dac6ee576", size = 316361 }, - { url = "https://files.pythonhosted.org/packages/9d/55/22ca9362d4f0222324981470fd50192be200154d51509ee6eb9baa148e96/frozenlist-1.6.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c444d824e22da6c9291886d80c7d00c444981a72686e2b59d38b285617cb52c8", size = 307115 }, - { url = "https://files.pythonhosted.org/packages/ae/39/4fff42920a57794881e7bb3898dc7f5f539261711ea411b43bba3cde8b79/frozenlist-1.6.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb52c8166499a8150bfd38478248572c924c003cbb45fe3bcd348e5ac7c000f9", size = 322505 }, - { url = "https://files.pythonhosted.org/packages/55/f2/88c41f374c1e4cf0092a5459e5f3d6a1e17ed274c98087a76487783df90c/frozenlist-1.6.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b35298b2db9c2468106278537ee529719228950a5fdda686582f68f247d1dc6e", size = 322666 }, - { url = "https://files.pythonhosted.org/packages/75/51/034eeb75afdf3fd03997856195b500722c0b1a50716664cde64e28299c4b/frozenlist-1.6.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d108e2d070034f9d57210f22fefd22ea0d04609fc97c5f7f5a686b3471028590", size = 302119 }, - { url = "https://files.pythonhosted.org/packages/2b/a6/564ecde55ee633270a793999ef4fd1d2c2b32b5a7eec903b1012cb7c5143/frozenlist-1.6.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e1be9111cb6756868ac242b3c2bd1f09d9aea09846e4f5c23715e7afb647103", size = 316226 }, - { url = "https://files.pythonhosted.org/packages/f1/c8/6c0682c32377f402b8a6174fb16378b683cf6379ab4d2827c580892ab3c7/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:94bb451c664415f02f07eef4ece976a2c65dcbab9c2f1705b7031a3a75349d8c", size = 312788 }, - { url = "https://files.pythonhosted.org/packages/b6/b8/10fbec38f82c5d163ca1750bfff4ede69713badf236a016781cf1f10a0f0/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d1a686d0b0949182b8faddea596f3fc11f44768d1f74d4cad70213b2e139d821", size = 325914 }, - { url = "https://files.pythonhosted.org/packages/62/ca/2bf4f3a1bd40cdedd301e6ecfdbb291080d5afc5f9ce350c0739f773d6b9/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ea8e59105d802c5a38bdbe7362822c522230b3faba2aa35c0fa1765239b7dd70", size = 305283 }, - { url = "https://files.pythonhosted.org/packages/09/64/20cc13ccf94abc2a1f482f74ad210703dc78a590d0b805af1c9aa67f76f9/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:abc4e880a9b920bc5020bf6a431a6bb40589d9bca3975c980495f63632e8382f", size = 319264 }, - { url = "https://files.pythonhosted.org/packages/20/ff/86c6a2bbe98cfc231519f5e6d712a0898488ceac804a917ce014f32e68f6/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9a79713adfe28830f27a3c62f6b5406c37376c892b05ae070906f07ae4487046", size = 326482 }, - { url = "https://files.pythonhosted.org/packages/2f/da/8e381f66367d79adca245d1d71527aac774e30e291d41ef161ce2d80c38e/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a0318c2068e217a8f5e3b85e35899f5a19e97141a45bb925bb357cfe1daf770", size = 318248 }, - { url = "https://files.pythonhosted.org/packages/39/24/1a1976563fb476ab6f0fa9fefaac7616a4361dbe0461324f9fd7bf425dbe/frozenlist-1.6.0-cp312-cp312-win32.whl", hash = "sha256:853ac025092a24bb3bf09ae87f9127de9fe6e0c345614ac92536577cf956dfcc", size = 115161 }, - { url = "https://files.pythonhosted.org/packages/80/2e/fb4ed62a65f8cd66044706b1013f0010930d8cbb0729a2219561ea075434/frozenlist-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:2bdfe2d7e6c9281c6e55523acd6c2bf77963cb422fdc7d142fb0cb6621b66878", size = 120548 }, - { url = "https://files.pythonhosted.org/packages/6f/e5/04c7090c514d96ca00887932417f04343ab94904a56ab7f57861bf63652d/frozenlist-1.6.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1d7fb014fe0fbfee3efd6a94fc635aeaa68e5e1720fe9e57357f2e2c6e1a647e", size = 158182 }, - { url = "https://files.pythonhosted.org/packages/e9/8f/60d0555c61eec855783a6356268314d204137f5e0c53b59ae2fc28938c99/frozenlist-1.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01bcaa305a0fdad12745502bfd16a1c75b14558dabae226852f9159364573117", size = 122838 }, - { url = "https://files.pythonhosted.org/packages/5a/a7/d0ec890e3665b4b3b7c05dc80e477ed8dc2e2e77719368e78e2cd9fec9c8/frozenlist-1.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b314faa3051a6d45da196a2c495e922f987dc848e967d8cfeaee8a0328b1cd4", size = 120980 }, - { url = "https://files.pythonhosted.org/packages/cc/19/9b355a5e7a8eba903a008579964192c3e427444752f20b2144b10bb336df/frozenlist-1.6.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da62fecac21a3ee10463d153549d8db87549a5e77eefb8c91ac84bb42bb1e4e3", size = 305463 }, - { url = "https://files.pythonhosted.org/packages/9c/8d/5b4c758c2550131d66935ef2fa700ada2461c08866aef4229ae1554b93ca/frozenlist-1.6.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1eb89bf3454e2132e046f9599fbcf0a4483ed43b40f545551a39316d0201cd1", size = 297985 }, - { url = "https://files.pythonhosted.org/packages/48/2c/537ec09e032b5865715726b2d1d9813e6589b571d34d01550c7aeaad7e53/frozenlist-1.6.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18689b40cb3936acd971f663ccb8e2589c45db5e2c5f07e0ec6207664029a9c", size = 311188 }, - { url = "https://files.pythonhosted.org/packages/31/2f/1aa74b33f74d54817055de9a4961eff798f066cdc6f67591905d4fc82a84/frozenlist-1.6.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e67ddb0749ed066b1a03fba812e2dcae791dd50e5da03be50b6a14d0c1a9ee45", size = 311874 }, - { url = "https://files.pythonhosted.org/packages/bf/f0/cfec18838f13ebf4b37cfebc8649db5ea71a1b25dacd691444a10729776c/frozenlist-1.6.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fc5e64626e6682638d6e44398c9baf1d6ce6bc236d40b4b57255c9d3f9761f1f", size = 291897 }, - { url = "https://files.pythonhosted.org/packages/ea/a5/deb39325cbbea6cd0a46db8ccd76150ae2fcbe60d63243d9df4a0b8c3205/frozenlist-1.6.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:437cfd39564744ae32ad5929e55b18ebd88817f9180e4cc05e7d53b75f79ce85", size = 305799 }, - { url = "https://files.pythonhosted.org/packages/78/22/6ddec55c5243a59f605e4280f10cee8c95a449f81e40117163383829c241/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62dd7df78e74d924952e2feb7357d826af8d2f307557a779d14ddf94d7311be8", size = 302804 }, - { url = "https://files.pythonhosted.org/packages/5d/b7/d9ca9bab87f28855063c4d202936800219e39db9e46f9fb004d521152623/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a66781d7e4cddcbbcfd64de3d41a61d6bdde370fc2e38623f30b2bd539e84a9f", size = 316404 }, - { url = "https://files.pythonhosted.org/packages/a6/3a/1255305db7874d0b9eddb4fe4a27469e1fb63720f1fc6d325a5118492d18/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:482fe06e9a3fffbcd41950f9d890034b4a54395c60b5e61fae875d37a699813f", size = 295572 }, - { url = "https://files.pythonhosted.org/packages/2a/f2/8d38eeee39a0e3a91b75867cc102159ecccf441deb6ddf67be96d3410b84/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e4f9373c500dfc02feea39f7a56e4f543e670212102cc2eeb51d3a99c7ffbde6", size = 307601 }, - { url = "https://files.pythonhosted.org/packages/38/04/80ec8e6b92f61ef085422d7b196822820404f940950dde5b2e367bede8bc/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e69bb81de06827147b7bfbaeb284d85219fa92d9f097e32cc73675f279d70188", size = 314232 }, - { url = "https://files.pythonhosted.org/packages/3a/58/93b41fb23e75f38f453ae92a2f987274c64637c450285577bd81c599b715/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7613d9977d2ab4a9141dde4a149f4357e4065949674c5649f920fec86ecb393e", size = 308187 }, - { url = "https://files.pythonhosted.org/packages/6a/a2/e64df5c5aa36ab3dee5a40d254f3e471bb0603c225f81664267281c46a2d/frozenlist-1.6.0-cp313-cp313-win32.whl", hash = "sha256:4def87ef6d90429f777c9d9de3961679abf938cb6b7b63d4a7eb8a268babfce4", size = 114772 }, - { url = "https://files.pythonhosted.org/packages/a0/77/fead27441e749b2d574bb73d693530d59d520d4b9e9679b8e3cb779d37f2/frozenlist-1.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:37a8a52c3dfff01515e9bbbee0e6063181362f9de3db2ccf9bc96189b557cbfd", size = 119847 }, - { url = "https://files.pythonhosted.org/packages/df/bd/cc6d934991c1e5d9cafda83dfdc52f987c7b28343686aef2e58a9cf89f20/frozenlist-1.6.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:46138f5a0773d064ff663d273b309b696293d7a7c00a0994c5c13a5078134b64", size = 174937 }, - { url = "https://files.pythonhosted.org/packages/f2/a2/daf945f335abdbfdd5993e9dc348ef4507436936ab3c26d7cfe72f4843bf/frozenlist-1.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f88bc0a2b9c2a835cb888b32246c27cdab5740059fb3688852bf91e915399b91", size = 136029 }, - { url = "https://files.pythonhosted.org/packages/51/65/4c3145f237a31247c3429e1c94c384d053f69b52110a0d04bfc8afc55fb2/frozenlist-1.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:777704c1d7655b802c7850255639672e90e81ad6fa42b99ce5ed3fbf45e338dd", size = 134831 }, - { url = "https://files.pythonhosted.org/packages/77/38/03d316507d8dea84dfb99bdd515ea245628af964b2bf57759e3c9205cc5e/frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85ef8d41764c7de0dcdaf64f733a27352248493a85a80661f3c678acd27e31f2", size = 392981 }, - { url = "https://files.pythonhosted.org/packages/37/02/46285ef9828f318ba400a51d5bb616ded38db8466836a9cfa39f3903260b/frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:da5cb36623f2b846fb25009d9d9215322318ff1c63403075f812b3b2876c8506", size = 371999 }, - { url = "https://files.pythonhosted.org/packages/0d/64/1212fea37a112c3c5c05bfb5f0a81af4836ce349e69be75af93f99644da9/frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cbb56587a16cf0fb8acd19e90ff9924979ac1431baea8681712716a8337577b0", size = 392200 }, - { url = "https://files.pythonhosted.org/packages/81/ce/9a6ea1763e3366e44a5208f76bf37c76c5da570772375e4d0be85180e588/frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6154c3ba59cda3f954c6333025369e42c3acd0c6e8b6ce31eb5c5b8116c07e0", size = 390134 }, - { url = "https://files.pythonhosted.org/packages/bc/36/939738b0b495b2c6d0c39ba51563e453232813042a8d908b8f9544296c29/frozenlist-1.6.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e8246877afa3f1ae5c979fe85f567d220f86a50dc6c493b9b7d8191181ae01e", size = 365208 }, - { url = "https://files.pythonhosted.org/packages/b4/8b/939e62e93c63409949c25220d1ba8e88e3960f8ef6a8d9ede8f94b459d27/frozenlist-1.6.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b0f6cce16306d2e117cf9db71ab3a9e8878a28176aeaf0dbe35248d97b28d0c", size = 385548 }, - { url = "https://files.pythonhosted.org/packages/62/38/22d2873c90102e06a7c5a3a5b82ca47e393c6079413e8a75c72bff067fa8/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1b8e8cd8032ba266f91136d7105706ad57770f3522eac4a111d77ac126a25a9b", size = 391123 }, - { url = "https://files.pythonhosted.org/packages/44/78/63aaaf533ee0701549500f6d819be092c6065cb5c577edb70c09df74d5d0/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:e2ada1d8515d3ea5378c018a5f6d14b4994d4036591a52ceaf1a1549dec8e1ad", size = 394199 }, - { url = "https://files.pythonhosted.org/packages/54/45/71a6b48981d429e8fbcc08454dc99c4c2639865a646d549812883e9c9dd3/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:cdb2c7f071e4026c19a3e32b93a09e59b12000751fc9b0b7758da899e657d215", size = 373854 }, - { url = "https://files.pythonhosted.org/packages/3f/f3/dbf2a5e11736ea81a66e37288bf9f881143a7822b288a992579ba1b4204d/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:03572933a1969a6d6ab509d509e5af82ef80d4a5d4e1e9f2e1cdd22c77a3f4d2", size = 395412 }, - { url = "https://files.pythonhosted.org/packages/b3/f1/c63166806b331f05104d8ea385c4acd511598568b1f3e4e8297ca54f2676/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:77effc978947548b676c54bbd6a08992759ea6f410d4987d69feea9cd0919911", size = 394936 }, - { url = "https://files.pythonhosted.org/packages/ef/ea/4f3e69e179a430473eaa1a75ff986526571215fefc6b9281cdc1f09a4eb8/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a2bda8be77660ad4089caf2223fdbd6db1858462c4b85b67fbfa22102021e497", size = 391459 }, - { url = "https://files.pythonhosted.org/packages/d3/c3/0fc2c97dea550df9afd072a37c1e95421652e3206bbeaa02378b24c2b480/frozenlist-1.6.0-cp313-cp313t-win32.whl", hash = "sha256:a4d96dc5bcdbd834ec6b0f91027817214216b5b30316494d2b1aebffb87c534f", size = 128797 }, - { url = "https://files.pythonhosted.org/packages/ae/f5/79c9320c5656b1965634fe4be9c82b12a3305bdbc58ad9cb941131107b20/frozenlist-1.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e18036cb4caa17ea151fd5f3d70be9d354c99eb8cf817a3ccde8a7873b074348", size = 134709 }, - { url = "https://files.pythonhosted.org/packages/71/3e/b04a0adda73bd52b390d730071c0d577073d3d26740ee1bad25c3ad0f37b/frozenlist-1.6.0-py3-none-any.whl", hash = "sha256:535eec9987adb04701266b92745d6cdcef2e77669299359c3009c3404dd5d191", size = 12404 }, +sdist = { url = "https://files.pythonhosted.org/packages/ee/f4/d744cba2da59b5c1d88823cf9e8a6c74e4659e2b27604ed973be2a0bf5ab/frozenlist-1.6.0.tar.gz", hash = "sha256:b99655c32c1c8e06d111e7f41c06c29a5318cb1835df23a45518e02a47c63b68", size = 42831, upload-time = "2025-04-17T22:38:53.099Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/8a/289b7d0de2fbac832ea80944d809759976f661557a38bb8e77db5d9f79b7/frozenlist-1.6.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c5b9e42ace7d95bf41e19b87cec8f262c41d3510d8ad7514ab3862ea2197bfb1", size = 160193, upload-time = "2025-04-17T22:36:47.382Z" }, + { url = "https://files.pythonhosted.org/packages/19/80/2fd17d322aec7f430549f0669f599997174f93ee17929ea5b92781ec902c/frozenlist-1.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ca9973735ce9f770d24d5484dcb42f68f135351c2fc81a7a9369e48cf2998a29", size = 123831, upload-time = "2025-04-17T22:36:49.401Z" }, + { url = "https://files.pythonhosted.org/packages/99/06/f5812da431273f78c6543e0b2f7de67dfd65eb0a433978b2c9c63d2205e4/frozenlist-1.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ac40ec76041c67b928ca8aaffba15c2b2ee3f5ae8d0cb0617b5e63ec119ca25", size = 121862, upload-time = "2025-04-17T22:36:51.899Z" }, + { url = "https://files.pythonhosted.org/packages/d0/31/9e61c6b5fc493cf24d54881731204d27105234d09878be1a5983182cc4a5/frozenlist-1.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95b7a8a3180dfb280eb044fdec562f9b461614c0ef21669aea6f1d3dac6ee576", size = 316361, upload-time = "2025-04-17T22:36:53.402Z" }, + { url = "https://files.pythonhosted.org/packages/9d/55/22ca9362d4f0222324981470fd50192be200154d51509ee6eb9baa148e96/frozenlist-1.6.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c444d824e22da6c9291886d80c7d00c444981a72686e2b59d38b285617cb52c8", size = 307115, upload-time = "2025-04-17T22:36:55.016Z" }, + { url = "https://files.pythonhosted.org/packages/ae/39/4fff42920a57794881e7bb3898dc7f5f539261711ea411b43bba3cde8b79/frozenlist-1.6.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb52c8166499a8150bfd38478248572c924c003cbb45fe3bcd348e5ac7c000f9", size = 322505, upload-time = "2025-04-17T22:36:57.12Z" }, + { url = "https://files.pythonhosted.org/packages/55/f2/88c41f374c1e4cf0092a5459e5f3d6a1e17ed274c98087a76487783df90c/frozenlist-1.6.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b35298b2db9c2468106278537ee529719228950a5fdda686582f68f247d1dc6e", size = 322666, upload-time = "2025-04-17T22:36:58.735Z" }, + { url = "https://files.pythonhosted.org/packages/75/51/034eeb75afdf3fd03997856195b500722c0b1a50716664cde64e28299c4b/frozenlist-1.6.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d108e2d070034f9d57210f22fefd22ea0d04609fc97c5f7f5a686b3471028590", size = 302119, upload-time = "2025-04-17T22:37:00.512Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a6/564ecde55ee633270a793999ef4fd1d2c2b32b5a7eec903b1012cb7c5143/frozenlist-1.6.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e1be9111cb6756868ac242b3c2bd1f09d9aea09846e4f5c23715e7afb647103", size = 316226, upload-time = "2025-04-17T22:37:02.102Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/6c0682c32377f402b8a6174fb16378b683cf6379ab4d2827c580892ab3c7/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:94bb451c664415f02f07eef4ece976a2c65dcbab9c2f1705b7031a3a75349d8c", size = 312788, upload-time = "2025-04-17T22:37:03.578Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b8/10fbec38f82c5d163ca1750bfff4ede69713badf236a016781cf1f10a0f0/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d1a686d0b0949182b8faddea596f3fc11f44768d1f74d4cad70213b2e139d821", size = 325914, upload-time = "2025-04-17T22:37:05.213Z" }, + { url = "https://files.pythonhosted.org/packages/62/ca/2bf4f3a1bd40cdedd301e6ecfdbb291080d5afc5f9ce350c0739f773d6b9/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ea8e59105d802c5a38bdbe7362822c522230b3faba2aa35c0fa1765239b7dd70", size = 305283, upload-time = "2025-04-17T22:37:06.985Z" }, + { url = "https://files.pythonhosted.org/packages/09/64/20cc13ccf94abc2a1f482f74ad210703dc78a590d0b805af1c9aa67f76f9/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:abc4e880a9b920bc5020bf6a431a6bb40589d9bca3975c980495f63632e8382f", size = 319264, upload-time = "2025-04-17T22:37:08.618Z" }, + { url = "https://files.pythonhosted.org/packages/20/ff/86c6a2bbe98cfc231519f5e6d712a0898488ceac804a917ce014f32e68f6/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9a79713adfe28830f27a3c62f6b5406c37376c892b05ae070906f07ae4487046", size = 326482, upload-time = "2025-04-17T22:37:10.196Z" }, + { url = "https://files.pythonhosted.org/packages/2f/da/8e381f66367d79adca245d1d71527aac774e30e291d41ef161ce2d80c38e/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a0318c2068e217a8f5e3b85e35899f5a19e97141a45bb925bb357cfe1daf770", size = 318248, upload-time = "2025-04-17T22:37:12.284Z" }, + { url = "https://files.pythonhosted.org/packages/39/24/1a1976563fb476ab6f0fa9fefaac7616a4361dbe0461324f9fd7bf425dbe/frozenlist-1.6.0-cp312-cp312-win32.whl", hash = "sha256:853ac025092a24bb3bf09ae87f9127de9fe6e0c345614ac92536577cf956dfcc", size = 115161, upload-time = "2025-04-17T22:37:13.902Z" }, + { url = "https://files.pythonhosted.org/packages/80/2e/fb4ed62a65f8cd66044706b1013f0010930d8cbb0729a2219561ea075434/frozenlist-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:2bdfe2d7e6c9281c6e55523acd6c2bf77963cb422fdc7d142fb0cb6621b66878", size = 120548, upload-time = "2025-04-17T22:37:15.326Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e5/04c7090c514d96ca00887932417f04343ab94904a56ab7f57861bf63652d/frozenlist-1.6.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1d7fb014fe0fbfee3efd6a94fc635aeaa68e5e1720fe9e57357f2e2c6e1a647e", size = 158182, upload-time = "2025-04-17T22:37:16.837Z" }, + { url = "https://files.pythonhosted.org/packages/e9/8f/60d0555c61eec855783a6356268314d204137f5e0c53b59ae2fc28938c99/frozenlist-1.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01bcaa305a0fdad12745502bfd16a1c75b14558dabae226852f9159364573117", size = 122838, upload-time = "2025-04-17T22:37:18.352Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a7/d0ec890e3665b4b3b7c05dc80e477ed8dc2e2e77719368e78e2cd9fec9c8/frozenlist-1.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b314faa3051a6d45da196a2c495e922f987dc848e967d8cfeaee8a0328b1cd4", size = 120980, upload-time = "2025-04-17T22:37:19.857Z" }, + { url = "https://files.pythonhosted.org/packages/cc/19/9b355a5e7a8eba903a008579964192c3e427444752f20b2144b10bb336df/frozenlist-1.6.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da62fecac21a3ee10463d153549d8db87549a5e77eefb8c91ac84bb42bb1e4e3", size = 305463, upload-time = "2025-04-17T22:37:21.328Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8d/5b4c758c2550131d66935ef2fa700ada2461c08866aef4229ae1554b93ca/frozenlist-1.6.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1eb89bf3454e2132e046f9599fbcf0a4483ed43b40f545551a39316d0201cd1", size = 297985, upload-time = "2025-04-17T22:37:23.55Z" }, + { url = "https://files.pythonhosted.org/packages/48/2c/537ec09e032b5865715726b2d1d9813e6589b571d34d01550c7aeaad7e53/frozenlist-1.6.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18689b40cb3936acd971f663ccb8e2589c45db5e2c5f07e0ec6207664029a9c", size = 311188, upload-time = "2025-04-17T22:37:25.221Z" }, + { url = "https://files.pythonhosted.org/packages/31/2f/1aa74b33f74d54817055de9a4961eff798f066cdc6f67591905d4fc82a84/frozenlist-1.6.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e67ddb0749ed066b1a03fba812e2dcae791dd50e5da03be50b6a14d0c1a9ee45", size = 311874, upload-time = "2025-04-17T22:37:26.791Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f0/cfec18838f13ebf4b37cfebc8649db5ea71a1b25dacd691444a10729776c/frozenlist-1.6.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fc5e64626e6682638d6e44398c9baf1d6ce6bc236d40b4b57255c9d3f9761f1f", size = 291897, upload-time = "2025-04-17T22:37:28.958Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a5/deb39325cbbea6cd0a46db8ccd76150ae2fcbe60d63243d9df4a0b8c3205/frozenlist-1.6.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:437cfd39564744ae32ad5929e55b18ebd88817f9180e4cc05e7d53b75f79ce85", size = 305799, upload-time = "2025-04-17T22:37:30.889Z" }, + { url = "https://files.pythonhosted.org/packages/78/22/6ddec55c5243a59f605e4280f10cee8c95a449f81e40117163383829c241/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62dd7df78e74d924952e2feb7357d826af8d2f307557a779d14ddf94d7311be8", size = 302804, upload-time = "2025-04-17T22:37:32.489Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b7/d9ca9bab87f28855063c4d202936800219e39db9e46f9fb004d521152623/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a66781d7e4cddcbbcfd64de3d41a61d6bdde370fc2e38623f30b2bd539e84a9f", size = 316404, upload-time = "2025-04-17T22:37:34.59Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3a/1255305db7874d0b9eddb4fe4a27469e1fb63720f1fc6d325a5118492d18/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:482fe06e9a3fffbcd41950f9d890034b4a54395c60b5e61fae875d37a699813f", size = 295572, upload-time = "2025-04-17T22:37:36.337Z" }, + { url = "https://files.pythonhosted.org/packages/2a/f2/8d38eeee39a0e3a91b75867cc102159ecccf441deb6ddf67be96d3410b84/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e4f9373c500dfc02feea39f7a56e4f543e670212102cc2eeb51d3a99c7ffbde6", size = 307601, upload-time = "2025-04-17T22:37:37.923Z" }, + { url = "https://files.pythonhosted.org/packages/38/04/80ec8e6b92f61ef085422d7b196822820404f940950dde5b2e367bede8bc/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e69bb81de06827147b7bfbaeb284d85219fa92d9f097e32cc73675f279d70188", size = 314232, upload-time = "2025-04-17T22:37:39.669Z" }, + { url = "https://files.pythonhosted.org/packages/3a/58/93b41fb23e75f38f453ae92a2f987274c64637c450285577bd81c599b715/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7613d9977d2ab4a9141dde4a149f4357e4065949674c5649f920fec86ecb393e", size = 308187, upload-time = "2025-04-17T22:37:41.662Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a2/e64df5c5aa36ab3dee5a40d254f3e471bb0603c225f81664267281c46a2d/frozenlist-1.6.0-cp313-cp313-win32.whl", hash = "sha256:4def87ef6d90429f777c9d9de3961679abf938cb6b7b63d4a7eb8a268babfce4", size = 114772, upload-time = "2025-04-17T22:37:43.132Z" }, + { url = "https://files.pythonhosted.org/packages/a0/77/fead27441e749b2d574bb73d693530d59d520d4b9e9679b8e3cb779d37f2/frozenlist-1.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:37a8a52c3dfff01515e9bbbee0e6063181362f9de3db2ccf9bc96189b557cbfd", size = 119847, upload-time = "2025-04-17T22:37:45.118Z" }, + { url = "https://files.pythonhosted.org/packages/df/bd/cc6d934991c1e5d9cafda83dfdc52f987c7b28343686aef2e58a9cf89f20/frozenlist-1.6.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:46138f5a0773d064ff663d273b309b696293d7a7c00a0994c5c13a5078134b64", size = 174937, upload-time = "2025-04-17T22:37:46.635Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a2/daf945f335abdbfdd5993e9dc348ef4507436936ab3c26d7cfe72f4843bf/frozenlist-1.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f88bc0a2b9c2a835cb888b32246c27cdab5740059fb3688852bf91e915399b91", size = 136029, upload-time = "2025-04-17T22:37:48.192Z" }, + { url = "https://files.pythonhosted.org/packages/51/65/4c3145f237a31247c3429e1c94c384d053f69b52110a0d04bfc8afc55fb2/frozenlist-1.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:777704c1d7655b802c7850255639672e90e81ad6fa42b99ce5ed3fbf45e338dd", size = 134831, upload-time = "2025-04-17T22:37:50.485Z" }, + { url = "https://files.pythonhosted.org/packages/77/38/03d316507d8dea84dfb99bdd515ea245628af964b2bf57759e3c9205cc5e/frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85ef8d41764c7de0dcdaf64f733a27352248493a85a80661f3c678acd27e31f2", size = 392981, upload-time = "2025-04-17T22:37:52.558Z" }, + { url = "https://files.pythonhosted.org/packages/37/02/46285ef9828f318ba400a51d5bb616ded38db8466836a9cfa39f3903260b/frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:da5cb36623f2b846fb25009d9d9215322318ff1c63403075f812b3b2876c8506", size = 371999, upload-time = "2025-04-17T22:37:54.092Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/1212fea37a112c3c5c05bfb5f0a81af4836ce349e69be75af93f99644da9/frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cbb56587a16cf0fb8acd19e90ff9924979ac1431baea8681712716a8337577b0", size = 392200, upload-time = "2025-04-17T22:37:55.951Z" }, + { url = "https://files.pythonhosted.org/packages/81/ce/9a6ea1763e3366e44a5208f76bf37c76c5da570772375e4d0be85180e588/frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6154c3ba59cda3f954c6333025369e42c3acd0c6e8b6ce31eb5c5b8116c07e0", size = 390134, upload-time = "2025-04-17T22:37:57.633Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/939738b0b495b2c6d0c39ba51563e453232813042a8d908b8f9544296c29/frozenlist-1.6.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e8246877afa3f1ae5c979fe85f567d220f86a50dc6c493b9b7d8191181ae01e", size = 365208, upload-time = "2025-04-17T22:37:59.742Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8b/939e62e93c63409949c25220d1ba8e88e3960f8ef6a8d9ede8f94b459d27/frozenlist-1.6.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b0f6cce16306d2e117cf9db71ab3a9e8878a28176aeaf0dbe35248d97b28d0c", size = 385548, upload-time = "2025-04-17T22:38:01.416Z" }, + { url = "https://files.pythonhosted.org/packages/62/38/22d2873c90102e06a7c5a3a5b82ca47e393c6079413e8a75c72bff067fa8/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1b8e8cd8032ba266f91136d7105706ad57770f3522eac4a111d77ac126a25a9b", size = 391123, upload-time = "2025-04-17T22:38:03.049Z" }, + { url = "https://files.pythonhosted.org/packages/44/78/63aaaf533ee0701549500f6d819be092c6065cb5c577edb70c09df74d5d0/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:e2ada1d8515d3ea5378c018a5f6d14b4994d4036591a52ceaf1a1549dec8e1ad", size = 394199, upload-time = "2025-04-17T22:38:04.776Z" }, + { url = "https://files.pythonhosted.org/packages/54/45/71a6b48981d429e8fbcc08454dc99c4c2639865a646d549812883e9c9dd3/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:cdb2c7f071e4026c19a3e32b93a09e59b12000751fc9b0b7758da899e657d215", size = 373854, upload-time = "2025-04-17T22:38:06.576Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f3/dbf2a5e11736ea81a66e37288bf9f881143a7822b288a992579ba1b4204d/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:03572933a1969a6d6ab509d509e5af82ef80d4a5d4e1e9f2e1cdd22c77a3f4d2", size = 395412, upload-time = "2025-04-17T22:38:08.197Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f1/c63166806b331f05104d8ea385c4acd511598568b1f3e4e8297ca54f2676/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:77effc978947548b676c54bbd6a08992759ea6f410d4987d69feea9cd0919911", size = 394936, upload-time = "2025-04-17T22:38:10.056Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ea/4f3e69e179a430473eaa1a75ff986526571215fefc6b9281cdc1f09a4eb8/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a2bda8be77660ad4089caf2223fdbd6db1858462c4b85b67fbfa22102021e497", size = 391459, upload-time = "2025-04-17T22:38:11.826Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c3/0fc2c97dea550df9afd072a37c1e95421652e3206bbeaa02378b24c2b480/frozenlist-1.6.0-cp313-cp313t-win32.whl", hash = "sha256:a4d96dc5bcdbd834ec6b0f91027817214216b5b30316494d2b1aebffb87c534f", size = 128797, upload-time = "2025-04-17T22:38:14.013Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f5/79c9320c5656b1965634fe4be9c82b12a3305bdbc58ad9cb941131107b20/frozenlist-1.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e18036cb4caa17ea151fd5f3d70be9d354c99eb8cf817a3ccde8a7873b074348", size = 134709, upload-time = "2025-04-17T22:38:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/71/3e/b04a0adda73bd52b390d730071c0d577073d3d26740ee1bad25c3ad0f37b/frozenlist-1.6.0-py3-none-any.whl", hash = "sha256:535eec9987adb04701266b92745d6cdcef2e77669299359c3009c3404dd5d191", size = 12404, upload-time = "2025-04-17T22:38:51.668Z" }, ] [[package]] name = "h11" version = "0.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418, upload-time = "2022-09-25T15:40:01.519Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259, upload-time = "2022-09-25T15:39:59.68Z" }, ] [[package]] @@ -278,9 +264,9 @@ dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } +sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196, upload-time = "2024-11-15T12:30:47.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, + { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551, upload-time = "2024-11-15T12:30:45.782Z" }, ] [[package]] @@ -293,45 +279,45 @@ dependencies = [ { 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 } +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 }, + { 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.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } +sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624, upload-time = "2023-12-22T08:01:21.083Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, + { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819, upload-time = "2023-12-22T08:01:19.89Z" }, ] [[package]] name = "humanize" version = "4.12.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/84/ae8e64a6ffe3291105e9688f4e28fa65eba7924e0fe6053d85ca00556385/humanize-4.12.2.tar.gz", hash = "sha256:ce0715740e9caacc982bb89098182cf8ded3552693a433311c6a4ce6f4e12a2c", size = 80871 } +sdist = { url = "https://files.pythonhosted.org/packages/e0/84/ae8e64a6ffe3291105e9688f4e28fa65eba7924e0fe6053d85ca00556385/humanize-4.12.2.tar.gz", hash = "sha256:ce0715740e9caacc982bb89098182cf8ded3552693a433311c6a4ce6f4e12a2c", size = 80871, upload-time = "2025-03-24T17:12:39.167Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/c7/6f89082f619c76165feb633446bd0fee32b0e0cbad00d22480e5aea26ade/humanize-4.12.2-py3-none-any.whl", hash = "sha256:e4e44dced598b7e03487f3b1c6fd5b1146c30ea55a110e71d5d4bca3e094259e", size = 128305 }, + { url = "https://files.pythonhosted.org/packages/55/c7/6f89082f619c76165feb633446bd0fee32b0e0cbad00d22480e5aea26ade/humanize-4.12.2-py3-none-any.whl", hash = "sha256:e4e44dced598b7e03487f3b1c6fd5b1146c30ea55a110e71d5d4bca3e094259e", size = 128305, upload-time = "2025-03-24T17:12:37.059Z" }, ] [[package]] name = "idna" version = "3.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] [[package]] name = "iniconfig" version = "2.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] [[package]] @@ -351,9 +337,9 @@ dependencies = [ { name = "tenacity" }, { name = "typer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f5/ff/a1b8d008c774bf88dbc13120719f4ab24b9c869b4dd30dc9bd49a9dcc58e/instructor-1.7.9.tar.gz", hash = "sha256:3b7ff9119b386ebdc3c683a8af3c6461f424b9d80795d5e12676990b8379dd8a", size = 69063860 } +sdist = { url = "https://files.pythonhosted.org/packages/f5/ff/a1b8d008c774bf88dbc13120719f4ab24b9c869b4dd30dc9bd49a9dcc58e/instructor-1.7.9.tar.gz", hash = "sha256:3b7ff9119b386ebdc3c683a8af3c6461f424b9d80795d5e12676990b8379dd8a", size = 69063860, upload-time = "2025-04-03T14:15:31.188Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/a1/aa7d1ff14070ab55f1e915566b47f8f94f7d21531276320d9a0d5ac74165/instructor-1.7.9-py3-none-any.whl", hash = "sha256:8050c3de3e38680a7fa8de03e52bb644d0cb3d5fab38fee4343977db74ed77bc", size = 86010 }, + { url = "https://files.pythonhosted.org/packages/e5/a1/aa7d1ff14070ab55f1e915566b47f8f94f7d21531276320d9a0d5ac74165/instructor-1.7.9-py3-none-any.whl", hash = "sha256:8050c3de3e38680a7fa8de03e52bb644d0cb3d5fab38fee4343977db74ed77bc", size = 86010, upload-time = "2025-04-03T14:15:23.601Z" }, ] [[package]] @@ -363,44 +349,44 @@ 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 } +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 }, + { 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 = "jiter" version = "0.8.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/70/90bc7bd3932e651486861df5c8ffea4ca7c77d28e8532ddefe2abc561a53/jiter-0.8.2.tar.gz", hash = "sha256:cd73d3e740666d0e639f678adb176fad25c1bcbdae88d8d7b857e1783bb4212d", size = 163007 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/17/c8747af8ea4e045f57d6cfd6fc180752cab9bc3de0e8a0c9ca4e8af333b1/jiter-0.8.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:e6ec2be506e7d6f9527dae9ff4b7f54e68ea44a0ef6b098256ddf895218a2f8f", size = 302027 }, - { url = "https://files.pythonhosted.org/packages/3c/c1/6da849640cd35a41e91085723b76acc818d4b7d92b0b6e5111736ce1dd10/jiter-0.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76e324da7b5da060287c54f2fabd3db5f76468006c811831f051942bf68c9d44", size = 310326 }, - { url = "https://files.pythonhosted.org/packages/06/99/a2bf660d8ccffee9ad7ed46b4f860d2108a148d0ea36043fd16f4dc37e94/jiter-0.8.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:180a8aea058f7535d1c84183c0362c710f4750bef66630c05f40c93c2b152a0f", size = 334242 }, - { url = "https://files.pythonhosted.org/packages/a7/5f/cea1c17864828731f11427b9d1ab7f24764dbd9aaf4648a7f851164d2718/jiter-0.8.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:025337859077b41548bdcbabe38698bcd93cfe10b06ff66617a48ff92c9aec60", size = 356654 }, - { url = "https://files.pythonhosted.org/packages/e9/13/62774b7e5e7f5d5043efe1d0f94ead66e6d0f894ae010adb56b3f788de71/jiter-0.8.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ecff0dc14f409599bbcafa7e470c00b80f17abc14d1405d38ab02e4b42e55b57", size = 379967 }, - { url = "https://files.pythonhosted.org/packages/ec/fb/096b34c553bb0bd3f2289d5013dcad6074948b8d55212aa13a10d44c5326/jiter-0.8.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ffd9fee7d0775ebaba131f7ca2e2d83839a62ad65e8e02fe2bd8fc975cedeb9e", size = 389252 }, - { url = "https://files.pythonhosted.org/packages/17/61/beea645c0bf398ced8b199e377b61eb999d8e46e053bb285c91c3d3eaab0/jiter-0.8.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14601dcac4889e0a1c75ccf6a0e4baf70dbc75041e51bcf8d0e9274519df6887", size = 345490 }, - { url = "https://files.pythonhosted.org/packages/d5/df/834aa17ad5dcc3cf0118821da0a0cf1589ea7db9832589278553640366bc/jiter-0.8.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:92249669925bc1c54fcd2ec73f70f2c1d6a817928480ee1c65af5f6b81cdf12d", size = 376991 }, - { url = "https://files.pythonhosted.org/packages/67/80/87d140399d382fb4ea5b3d56e7ecaa4efdca17cd7411ff904c1517855314/jiter-0.8.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e725edd0929fa79f8349ab4ec7f81c714df51dc4e991539a578e5018fa4a7152", size = 510822 }, - { url = "https://files.pythonhosted.org/packages/5c/37/3394bb47bac1ad2cb0465601f86828a0518d07828a650722e55268cdb7e6/jiter-0.8.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bf55846c7b7a680eebaf9c3c48d630e1bf51bdf76c68a5f654b8524335b0ad29", size = 503730 }, - { url = "https://files.pythonhosted.org/packages/f9/e2/253fc1fa59103bb4e3aa0665d6ceb1818df1cd7bf3eb492c4dad229b1cd4/jiter-0.8.2-cp312-cp312-win32.whl", hash = "sha256:7efe4853ecd3d6110301665a5178b9856be7e2a9485f49d91aa4d737ad2ae49e", size = 203375 }, - { url = "https://files.pythonhosted.org/packages/41/69/6d4bbe66b3b3b4507e47aa1dd5d075919ad242b4b1115b3f80eecd443687/jiter-0.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:83c0efd80b29695058d0fd2fa8a556490dbce9804eac3e281f373bbc99045f6c", size = 204740 }, - { url = "https://files.pythonhosted.org/packages/6c/b0/bfa1f6f2c956b948802ef5a021281978bf53b7a6ca54bb126fd88a5d014e/jiter-0.8.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:ca1f08b8e43dc3bd0594c992fb1fd2f7ce87f7bf0d44358198d6da8034afdf84", size = 301190 }, - { url = "https://files.pythonhosted.org/packages/a4/8f/396ddb4e292b5ea57e45ade5dc48229556b9044bad29a3b4b2dddeaedd52/jiter-0.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5672a86d55416ccd214c778efccf3266b84f87b89063b582167d803246354be4", size = 309334 }, - { url = "https://files.pythonhosted.org/packages/7f/68/805978f2f446fa6362ba0cc2e4489b945695940656edd844e110a61c98f8/jiter-0.8.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58dc9bc9767a1101f4e5e22db1b652161a225874d66f0e5cb8e2c7d1c438b587", size = 333918 }, - { url = "https://files.pythonhosted.org/packages/b3/99/0f71f7be667c33403fa9706e5b50583ae5106d96fab997fa7e2f38ee8347/jiter-0.8.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:37b2998606d6dadbb5ccda959a33d6a5e853252d921fec1792fc902351bb4e2c", size = 356057 }, - { url = "https://files.pythonhosted.org/packages/8d/50/a82796e421a22b699ee4d2ce527e5bcb29471a2351cbdc931819d941a167/jiter-0.8.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4ab9a87f3784eb0e098f84a32670cfe4a79cb6512fd8f42ae3d0709f06405d18", size = 379790 }, - { url = "https://files.pythonhosted.org/packages/3c/31/10fb012b00f6d83342ca9e2c9618869ab449f1aa78c8f1b2193a6b49647c/jiter-0.8.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:79aec8172b9e3c6d05fd4b219d5de1ac616bd8da934107325a6c0d0e866a21b6", size = 388285 }, - { url = "https://files.pythonhosted.org/packages/c8/81/f15ebf7de57be488aa22944bf4274962aca8092e4f7817f92ffa50d3ee46/jiter-0.8.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:711e408732d4e9a0208008e5892c2966b485c783cd2d9a681f3eb147cf36c7ef", size = 344764 }, - { url = "https://files.pythonhosted.org/packages/b3/e8/0cae550d72b48829ba653eb348cdc25f3f06f8a62363723702ec18e7be9c/jiter-0.8.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:653cf462db4e8c41995e33d865965e79641ef45369d8a11f54cd30888b7e6ff1", size = 376620 }, - { url = "https://files.pythonhosted.org/packages/b8/50/e5478ff9d82534a944c03b63bc217c5f37019d4a34d288db0f079b13c10b/jiter-0.8.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:9c63eaef32b7bebac8ebebf4dabebdbc6769a09c127294db6babee38e9f405b9", size = 510402 }, - { url = "https://files.pythonhosted.org/packages/8e/1e/3de48bbebbc8f7025bd454cedc8c62378c0e32dd483dece5f4a814a5cb55/jiter-0.8.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:eb21aaa9a200d0a80dacc7a81038d2e476ffe473ffdd9c91eb745d623561de05", size = 503018 }, - { url = "https://files.pythonhosted.org/packages/d5/cd/d5a5501d72a11fe3e5fd65c78c884e5164eefe80077680533919be22d3a3/jiter-0.8.2-cp313-cp313-win32.whl", hash = "sha256:789361ed945d8d42850f919342a8665d2dc79e7e44ca1c97cc786966a21f627a", size = 203190 }, - { url = "https://files.pythonhosted.org/packages/51/bf/e5ca301245ba951447e3ad677a02a64a8845b185de2603dabd83e1e4b9c6/jiter-0.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:ab7f43235d71e03b941c1630f4b6e3055d46b6cb8728a17663eaac9d8e83a865", size = 203551 }, - { url = "https://files.pythonhosted.org/packages/2f/3c/71a491952c37b87d127790dd7a0b1ebea0514c6b6ad30085b16bbe00aee6/jiter-0.8.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b426f72cd77da3fec300ed3bc990895e2dd6b49e3bfe6c438592a3ba660e41ca", size = 308347 }, - { url = "https://files.pythonhosted.org/packages/a0/4c/c02408042e6a7605ec063daed138e07b982fdb98467deaaf1c90950cf2c6/jiter-0.8.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2dd880785088ff2ad21ffee205e58a8c1ddabc63612444ae41e5e4b321b39c0", size = 342875 }, - { url = "https://files.pythonhosted.org/packages/91/61/c80ef80ed8a0a21158e289ef70dac01e351d929a1c30cb0f49be60772547/jiter-0.8.2-cp313-cp313t-win_amd64.whl", hash = "sha256:3ac9f578c46f22405ff7f8b1f5848fb753cc4b8377fbec8470a7dc3997ca7566", size = 202374 }, +sdist = { url = "https://files.pythonhosted.org/packages/f8/70/90bc7bd3932e651486861df5c8ffea4ca7c77d28e8532ddefe2abc561a53/jiter-0.8.2.tar.gz", hash = "sha256:cd73d3e740666d0e639f678adb176fad25c1bcbdae88d8d7b857e1783bb4212d", size = 163007, upload-time = "2024-12-09T18:11:08.649Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/17/c8747af8ea4e045f57d6cfd6fc180752cab9bc3de0e8a0c9ca4e8af333b1/jiter-0.8.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:e6ec2be506e7d6f9527dae9ff4b7f54e68ea44a0ef6b098256ddf895218a2f8f", size = 302027, upload-time = "2024-12-09T18:09:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/3c/c1/6da849640cd35a41e91085723b76acc818d4b7d92b0b6e5111736ce1dd10/jiter-0.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76e324da7b5da060287c54f2fabd3db5f76468006c811831f051942bf68c9d44", size = 310326, upload-time = "2024-12-09T18:09:44.426Z" }, + { url = "https://files.pythonhosted.org/packages/06/99/a2bf660d8ccffee9ad7ed46b4f860d2108a148d0ea36043fd16f4dc37e94/jiter-0.8.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:180a8aea058f7535d1c84183c0362c710f4750bef66630c05f40c93c2b152a0f", size = 334242, upload-time = "2024-12-09T18:09:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/a7/5f/cea1c17864828731f11427b9d1ab7f24764dbd9aaf4648a7f851164d2718/jiter-0.8.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:025337859077b41548bdcbabe38698bcd93cfe10b06ff66617a48ff92c9aec60", size = 356654, upload-time = "2024-12-09T18:09:47.619Z" }, + { url = "https://files.pythonhosted.org/packages/e9/13/62774b7e5e7f5d5043efe1d0f94ead66e6d0f894ae010adb56b3f788de71/jiter-0.8.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ecff0dc14f409599bbcafa7e470c00b80f17abc14d1405d38ab02e4b42e55b57", size = 379967, upload-time = "2024-12-09T18:09:49.987Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fb/096b34c553bb0bd3f2289d5013dcad6074948b8d55212aa13a10d44c5326/jiter-0.8.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ffd9fee7d0775ebaba131f7ca2e2d83839a62ad65e8e02fe2bd8fc975cedeb9e", size = 389252, upload-time = "2024-12-09T18:09:51.329Z" }, + { url = "https://files.pythonhosted.org/packages/17/61/beea645c0bf398ced8b199e377b61eb999d8e46e053bb285c91c3d3eaab0/jiter-0.8.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14601dcac4889e0a1c75ccf6a0e4baf70dbc75041e51bcf8d0e9274519df6887", size = 345490, upload-time = "2024-12-09T18:09:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/d5/df/834aa17ad5dcc3cf0118821da0a0cf1589ea7db9832589278553640366bc/jiter-0.8.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:92249669925bc1c54fcd2ec73f70f2c1d6a817928480ee1c65af5f6b81cdf12d", size = 376991, upload-time = "2024-12-09T18:09:53.972Z" }, + { url = "https://files.pythonhosted.org/packages/67/80/87d140399d382fb4ea5b3d56e7ecaa4efdca17cd7411ff904c1517855314/jiter-0.8.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e725edd0929fa79f8349ab4ec7f81c714df51dc4e991539a578e5018fa4a7152", size = 510822, upload-time = "2024-12-09T18:09:55.439Z" }, + { url = "https://files.pythonhosted.org/packages/5c/37/3394bb47bac1ad2cb0465601f86828a0518d07828a650722e55268cdb7e6/jiter-0.8.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bf55846c7b7a680eebaf9c3c48d630e1bf51bdf76c68a5f654b8524335b0ad29", size = 503730, upload-time = "2024-12-09T18:09:59.494Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/253fc1fa59103bb4e3aa0665d6ceb1818df1cd7bf3eb492c4dad229b1cd4/jiter-0.8.2-cp312-cp312-win32.whl", hash = "sha256:7efe4853ecd3d6110301665a5178b9856be7e2a9485f49d91aa4d737ad2ae49e", size = 203375, upload-time = "2024-12-09T18:10:00.814Z" }, + { url = "https://files.pythonhosted.org/packages/41/69/6d4bbe66b3b3b4507e47aa1dd5d075919ad242b4b1115b3f80eecd443687/jiter-0.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:83c0efd80b29695058d0fd2fa8a556490dbce9804eac3e281f373bbc99045f6c", size = 204740, upload-time = "2024-12-09T18:10:02.146Z" }, + { url = "https://files.pythonhosted.org/packages/6c/b0/bfa1f6f2c956b948802ef5a021281978bf53b7a6ca54bb126fd88a5d014e/jiter-0.8.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:ca1f08b8e43dc3bd0594c992fb1fd2f7ce87f7bf0d44358198d6da8034afdf84", size = 301190, upload-time = "2024-12-09T18:10:03.463Z" }, + { url = "https://files.pythonhosted.org/packages/a4/8f/396ddb4e292b5ea57e45ade5dc48229556b9044bad29a3b4b2dddeaedd52/jiter-0.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5672a86d55416ccd214c778efccf3266b84f87b89063b582167d803246354be4", size = 309334, upload-time = "2024-12-09T18:10:05.774Z" }, + { url = "https://files.pythonhosted.org/packages/7f/68/805978f2f446fa6362ba0cc2e4489b945695940656edd844e110a61c98f8/jiter-0.8.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58dc9bc9767a1101f4e5e22db1b652161a225874d66f0e5cb8e2c7d1c438b587", size = 333918, upload-time = "2024-12-09T18:10:07.158Z" }, + { url = "https://files.pythonhosted.org/packages/b3/99/0f71f7be667c33403fa9706e5b50583ae5106d96fab997fa7e2f38ee8347/jiter-0.8.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:37b2998606d6dadbb5ccda959a33d6a5e853252d921fec1792fc902351bb4e2c", size = 356057, upload-time = "2024-12-09T18:10:09.341Z" }, + { url = "https://files.pythonhosted.org/packages/8d/50/a82796e421a22b699ee4d2ce527e5bcb29471a2351cbdc931819d941a167/jiter-0.8.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4ab9a87f3784eb0e098f84a32670cfe4a79cb6512fd8f42ae3d0709f06405d18", size = 379790, upload-time = "2024-12-09T18:10:10.702Z" }, + { url = "https://files.pythonhosted.org/packages/3c/31/10fb012b00f6d83342ca9e2c9618869ab449f1aa78c8f1b2193a6b49647c/jiter-0.8.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:79aec8172b9e3c6d05fd4b219d5de1ac616bd8da934107325a6c0d0e866a21b6", size = 388285, upload-time = "2024-12-09T18:10:12.721Z" }, + { url = "https://files.pythonhosted.org/packages/c8/81/f15ebf7de57be488aa22944bf4274962aca8092e4f7817f92ffa50d3ee46/jiter-0.8.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:711e408732d4e9a0208008e5892c2966b485c783cd2d9a681f3eb147cf36c7ef", size = 344764, upload-time = "2024-12-09T18:10:14.075Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e8/0cae550d72b48829ba653eb348cdc25f3f06f8a62363723702ec18e7be9c/jiter-0.8.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:653cf462db4e8c41995e33d865965e79641ef45369d8a11f54cd30888b7e6ff1", size = 376620, upload-time = "2024-12-09T18:10:15.487Z" }, + { url = "https://files.pythonhosted.org/packages/b8/50/e5478ff9d82534a944c03b63bc217c5f37019d4a34d288db0f079b13c10b/jiter-0.8.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:9c63eaef32b7bebac8ebebf4dabebdbc6769a09c127294db6babee38e9f405b9", size = 510402, upload-time = "2024-12-09T18:10:17.499Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1e/3de48bbebbc8f7025bd454cedc8c62378c0e32dd483dece5f4a814a5cb55/jiter-0.8.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:eb21aaa9a200d0a80dacc7a81038d2e476ffe473ffdd9c91eb745d623561de05", size = 503018, upload-time = "2024-12-09T18:10:18.92Z" }, + { url = "https://files.pythonhosted.org/packages/d5/cd/d5a5501d72a11fe3e5fd65c78c884e5164eefe80077680533919be22d3a3/jiter-0.8.2-cp313-cp313-win32.whl", hash = "sha256:789361ed945d8d42850f919342a8665d2dc79e7e44ca1c97cc786966a21f627a", size = 203190, upload-time = "2024-12-09T18:10:20.801Z" }, + { url = "https://files.pythonhosted.org/packages/51/bf/e5ca301245ba951447e3ad677a02a64a8845b185de2603dabd83e1e4b9c6/jiter-0.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:ab7f43235d71e03b941c1630f4b6e3055d46b6cb8728a17663eaac9d8e83a865", size = 203551, upload-time = "2024-12-09T18:10:22.822Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3c/71a491952c37b87d127790dd7a0b1ebea0514c6b6ad30085b16bbe00aee6/jiter-0.8.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b426f72cd77da3fec300ed3bc990895e2dd6b49e3bfe6c438592a3ba660e41ca", size = 308347, upload-time = "2024-12-09T18:10:24.139Z" }, + { url = "https://files.pythonhosted.org/packages/a0/4c/c02408042e6a7605ec063daed138e07b982fdb98467deaaf1c90950cf2c6/jiter-0.8.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2dd880785088ff2ad21ffee205e58a8c1ddabc63612444ae41e5e4b321b39c0", size = 342875, upload-time = "2024-12-09T18:10:25.553Z" }, + { url = "https://files.pythonhosted.org/packages/91/61/c80ef80ed8a0a21158e289ef70dac01e351d929a1c30cb0f49be60772547/jiter-0.8.2-cp313-cp313t-win_amd64.whl", hash = "sha256:3ac9f578c46f22405ff7f8b1f5848fb753cc4b8377fbec8470a7dc3997ca7566", size = 202374, upload-time = "2024-12-09T18:10:26.958Z" }, ] [[package]] @@ -410,47 +396,47 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, ] [[package]] name = "markupsafe" version = "3.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 }, - { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 }, - { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 }, - { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 }, - { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 }, - { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 }, - { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 }, - { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 }, - { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 }, - { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 }, - { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 }, - { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 }, - { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 }, - { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 }, - { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 }, - { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 }, - { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 }, - { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 }, - { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 }, - { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 }, - { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 }, - { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 }, - { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 }, - { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 }, - { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 }, - { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 }, - { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 }, - { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 }, - { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 }, - { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, ] [[package]] @@ -467,9 +453,9 @@ dependencies = [ { name = "starlette" }, { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/d2/f587cb965a56e992634bebc8611c5b579af912b74e04eb9164bd49527d21/mcp-1.6.0.tar.gz", hash = "sha256:d9324876de2c5637369f43161cd71eebfd803df5a95e46225cab8d280e366723", size = 200031 } +sdist = { url = "https://files.pythonhosted.org/packages/95/d2/f587cb965a56e992634bebc8611c5b579af912b74e04eb9164bd49527d21/mcp-1.6.0.tar.gz", hash = "sha256:d9324876de2c5637369f43161cd71eebfd803df5a95e46225cab8d280e366723", size = 200031, upload-time = "2025-03-27T16:46:32.336Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/30/20a7f33b0b884a9d14dd3aa94ff1ac9da1479fe2ad66dd9e2736075d2506/mcp-1.6.0-py3-none-any.whl", hash = "sha256:7bd24c6ea042dbec44c754f100984d186620d8b841ec30f1b19eda9b93a634d0", size = 76077 }, + { url = "https://files.pythonhosted.org/packages/10/30/20a7f33b0b884a9d14dd3aa94ff1ac9da1479fe2ad66dd9e2736075d2506/mcp-1.6.0-py3-none-any.whl", hash = "sha256:7bd24c6ea042dbec44c754f100984d186620d8b841ec30f1b19eda9b93a634d0", size = 76077, upload-time = "2025-03-27T16:46:29.919Z" }, ] [package.optional-dependencies] @@ -482,78 +468,78 @@ cli = [ 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 } +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 }, + { 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 = "multidict" version = "6.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/da/2c/e367dfb4c6538614a0c9453e510d75d66099edf1c4e69da1b5ce691a1931/multidict-6.4.3.tar.gz", hash = "sha256:3ada0b058c9f213c5f95ba301f922d402ac234f1111a7d8fd70f1b99f3c281ec", size = 89372 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/bb/3abdaf8fe40e9226ce8a2ba5ecf332461f7beec478a455d6587159f1bf92/multidict-6.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1f1c2f58f08b36f8475f3ec6f5aeb95270921d418bf18f90dffd6be5c7b0e676", size = 64019 }, - { url = "https://files.pythonhosted.org/packages/7e/b5/1b2e8de8217d2e89db156625aa0fe4a6faad98972bfe07a7b8c10ef5dd6b/multidict-6.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:26ae9ad364fc61b936fb7bf4c9d8bd53f3a5b4417142cd0be5c509d6f767e2f1", size = 37925 }, - { url = "https://files.pythonhosted.org/packages/b4/e2/3ca91c112644a395c8eae017144c907d173ea910c913ff8b62549dcf0bbf/multidict-6.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:659318c6c8a85f6ecfc06b4e57529e5a78dfdd697260cc81f683492ad7e9435a", size = 37008 }, - { url = "https://files.pythonhosted.org/packages/60/23/79bc78146c7ac8d1ac766b2770ca2e07c2816058b8a3d5da6caed8148637/multidict-6.4.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1eb72c741fd24d5a28242ce72bb61bc91f8451877131fa3fe930edb195f7054", size = 224374 }, - { url = "https://files.pythonhosted.org/packages/86/35/77950ed9ebd09136003a85c1926ba42001ca5be14feb49710e4334ee199b/multidict-6.4.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3cd06d88cb7398252284ee75c8db8e680aa0d321451132d0dba12bc995f0adcc", size = 230869 }, - { url = "https://files.pythonhosted.org/packages/49/97/2a33c6e7d90bc116c636c14b2abab93d6521c0c052d24bfcc231cbf7f0e7/multidict-6.4.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4543d8dc6470a82fde92b035a92529317191ce993533c3c0c68f56811164ed07", size = 231949 }, - { url = "https://files.pythonhosted.org/packages/56/ce/e9b5d9fcf854f61d6686ada7ff64893a7a5523b2a07da6f1265eaaea5151/multidict-6.4.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:30a3ebdc068c27e9d6081fca0e2c33fdf132ecea703a72ea216b81a66860adde", size = 231032 }, - { url = "https://files.pythonhosted.org/packages/f0/ac/7ced59dcdfeddd03e601edb05adff0c66d81ed4a5160c443e44f2379eef0/multidict-6.4.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b038f10e23f277153f86f95c777ba1958bcd5993194fda26a1d06fae98b2f00c", size = 223517 }, - { url = "https://files.pythonhosted.org/packages/db/e6/325ed9055ae4e085315193a1b58bdb4d7fc38ffcc1f4975cfca97d015e17/multidict-6.4.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c605a2b2dc14282b580454b9b5d14ebe0668381a3a26d0ac39daa0ca115eb2ae", size = 216291 }, - { url = "https://files.pythonhosted.org/packages/fa/84/eeee6d477dd9dcb7691c3bb9d08df56017f5dd15c730bcc9383dcf201cf4/multidict-6.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8bd2b875f4ca2bb527fe23e318ddd509b7df163407b0fb717df229041c6df5d3", size = 228982 }, - { url = "https://files.pythonhosted.org/packages/82/94/4d1f3e74e7acf8b0c85db350e012dcc61701cd6668bc2440bb1ecb423c90/multidict-6.4.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c2e98c840c9c8e65c0e04b40c6c5066c8632678cd50c8721fdbcd2e09f21a507", size = 226823 }, - { url = "https://files.pythonhosted.org/packages/09/f0/1e54b95bda7cd01080e5732f9abb7b76ab5cc795b66605877caeb2197476/multidict-6.4.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66eb80dd0ab36dbd559635e62fba3083a48a252633164857a1d1684f14326427", size = 222714 }, - { url = "https://files.pythonhosted.org/packages/e7/a2/f6cbca875195bd65a3e53b37ab46486f3cc125bdeab20eefe5042afa31fb/multidict-6.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c23831bdee0a2a3cf21be057b5e5326292f60472fb6c6f86392bbf0de70ba731", size = 233739 }, - { url = "https://files.pythonhosted.org/packages/79/68/9891f4d2b8569554723ddd6154375295f789dc65809826c6fb96a06314fd/multidict-6.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1535cec6443bfd80d028052e9d17ba6ff8a5a3534c51d285ba56c18af97e9713", size = 230809 }, - { url = "https://files.pythonhosted.org/packages/e6/72/a7be29ba1e87e4fc5ceb44dabc7940b8005fd2436a332a23547709315f70/multidict-6.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b73e7227681f85d19dec46e5b881827cd354aabe46049e1a61d2f9aaa4e285a", size = 226934 }, - { url = "https://files.pythonhosted.org/packages/12/c1/259386a9ad6840ff7afc686da96808b503d152ac4feb3a96c651dc4f5abf/multidict-6.4.3-cp312-cp312-win32.whl", hash = "sha256:8eac0c49df91b88bf91f818e0a24c1c46f3622978e2c27035bfdca98e0e18124", size = 35242 }, - { url = "https://files.pythonhosted.org/packages/06/24/c8fdff4f924d37225dc0c56a28b1dca10728fc2233065fafeb27b4b125be/multidict-6.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:11990b5c757d956cd1db7cb140be50a63216af32cd6506329c2c59d732d802db", size = 38635 }, - { url = "https://files.pythonhosted.org/packages/6c/4b/86fd786d03915c6f49998cf10cd5fe6b6ac9e9a071cb40885d2e080fb90d/multidict-6.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7a76534263d03ae0cfa721fea40fd2b5b9d17a6f85e98025931d41dc49504474", size = 63831 }, - { url = "https://files.pythonhosted.org/packages/45/05/9b51fdf7aef2563340a93be0a663acba2c428c4daeaf3960d92d53a4a930/multidict-6.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:805031c2f599eee62ac579843555ed1ce389ae00c7e9f74c2a1b45e0564a88dd", size = 37888 }, - { url = "https://files.pythonhosted.org/packages/0b/43/53fc25394386c911822419b522181227ca450cf57fea76e6188772a1bd91/multidict-6.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c56c179839d5dcf51d565132185409d1d5dd8e614ba501eb79023a6cab25576b", size = 36852 }, - { url = "https://files.pythonhosted.org/packages/8a/68/7b99c751e822467c94a235b810a2fd4047d4ecb91caef6b5c60116991c4b/multidict-6.4.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c64f4ddb3886dd8ab71b68a7431ad4aa01a8fa5be5b11543b29674f29ca0ba3", size = 223644 }, - { url = "https://files.pythonhosted.org/packages/80/1b/d458d791e4dd0f7e92596667784fbf99e5c8ba040affe1ca04f06b93ae92/multidict-6.4.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3002a856367c0b41cad6784f5b8d3ab008eda194ed7864aaa58f65312e2abcac", size = 230446 }, - { url = "https://files.pythonhosted.org/packages/e2/46/9793378d988905491a7806d8987862dc5a0bae8a622dd896c4008c7b226b/multidict-6.4.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3d75e621e7d887d539d6e1d789f0c64271c250276c333480a9e1de089611f790", size = 231070 }, - { url = "https://files.pythonhosted.org/packages/a7/b8/b127d3e1f8dd2a5bf286b47b24567ae6363017292dc6dec44656e6246498/multidict-6.4.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:995015cf4a3c0d72cbf453b10a999b92c5629eaf3a0c3e1efb4b5c1f602253bb", size = 229956 }, - { url = "https://files.pythonhosted.org/packages/0c/93/f70a4c35b103fcfe1443059a2bb7f66e5c35f2aea7804105ff214f566009/multidict-6.4.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2b0fabae7939d09d7d16a711468c385272fa1b9b7fb0d37e51143585d8e72e0", size = 222599 }, - { url = "https://files.pythonhosted.org/packages/63/8c/e28e0eb2fe34921d6aa32bfc4ac75b09570b4d6818cc95d25499fe08dc1d/multidict-6.4.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:61ed4d82f8a1e67eb9eb04f8587970d78fe7cddb4e4d6230b77eda23d27938f9", size = 216136 }, - { url = "https://files.pythonhosted.org/packages/72/f5/fbc81f866585b05f89f99d108be5d6ad170e3b6c4d0723d1a2f6ba5fa918/multidict-6.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:062428944a8dc69df9fdc5d5fc6279421e5f9c75a9ee3f586f274ba7b05ab3c8", size = 228139 }, - { url = "https://files.pythonhosted.org/packages/bb/ba/7d196bad6b85af2307d81f6979c36ed9665f49626f66d883d6c64d156f78/multidict-6.4.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b90e27b4674e6c405ad6c64e515a505c6d113b832df52fdacb6b1ffd1fa9a1d1", size = 226251 }, - { url = "https://files.pythonhosted.org/packages/cc/e2/fae46a370dce79d08b672422a33df721ec8b80105e0ea8d87215ff6b090d/multidict-6.4.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7d50d4abf6729921e9613d98344b74241572b751c6b37feed75fb0c37bd5a817", size = 221868 }, - { url = "https://files.pythonhosted.org/packages/26/20/bbc9a3dec19d5492f54a167f08546656e7aef75d181d3d82541463450e88/multidict-6.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:43fe10524fb0a0514be3954be53258e61d87341008ce4914f8e8b92bee6f875d", size = 233106 }, - { url = "https://files.pythonhosted.org/packages/ee/8d/f30ae8f5ff7a2461177f4d8eb0d8f69f27fb6cfe276b54ec4fd5a282d918/multidict-6.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:236966ca6c472ea4e2d3f02f6673ebfd36ba3f23159c323f5a496869bc8e47c9", size = 230163 }, - { url = "https://files.pythonhosted.org/packages/15/e9/2833f3c218d3c2179f3093f766940ded6b81a49d2e2f9c46ab240d23dfec/multidict-6.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:422a5ec315018e606473ba1f5431e064cf8b2a7468019233dcf8082fabad64c8", size = 225906 }, - { url = "https://files.pythonhosted.org/packages/f1/31/6edab296ac369fd286b845fa5dd4c409e63bc4655ed8c9510fcb477e9ae9/multidict-6.4.3-cp313-cp313-win32.whl", hash = "sha256:f901a5aace8e8c25d78960dcc24c870c8d356660d3b49b93a78bf38eb682aac3", size = 35238 }, - { url = "https://files.pythonhosted.org/packages/23/57/2c0167a1bffa30d9a1383c3dab99d8caae985defc8636934b5668830d2ef/multidict-6.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:1c152c49e42277bc9a2f7b78bd5fa10b13e88d1b0328221e7aef89d5c60a99a5", size = 38799 }, - { url = "https://files.pythonhosted.org/packages/c9/13/2ead63b9ab0d2b3080819268acb297bd66e238070aa8d42af12b08cbee1c/multidict-6.4.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:be8751869e28b9c0d368d94f5afcb4234db66fe8496144547b4b6d6a0645cfc6", size = 68642 }, - { url = "https://files.pythonhosted.org/packages/85/45/f1a751e1eede30c23951e2ae274ce8fad738e8a3d5714be73e0a41b27b16/multidict-6.4.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0d4b31f8a68dccbcd2c0ea04f0e014f1defc6b78f0eb8b35f2265e8716a6df0c", size = 40028 }, - { url = "https://files.pythonhosted.org/packages/a7/29/fcc53e886a2cc5595cc4560df333cb9630257bda65003a7eb4e4e0d8f9c1/multidict-6.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:032efeab3049e37eef2ff91271884303becc9e54d740b492a93b7e7266e23756", size = 39424 }, - { url = "https://files.pythonhosted.org/packages/f6/f0/056c81119d8b88703971f937b371795cab1407cd3c751482de5bfe1a04a9/multidict-6.4.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e78006af1a7c8a8007e4f56629d7252668344442f66982368ac06522445e375", size = 226178 }, - { url = "https://files.pythonhosted.org/packages/a3/79/3b7e5fea0aa80583d3a69c9d98b7913dfd4fbc341fb10bb2fb48d35a9c21/multidict-6.4.3-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:daeac9dd30cda8703c417e4fddccd7c4dc0c73421a0b54a7da2713be125846be", size = 222617 }, - { url = "https://files.pythonhosted.org/packages/06/db/3ed012b163e376fc461e1d6a67de69b408339bc31dc83d39ae9ec3bf9578/multidict-6.4.3-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f6f90700881438953eae443a9c6f8a509808bc3b185246992c4233ccee37fea", size = 227919 }, - { url = "https://files.pythonhosted.org/packages/b1/db/0433c104bca380989bc04d3b841fc83e95ce0c89f680e9ea4251118b52b6/multidict-6.4.3-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f84627997008390dd15762128dcf73c3365f4ec0106739cde6c20a07ed198ec8", size = 226097 }, - { url = "https://files.pythonhosted.org/packages/c2/95/910db2618175724dd254b7ae635b6cd8d2947a8b76b0376de7b96d814dab/multidict-6.4.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3307b48cd156153b117c0ea54890a3bdbf858a5b296ddd40dc3852e5f16e9b02", size = 220706 }, - { url = "https://files.pythonhosted.org/packages/d1/af/aa176c6f5f1d901aac957d5258d5e22897fe13948d1e69063ae3d5d0ca01/multidict-6.4.3-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ead46b0fa1dcf5af503a46e9f1c2e80b5d95c6011526352fa5f42ea201526124", size = 211728 }, - { url = "https://files.pythonhosted.org/packages/e7/42/d51cc5fc1527c3717d7f85137d6c79bb7a93cd214c26f1fc57523774dbb5/multidict-6.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1748cb2743bedc339d63eb1bca314061568793acd603a6e37b09a326334c9f44", size = 226276 }, - { url = "https://files.pythonhosted.org/packages/28/6b/d836dea45e0b8432343ba4acf9a8ecaa245da4c0960fb7ab45088a5e568a/multidict-6.4.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:acc9fa606f76fc111b4569348cc23a771cb52c61516dcc6bcef46d612edb483b", size = 212069 }, - { url = "https://files.pythonhosted.org/packages/55/34/0ee1a7adb3560e18ee9289c6e5f7db54edc312b13e5c8263e88ea373d12c/multidict-6.4.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:31469d5832b5885adeb70982e531ce86f8c992334edd2f2254a10fa3182ac504", size = 217858 }, - { url = "https://files.pythonhosted.org/packages/04/08/586d652c2f5acefe0cf4e658eedb4d71d4ba6dfd4f189bd81b400fc1bc6b/multidict-6.4.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ba46b51b6e51b4ef7bfb84b82f5db0dc5e300fb222a8a13b8cd4111898a869cf", size = 226988 }, - { url = "https://files.pythonhosted.org/packages/82/e3/cc59c7e2bc49d7f906fb4ffb6d9c3a3cf21b9f2dd9c96d05bef89c2b1fd1/multidict-6.4.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:389cfefb599edf3fcfd5f64c0410da686f90f5f5e2c4d84e14f6797a5a337af4", size = 220435 }, - { url = "https://files.pythonhosted.org/packages/e0/32/5c3a556118aca9981d883f38c4b1bfae646f3627157f70f4068e5a648955/multidict-6.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:64bc2bbc5fba7b9db5c2c8d750824f41c6994e3882e6d73c903c2afa78d091e4", size = 221494 }, - { url = "https://files.pythonhosted.org/packages/b9/3b/1599631f59024b75c4d6e3069f4502409970a336647502aaf6b62fb7ac98/multidict-6.4.3-cp313-cp313t-win32.whl", hash = "sha256:0ecdc12ea44bab2807d6b4a7e5eef25109ab1c82a8240d86d3c1fc9f3b72efd5", size = 41775 }, - { url = "https://files.pythonhosted.org/packages/e8/4e/09301668d675d02ca8e8e1a3e6be046619e30403f5ada2ed5b080ae28d02/multidict-6.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:7146a8742ea71b5d7d955bffcef58a9e6e04efba704b52a460134fefd10a8208", size = 45946 }, - { url = "https://files.pythonhosted.org/packages/96/10/7d526c8974f017f1e7ca584c71ee62a638e9334d8d33f27d7cdfc9ae79e4/multidict-6.4.3-py3-none-any.whl", hash = "sha256:59fe01ee8e2a1e8ceb3f6dbb216b09c8d9f4ef1c22c4fc825d045a147fa2ebc9", size = 10400 }, +sdist = { url = "https://files.pythonhosted.org/packages/da/2c/e367dfb4c6538614a0c9453e510d75d66099edf1c4e69da1b5ce691a1931/multidict-6.4.3.tar.gz", hash = "sha256:3ada0b058c9f213c5f95ba301f922d402ac234f1111a7d8fd70f1b99f3c281ec", size = 89372, upload-time = "2025-04-10T22:20:17.956Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/bb/3abdaf8fe40e9226ce8a2ba5ecf332461f7beec478a455d6587159f1bf92/multidict-6.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1f1c2f58f08b36f8475f3ec6f5aeb95270921d418bf18f90dffd6be5c7b0e676", size = 64019, upload-time = "2025-04-10T22:18:23.174Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b5/1b2e8de8217d2e89db156625aa0fe4a6faad98972bfe07a7b8c10ef5dd6b/multidict-6.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:26ae9ad364fc61b936fb7bf4c9d8bd53f3a5b4417142cd0be5c509d6f767e2f1", size = 37925, upload-time = "2025-04-10T22:18:24.834Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e2/3ca91c112644a395c8eae017144c907d173ea910c913ff8b62549dcf0bbf/multidict-6.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:659318c6c8a85f6ecfc06b4e57529e5a78dfdd697260cc81f683492ad7e9435a", size = 37008, upload-time = "2025-04-10T22:18:26.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/23/79bc78146c7ac8d1ac766b2770ca2e07c2816058b8a3d5da6caed8148637/multidict-6.4.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1eb72c741fd24d5a28242ce72bb61bc91f8451877131fa3fe930edb195f7054", size = 224374, upload-time = "2025-04-10T22:18:27.714Z" }, + { url = "https://files.pythonhosted.org/packages/86/35/77950ed9ebd09136003a85c1926ba42001ca5be14feb49710e4334ee199b/multidict-6.4.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3cd06d88cb7398252284ee75c8db8e680aa0d321451132d0dba12bc995f0adcc", size = 230869, upload-time = "2025-04-10T22:18:29.162Z" }, + { url = "https://files.pythonhosted.org/packages/49/97/2a33c6e7d90bc116c636c14b2abab93d6521c0c052d24bfcc231cbf7f0e7/multidict-6.4.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4543d8dc6470a82fde92b035a92529317191ce993533c3c0c68f56811164ed07", size = 231949, upload-time = "2025-04-10T22:18:30.679Z" }, + { url = "https://files.pythonhosted.org/packages/56/ce/e9b5d9fcf854f61d6686ada7ff64893a7a5523b2a07da6f1265eaaea5151/multidict-6.4.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:30a3ebdc068c27e9d6081fca0e2c33fdf132ecea703a72ea216b81a66860adde", size = 231032, upload-time = "2025-04-10T22:18:32.146Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ac/7ced59dcdfeddd03e601edb05adff0c66d81ed4a5160c443e44f2379eef0/multidict-6.4.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b038f10e23f277153f86f95c777ba1958bcd5993194fda26a1d06fae98b2f00c", size = 223517, upload-time = "2025-04-10T22:18:33.538Z" }, + { url = "https://files.pythonhosted.org/packages/db/e6/325ed9055ae4e085315193a1b58bdb4d7fc38ffcc1f4975cfca97d015e17/multidict-6.4.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c605a2b2dc14282b580454b9b5d14ebe0668381a3a26d0ac39daa0ca115eb2ae", size = 216291, upload-time = "2025-04-10T22:18:34.962Z" }, + { url = "https://files.pythonhosted.org/packages/fa/84/eeee6d477dd9dcb7691c3bb9d08df56017f5dd15c730bcc9383dcf201cf4/multidict-6.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8bd2b875f4ca2bb527fe23e318ddd509b7df163407b0fb717df229041c6df5d3", size = 228982, upload-time = "2025-04-10T22:18:36.443Z" }, + { url = "https://files.pythonhosted.org/packages/82/94/4d1f3e74e7acf8b0c85db350e012dcc61701cd6668bc2440bb1ecb423c90/multidict-6.4.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c2e98c840c9c8e65c0e04b40c6c5066c8632678cd50c8721fdbcd2e09f21a507", size = 226823, upload-time = "2025-04-10T22:18:37.924Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/1e54b95bda7cd01080e5732f9abb7b76ab5cc795b66605877caeb2197476/multidict-6.4.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66eb80dd0ab36dbd559635e62fba3083a48a252633164857a1d1684f14326427", size = 222714, upload-time = "2025-04-10T22:18:39.807Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a2/f6cbca875195bd65a3e53b37ab46486f3cc125bdeab20eefe5042afa31fb/multidict-6.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c23831bdee0a2a3cf21be057b5e5326292f60472fb6c6f86392bbf0de70ba731", size = 233739, upload-time = "2025-04-10T22:18:41.341Z" }, + { url = "https://files.pythonhosted.org/packages/79/68/9891f4d2b8569554723ddd6154375295f789dc65809826c6fb96a06314fd/multidict-6.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1535cec6443bfd80d028052e9d17ba6ff8a5a3534c51d285ba56c18af97e9713", size = 230809, upload-time = "2025-04-10T22:18:42.817Z" }, + { url = "https://files.pythonhosted.org/packages/e6/72/a7be29ba1e87e4fc5ceb44dabc7940b8005fd2436a332a23547709315f70/multidict-6.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b73e7227681f85d19dec46e5b881827cd354aabe46049e1a61d2f9aaa4e285a", size = 226934, upload-time = "2025-04-10T22:18:44.311Z" }, + { url = "https://files.pythonhosted.org/packages/12/c1/259386a9ad6840ff7afc686da96808b503d152ac4feb3a96c651dc4f5abf/multidict-6.4.3-cp312-cp312-win32.whl", hash = "sha256:8eac0c49df91b88bf91f818e0a24c1c46f3622978e2c27035bfdca98e0e18124", size = 35242, upload-time = "2025-04-10T22:18:46.193Z" }, + { url = "https://files.pythonhosted.org/packages/06/24/c8fdff4f924d37225dc0c56a28b1dca10728fc2233065fafeb27b4b125be/multidict-6.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:11990b5c757d956cd1db7cb140be50a63216af32cd6506329c2c59d732d802db", size = 38635, upload-time = "2025-04-10T22:18:47.498Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4b/86fd786d03915c6f49998cf10cd5fe6b6ac9e9a071cb40885d2e080fb90d/multidict-6.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7a76534263d03ae0cfa721fea40fd2b5b9d17a6f85e98025931d41dc49504474", size = 63831, upload-time = "2025-04-10T22:18:48.748Z" }, + { url = "https://files.pythonhosted.org/packages/45/05/9b51fdf7aef2563340a93be0a663acba2c428c4daeaf3960d92d53a4a930/multidict-6.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:805031c2f599eee62ac579843555ed1ce389ae00c7e9f74c2a1b45e0564a88dd", size = 37888, upload-time = "2025-04-10T22:18:50.021Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/53fc25394386c911822419b522181227ca450cf57fea76e6188772a1bd91/multidict-6.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c56c179839d5dcf51d565132185409d1d5dd8e614ba501eb79023a6cab25576b", size = 36852, upload-time = "2025-04-10T22:18:51.246Z" }, + { url = "https://files.pythonhosted.org/packages/8a/68/7b99c751e822467c94a235b810a2fd4047d4ecb91caef6b5c60116991c4b/multidict-6.4.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c64f4ddb3886dd8ab71b68a7431ad4aa01a8fa5be5b11543b29674f29ca0ba3", size = 223644, upload-time = "2025-04-10T22:18:52.965Z" }, + { url = "https://files.pythonhosted.org/packages/80/1b/d458d791e4dd0f7e92596667784fbf99e5c8ba040affe1ca04f06b93ae92/multidict-6.4.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3002a856367c0b41cad6784f5b8d3ab008eda194ed7864aaa58f65312e2abcac", size = 230446, upload-time = "2025-04-10T22:18:54.509Z" }, + { url = "https://files.pythonhosted.org/packages/e2/46/9793378d988905491a7806d8987862dc5a0bae8a622dd896c4008c7b226b/multidict-6.4.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3d75e621e7d887d539d6e1d789f0c64271c250276c333480a9e1de089611f790", size = 231070, upload-time = "2025-04-10T22:18:56.019Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b8/b127d3e1f8dd2a5bf286b47b24567ae6363017292dc6dec44656e6246498/multidict-6.4.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:995015cf4a3c0d72cbf453b10a999b92c5629eaf3a0c3e1efb4b5c1f602253bb", size = 229956, upload-time = "2025-04-10T22:18:59.146Z" }, + { url = "https://files.pythonhosted.org/packages/0c/93/f70a4c35b103fcfe1443059a2bb7f66e5c35f2aea7804105ff214f566009/multidict-6.4.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2b0fabae7939d09d7d16a711468c385272fa1b9b7fb0d37e51143585d8e72e0", size = 222599, upload-time = "2025-04-10T22:19:00.657Z" }, + { url = "https://files.pythonhosted.org/packages/63/8c/e28e0eb2fe34921d6aa32bfc4ac75b09570b4d6818cc95d25499fe08dc1d/multidict-6.4.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:61ed4d82f8a1e67eb9eb04f8587970d78fe7cddb4e4d6230b77eda23d27938f9", size = 216136, upload-time = "2025-04-10T22:19:02.244Z" }, + { url = "https://files.pythonhosted.org/packages/72/f5/fbc81f866585b05f89f99d108be5d6ad170e3b6c4d0723d1a2f6ba5fa918/multidict-6.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:062428944a8dc69df9fdc5d5fc6279421e5f9c75a9ee3f586f274ba7b05ab3c8", size = 228139, upload-time = "2025-04-10T22:19:04.151Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ba/7d196bad6b85af2307d81f6979c36ed9665f49626f66d883d6c64d156f78/multidict-6.4.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b90e27b4674e6c405ad6c64e515a505c6d113b832df52fdacb6b1ffd1fa9a1d1", size = 226251, upload-time = "2025-04-10T22:19:06.117Z" }, + { url = "https://files.pythonhosted.org/packages/cc/e2/fae46a370dce79d08b672422a33df721ec8b80105e0ea8d87215ff6b090d/multidict-6.4.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7d50d4abf6729921e9613d98344b74241572b751c6b37feed75fb0c37bd5a817", size = 221868, upload-time = "2025-04-10T22:19:07.981Z" }, + { url = "https://files.pythonhosted.org/packages/26/20/bbc9a3dec19d5492f54a167f08546656e7aef75d181d3d82541463450e88/multidict-6.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:43fe10524fb0a0514be3954be53258e61d87341008ce4914f8e8b92bee6f875d", size = 233106, upload-time = "2025-04-10T22:19:09.5Z" }, + { url = "https://files.pythonhosted.org/packages/ee/8d/f30ae8f5ff7a2461177f4d8eb0d8f69f27fb6cfe276b54ec4fd5a282d918/multidict-6.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:236966ca6c472ea4e2d3f02f6673ebfd36ba3f23159c323f5a496869bc8e47c9", size = 230163, upload-time = "2025-04-10T22:19:11Z" }, + { url = "https://files.pythonhosted.org/packages/15/e9/2833f3c218d3c2179f3093f766940ded6b81a49d2e2f9c46ab240d23dfec/multidict-6.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:422a5ec315018e606473ba1f5431e064cf8b2a7468019233dcf8082fabad64c8", size = 225906, upload-time = "2025-04-10T22:19:12.875Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/6edab296ac369fd286b845fa5dd4c409e63bc4655ed8c9510fcb477e9ae9/multidict-6.4.3-cp313-cp313-win32.whl", hash = "sha256:f901a5aace8e8c25d78960dcc24c870c8d356660d3b49b93a78bf38eb682aac3", size = 35238, upload-time = "2025-04-10T22:19:14.41Z" }, + { url = "https://files.pythonhosted.org/packages/23/57/2c0167a1bffa30d9a1383c3dab99d8caae985defc8636934b5668830d2ef/multidict-6.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:1c152c49e42277bc9a2f7b78bd5fa10b13e88d1b0328221e7aef89d5c60a99a5", size = 38799, upload-time = "2025-04-10T22:19:15.869Z" }, + { url = "https://files.pythonhosted.org/packages/c9/13/2ead63b9ab0d2b3080819268acb297bd66e238070aa8d42af12b08cbee1c/multidict-6.4.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:be8751869e28b9c0d368d94f5afcb4234db66fe8496144547b4b6d6a0645cfc6", size = 68642, upload-time = "2025-04-10T22:19:17.527Z" }, + { url = "https://files.pythonhosted.org/packages/85/45/f1a751e1eede30c23951e2ae274ce8fad738e8a3d5714be73e0a41b27b16/multidict-6.4.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0d4b31f8a68dccbcd2c0ea04f0e014f1defc6b78f0eb8b35f2265e8716a6df0c", size = 40028, upload-time = "2025-04-10T22:19:19.465Z" }, + { url = "https://files.pythonhosted.org/packages/a7/29/fcc53e886a2cc5595cc4560df333cb9630257bda65003a7eb4e4e0d8f9c1/multidict-6.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:032efeab3049e37eef2ff91271884303becc9e54d740b492a93b7e7266e23756", size = 39424, upload-time = "2025-04-10T22:19:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f0/056c81119d8b88703971f937b371795cab1407cd3c751482de5bfe1a04a9/multidict-6.4.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e78006af1a7c8a8007e4f56629d7252668344442f66982368ac06522445e375", size = 226178, upload-time = "2025-04-10T22:19:22.17Z" }, + { url = "https://files.pythonhosted.org/packages/a3/79/3b7e5fea0aa80583d3a69c9d98b7913dfd4fbc341fb10bb2fb48d35a9c21/multidict-6.4.3-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:daeac9dd30cda8703c417e4fddccd7c4dc0c73421a0b54a7da2713be125846be", size = 222617, upload-time = "2025-04-10T22:19:23.773Z" }, + { url = "https://files.pythonhosted.org/packages/06/db/3ed012b163e376fc461e1d6a67de69b408339bc31dc83d39ae9ec3bf9578/multidict-6.4.3-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f6f90700881438953eae443a9c6f8a509808bc3b185246992c4233ccee37fea", size = 227919, upload-time = "2025-04-10T22:19:25.35Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/0433c104bca380989bc04d3b841fc83e95ce0c89f680e9ea4251118b52b6/multidict-6.4.3-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f84627997008390dd15762128dcf73c3365f4ec0106739cde6c20a07ed198ec8", size = 226097, upload-time = "2025-04-10T22:19:27.183Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/910db2618175724dd254b7ae635b6cd8d2947a8b76b0376de7b96d814dab/multidict-6.4.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3307b48cd156153b117c0ea54890a3bdbf858a5b296ddd40dc3852e5f16e9b02", size = 220706, upload-time = "2025-04-10T22:19:28.882Z" }, + { url = "https://files.pythonhosted.org/packages/d1/af/aa176c6f5f1d901aac957d5258d5e22897fe13948d1e69063ae3d5d0ca01/multidict-6.4.3-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ead46b0fa1dcf5af503a46e9f1c2e80b5d95c6011526352fa5f42ea201526124", size = 211728, upload-time = "2025-04-10T22:19:30.481Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/d51cc5fc1527c3717d7f85137d6c79bb7a93cd214c26f1fc57523774dbb5/multidict-6.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1748cb2743bedc339d63eb1bca314061568793acd603a6e37b09a326334c9f44", size = 226276, upload-time = "2025-04-10T22:19:32.454Z" }, + { url = "https://files.pythonhosted.org/packages/28/6b/d836dea45e0b8432343ba4acf9a8ecaa245da4c0960fb7ab45088a5e568a/multidict-6.4.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:acc9fa606f76fc111b4569348cc23a771cb52c61516dcc6bcef46d612edb483b", size = 212069, upload-time = "2025-04-10T22:19:34.17Z" }, + { url = "https://files.pythonhosted.org/packages/55/34/0ee1a7adb3560e18ee9289c6e5f7db54edc312b13e5c8263e88ea373d12c/multidict-6.4.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:31469d5832b5885adeb70982e531ce86f8c992334edd2f2254a10fa3182ac504", size = 217858, upload-time = "2025-04-10T22:19:35.879Z" }, + { url = "https://files.pythonhosted.org/packages/04/08/586d652c2f5acefe0cf4e658eedb4d71d4ba6dfd4f189bd81b400fc1bc6b/multidict-6.4.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ba46b51b6e51b4ef7bfb84b82f5db0dc5e300fb222a8a13b8cd4111898a869cf", size = 226988, upload-time = "2025-04-10T22:19:37.434Z" }, + { url = "https://files.pythonhosted.org/packages/82/e3/cc59c7e2bc49d7f906fb4ffb6d9c3a3cf21b9f2dd9c96d05bef89c2b1fd1/multidict-6.4.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:389cfefb599edf3fcfd5f64c0410da686f90f5f5e2c4d84e14f6797a5a337af4", size = 220435, upload-time = "2025-04-10T22:19:39.005Z" }, + { url = "https://files.pythonhosted.org/packages/e0/32/5c3a556118aca9981d883f38c4b1bfae646f3627157f70f4068e5a648955/multidict-6.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:64bc2bbc5fba7b9db5c2c8d750824f41c6994e3882e6d73c903c2afa78d091e4", size = 221494, upload-time = "2025-04-10T22:19:41.447Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3b/1599631f59024b75c4d6e3069f4502409970a336647502aaf6b62fb7ac98/multidict-6.4.3-cp313-cp313t-win32.whl", hash = "sha256:0ecdc12ea44bab2807d6b4a7e5eef25109ab1c82a8240d86d3c1fc9f3b72efd5", size = 41775, upload-time = "2025-04-10T22:19:43.707Z" }, + { url = "https://files.pythonhosted.org/packages/e8/4e/09301668d675d02ca8e8e1a3e6be046619e30403f5ada2ed5b080ae28d02/multidict-6.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:7146a8742ea71b5d7d955bffcef58a9e6e04efba704b52a460134fefd10a8208", size = 45946, upload-time = "2025-04-10T22:19:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/7d526c8974f017f1e7ca584c71ee62a638e9334d8d33f27d7cdfc9ae79e4/multidict-6.4.3-py3-none-any.whl", hash = "sha256:59fe01ee8e2a1e8ceb3f6dbb216b09c8d9f4ef1c22c4fc825d045a147fa2ebc9", size = 10400, upload-time = "2025-04-10T22:20:16.445Z" }, ] [[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 } +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 }, + { 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]] @@ -570,18 +556,18 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/99/b1/318f5d4c482f19c5fcbcde190801bfaaaec23413cda0b88a29f6897448ff/openai-1.75.0.tar.gz", hash = "sha256:fb3ea907efbdb1bcfd0c44507ad9c961afd7dce3147292b54505ecfd17be8fd1", size = 429492 } +sdist = { url = "https://files.pythonhosted.org/packages/99/b1/318f5d4c482f19c5fcbcde190801bfaaaec23413cda0b88a29f6897448ff/openai-1.75.0.tar.gz", hash = "sha256:fb3ea907efbdb1bcfd0c44507ad9c961afd7dce3147292b54505ecfd17be8fd1", size = 429492, upload-time = "2025-04-16T16:49:29.25Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/80/9a/f34f163294345f123673ed03e77c33dee2534f3ac1f9d18120384457304d/openai-1.75.0-py3-none-any.whl", hash = "sha256:fe6f932d2ded3b429ff67cc9ad118c71327db32eb9d32dd723de3acfca337125", size = 646972 }, + { url = "https://files.pythonhosted.org/packages/80/9a/f34f163294345f123673ed03e77c33dee2534f3ac1f9d18120384457304d/openai-1.75.0-py3-none-any.whl", hash = "sha256:fe6f932d2ded3b429ff67cc9ad118c71327db32eb9d32dd723de3acfca337125", size = 646972, upload-time = "2025-04-16T16:49:27.196Z" }, ] [[package]] name = "packaging" version = "24.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950, upload-time = "2024-11-08T09:47:47.202Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload-time = "2024-11-08T09:47:44.722Z" }, ] [[package]] @@ -591,37 +577,37 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/4e/f0aac6a336fb52ab37485c5ce530880a78179e55948bb684945de6a22bd8/pglast-7.2.tar.gz", hash = "sha256:c0e9619a58af9323bbf51af8b5472638f1aba3916665f0b6540e4638783172be", size = 3366690 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/fe/75fb9f8e80556effe55ddfc4d225d3bdc2ebe047d84f6321548a259743d2/pglast-7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a046fefb17286e591ed6eaf04500d4ea1f0afa5049f6bacb4f3b66e73eae44c5", size = 1146473 }, - { url = "https://files.pythonhosted.org/packages/ee/cf/e93ca2eb500ece9eb09ab8d95c7e6c404ee9a94f3967cef8944d2302474f/pglast-7.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82e56c0007e8de85e5d306557b38cab7e1f7dda3a108f7ccd34419dc9589ba85", size = 1083071 }, - { url = "https://files.pythonhosted.org/packages/1d/59/38ea481972978d7ac7bf7c49bb714a46ff2322dbd4f5a5159eb835136b27/pglast-7.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7eb5728b1a2418a5ed3f8549ba8e24d089b99e582e2bf38b229b6d52ffa20ba", size = 5548938 }, - { url = "https://files.pythonhosted.org/packages/58/e6/bf739bd61518c4a90ff05dbd456c973b21ca2a7b664d1f9910c1fe4f3088/pglast-7.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2613ad4228165125047706c26925230202d63adc4d5a10cbe8a8896af024ced", size = 5643862 }, - { url = "https://files.pythonhosted.org/packages/ba/9f/f6c71865c3c09417de1a1a94664ba83850e83b8bf0780e14b96ea1248154/pglast-7.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a339993019619e2939086a14d4c02cd5e4e3833c17d116ac711e84c40d5b979e", size = 5415528 }, - { url = "https://files.pythonhosted.org/packages/35/a5/4f300161cb8105aca7a55bf22b13d0a916116c550e6720db0cebb353fdc5/pglast-7.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:acdb5404ba6bcf5b1cd43a8777a7beb2afd7cba66bbf512559ab8760bf1712c8", size = 5297725 }, - { url = "https://files.pythonhosted.org/packages/b2/b9/f1ed29d9d7e26fff80dbe95779c8c8803bc55ee9700c86a802e18a5b8862/pglast-7.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45d5fecada160562fe0c699f5cdd032a37f65a952be52d88dbd50df186886fbf", size = 5253535 }, - { url = "https://files.pythonhosted.org/packages/e5/bd/062973b80c42945e6893e42bca19da4435530dc7dc4f92e89f0757b6c00f/pglast-7.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd96731e3f17289896e1a9977b611358a01410947ed85ebfd1a58a7dc18479af", size = 5432734 }, - { url = "https://files.pythonhosted.org/packages/44/c9/c671743588e6f06253f7da1bb0a2f5c1dd470fccf19db170f6cfb3be1505/pglast-7.2-cp312-cp312-win32.whl", hash = "sha256:bf70d7d803641a645f9d9c504ac4a59aa9ffa1a282ef6214a8caed3251e994ef", size = 1010386 }, - { url = "https://files.pythonhosted.org/packages/be/4b/a4d244211dd3dcfd99c704bb6195d9c5c564da175531b0fb8c844f311b8d/pglast-7.2-cp312-cp312-win_amd64.whl", hash = "sha256:06a5b2d3dd63c44ca71e29fcc0fe162f959708bd348754e420ac1b9332d1d340", size = 1054938 }, - { url = "https://files.pythonhosted.org/packages/01/ff/cda3dc03f469c3fa56bc5d14b6420c3ac18bc0a935c9abcb162604fddd9a/pglast-7.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c8c6ce2d315a9d69d2a7cb0b11379ff450c7b29c44f5c488767f6de875dca907", size = 1140994 }, - { url = "https://files.pythonhosted.org/packages/dc/f0/90ca159feaf5da2a74372b011084fb1cdb80ca1575f6c2ac36cec2408db8/pglast-7.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cc8fdb2c26b48b8ea9fe14a8e9195988e926f8cc5232833605eff91625e4db0e", size = 1079884 }, - { url = "https://files.pythonhosted.org/packages/b9/79/a23b9cf526c82c88b1009e87363ddcb73ea1f9765526040747694850f5de/pglast-7.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f343d59ae674282a627140c3a9794dc86bc2dd43b76fd1202a8915f9cd38bdfd", size = 5552258 }, - { url = "https://files.pythonhosted.org/packages/1e/d4/4e088c256f07231b38a9617acd7a29daeac08ec4534b803b801ff3a82f91/pglast-7.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18d228416c7a56ec0f73f35ee5a0a7ce7a58ec0bcaecbe0fe6f1bc388a1401af", size = 5644806 }, - { url = "https://files.pythonhosted.org/packages/e9/77/b683afc004a5666c8e31d2a8dd27e57ebc227ccefeb61204fc6599d74f67/pglast-7.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fad462b577021aa91bdfcf16ca32922fed0347ac05ea0de4235b9554a2c7d846", size = 5417808 }, - { url = "https://files.pythonhosted.org/packages/55/f8/7bd061ec3eb5d43c752daa60fe90b3c6b3ce1698701529756ba4b89f23bb/pglast-7.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e15f46038e9cd579ffb1ac993cfd961aabcb7d9c61e860c75f6dee4fbabf97fb", size = 5300625 }, - { url = "https://files.pythonhosted.org/packages/5d/e1/9a7bfe9f9b6953324dd587135ec2c63150c71f4b38fca220a8c4d7d65951/pglast-7.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b536d7af6a8f820806b6799e0034844d3760c02d9a8764f8453906618ce151bf", size = 5257865 }, - { url = "https://files.pythonhosted.org/packages/39/77/70ebfe9cbfc92b609f0b301d5cc3432897acf8f932d6f453f339e00018b0/pglast-7.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ae0e93d74e9779d2a02efa2eee84440736cc367b9d695c1e5735df92540ca4fe", size = 5440924 }, - { url = "https://files.pythonhosted.org/packages/ae/1e/6ffb94b259af4cd60fee589c4b68cea2e6401df15f1ff3cd1950e339d71e/pglast-7.2-cp313-cp313-win32.whl", hash = "sha256:b1b940a09b884f8af95e29779d8fd812df0a5e5d5d885f9a4a91105e2395c2e0", size = 1009452 }, - { url = "https://files.pythonhosted.org/packages/c5/d5/7c04fb7a2ebbb03b90391c58f876587cbe7073dfb769d0612fb348e37518/pglast-7.2-cp313-cp313-win_amd64.whl", hash = "sha256:56443a3416f83c6eb587d3bc2715e1c2d35e2aa751957a07aa54c0600280ac07", size = 1050476 }, +sdist = { url = "https://files.pythonhosted.org/packages/39/4e/f0aac6a336fb52ab37485c5ce530880a78179e55948bb684945de6a22bd8/pglast-7.2.tar.gz", hash = "sha256:c0e9619a58af9323bbf51af8b5472638f1aba3916665f0b6540e4638783172be", size = 3366690, upload-time = "2024-12-21T08:10:54.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/fe/75fb9f8e80556effe55ddfc4d225d3bdc2ebe047d84f6321548a259743d2/pglast-7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a046fefb17286e591ed6eaf04500d4ea1f0afa5049f6bacb4f3b66e73eae44c5", size = 1146473, upload-time = "2024-12-21T09:17:26.747Z" }, + { url = "https://files.pythonhosted.org/packages/ee/cf/e93ca2eb500ece9eb09ab8d95c7e6c404ee9a94f3967cef8944d2302474f/pglast-7.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82e56c0007e8de85e5d306557b38cab7e1f7dda3a108f7ccd34419dc9589ba85", size = 1083071, upload-time = "2024-12-21T09:17:30.191Z" }, + { url = "https://files.pythonhosted.org/packages/1d/59/38ea481972978d7ac7bf7c49bb714a46ff2322dbd4f5a5159eb835136b27/pglast-7.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7eb5728b1a2418a5ed3f8549ba8e24d089b99e582e2bf38b229b6d52ffa20ba", size = 5548938, upload-time = "2024-12-21T09:17:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/58/e6/bf739bd61518c4a90ff05dbd456c973b21ca2a7b664d1f9910c1fe4f3088/pglast-7.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2613ad4228165125047706c26925230202d63adc4d5a10cbe8a8896af024ced", size = 5643862, upload-time = "2024-12-21T09:17:38.737Z" }, + { url = "https://files.pythonhosted.org/packages/ba/9f/f6c71865c3c09417de1a1a94664ba83850e83b8bf0780e14b96ea1248154/pglast-7.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a339993019619e2939086a14d4c02cd5e4e3833c17d116ac711e84c40d5b979e", size = 5415528, upload-time = "2024-12-21T09:17:41.054Z" }, + { url = "https://files.pythonhosted.org/packages/35/a5/4f300161cb8105aca7a55bf22b13d0a916116c550e6720db0cebb353fdc5/pglast-7.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:acdb5404ba6bcf5b1cd43a8777a7beb2afd7cba66bbf512559ab8760bf1712c8", size = 5297725, upload-time = "2024-12-21T09:17:44.579Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b9/f1ed29d9d7e26fff80dbe95779c8c8803bc55ee9700c86a802e18a5b8862/pglast-7.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45d5fecada160562fe0c699f5cdd032a37f65a952be52d88dbd50df186886fbf", size = 5253535, upload-time = "2024-12-21T09:17:46.961Z" }, + { url = "https://files.pythonhosted.org/packages/e5/bd/062973b80c42945e6893e42bca19da4435530dc7dc4f92e89f0757b6c00f/pglast-7.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd96731e3f17289896e1a9977b611358a01410947ed85ebfd1a58a7dc18479af", size = 5432734, upload-time = "2024-12-21T09:17:49.825Z" }, + { url = "https://files.pythonhosted.org/packages/44/c9/c671743588e6f06253f7da1bb0a2f5c1dd470fccf19db170f6cfb3be1505/pglast-7.2-cp312-cp312-win32.whl", hash = "sha256:bf70d7d803641a645f9d9c504ac4a59aa9ffa1a282ef6214a8caed3251e994ef", size = 1010386, upload-time = "2024-12-21T09:17:52.552Z" }, + { url = "https://files.pythonhosted.org/packages/be/4b/a4d244211dd3dcfd99c704bb6195d9c5c564da175531b0fb8c844f311b8d/pglast-7.2-cp312-cp312-win_amd64.whl", hash = "sha256:06a5b2d3dd63c44ca71e29fcc0fe162f959708bd348754e420ac1b9332d1d340", size = 1054938, upload-time = "2024-12-21T09:17:54.383Z" }, + { url = "https://files.pythonhosted.org/packages/01/ff/cda3dc03f469c3fa56bc5d14b6420c3ac18bc0a935c9abcb162604fddd9a/pglast-7.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c8c6ce2d315a9d69d2a7cb0b11379ff450c7b29c44f5c488767f6de875dca907", size = 1140994, upload-time = "2024-12-21T09:17:57.617Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f0/90ca159feaf5da2a74372b011084fb1cdb80ca1575f6c2ac36cec2408db8/pglast-7.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cc8fdb2c26b48b8ea9fe14a8e9195988e926f8cc5232833605eff91625e4db0e", size = 1079884, upload-time = "2024-12-21T09:18:00.667Z" }, + { url = "https://files.pythonhosted.org/packages/b9/79/a23b9cf526c82c88b1009e87363ddcb73ea1f9765526040747694850f5de/pglast-7.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f343d59ae674282a627140c3a9794dc86bc2dd43b76fd1202a8915f9cd38bdfd", size = 5552258, upload-time = "2024-12-21T09:18:04.107Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d4/4e088c256f07231b38a9617acd7a29daeac08ec4534b803b801ff3a82f91/pglast-7.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18d228416c7a56ec0f73f35ee5a0a7ce7a58ec0bcaecbe0fe6f1bc388a1401af", size = 5644806, upload-time = "2024-12-21T09:18:07.636Z" }, + { url = "https://files.pythonhosted.org/packages/e9/77/b683afc004a5666c8e31d2a8dd27e57ebc227ccefeb61204fc6599d74f67/pglast-7.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fad462b577021aa91bdfcf16ca32922fed0347ac05ea0de4235b9554a2c7d846", size = 5417808, upload-time = "2024-12-21T09:18:10.57Z" }, + { url = "https://files.pythonhosted.org/packages/55/f8/7bd061ec3eb5d43c752daa60fe90b3c6b3ce1698701529756ba4b89f23bb/pglast-7.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e15f46038e9cd579ffb1ac993cfd961aabcb7d9c61e860c75f6dee4fbabf97fb", size = 5300625, upload-time = "2024-12-21T09:18:14.307Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e1/9a7bfe9f9b6953324dd587135ec2c63150c71f4b38fca220a8c4d7d65951/pglast-7.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b536d7af6a8f820806b6799e0034844d3760c02d9a8764f8453906618ce151bf", size = 5257865, upload-time = "2024-12-21T09:18:16.67Z" }, + { url = "https://files.pythonhosted.org/packages/39/77/70ebfe9cbfc92b609f0b301d5cc3432897acf8f932d6f453f339e00018b0/pglast-7.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ae0e93d74e9779d2a02efa2eee84440736cc367b9d695c1e5735df92540ca4fe", size = 5440924, upload-time = "2024-12-21T09:18:19.754Z" }, + { url = "https://files.pythonhosted.org/packages/ae/1e/6ffb94b259af4cd60fee589c4b68cea2e6401df15f1ff3cd1950e339d71e/pglast-7.2-cp313-cp313-win32.whl", hash = "sha256:b1b940a09b884f8af95e29779d8fd812df0a5e5d5d885f9a4a91105e2395c2e0", size = 1009452, upload-time = "2024-12-21T09:18:21.898Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d5/7c04fb7a2ebbb03b90391c58f876587cbe7073dfb769d0612fb348e37518/pglast-7.2-cp313-cp313-win_amd64.whl", hash = "sha256:56443a3416f83c6eb587d3bc2715e1c2d35e2aa751957a07aa54c0600280ac07", size = 1050476, upload-time = "2024-12-21T09:18:24.397Z" }, ] [[package]] name = "pluggy" version = "1.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, ] [[package]] @@ -640,7 +626,6 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "docker" }, { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -660,7 +645,6 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "docker", specifier = ">=7.1.0" }, { name = "pyright", specifier = "==1.1.398" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-asyncio", specifier = ">=0.26.0" }, @@ -671,57 +655,57 @@ dev = [ name = "propcache" version = "0.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/07/c8/fdc6686a986feae3541ea23dcaa661bd93972d3940460646c6bb96e21c40/propcache-0.3.1.tar.gz", hash = "sha256:40d980c33765359098837527e18eddefc9a24cea5b45e078a7f3bb5b032c6ecf", size = 43651 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/aa/ca78d9be314d1e15ff517b992bebbed3bdfef5b8919e85bf4940e57b6137/propcache-0.3.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f78eb8422acc93d7b69964012ad7048764bb45a54ba7a39bb9e146c72ea29723", size = 80430 }, - { url = "https://files.pythonhosted.org/packages/1a/d8/f0c17c44d1cda0ad1979af2e593ea290defdde9eaeb89b08abbe02a5e8e1/propcache-0.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:89498dd49c2f9a026ee057965cdf8192e5ae070ce7d7a7bd4b66a8e257d0c976", size = 46637 }, - { url = "https://files.pythonhosted.org/packages/ae/bd/c1e37265910752e6e5e8a4c1605d0129e5b7933c3dc3cf1b9b48ed83b364/propcache-0.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:09400e98545c998d57d10035ff623266927cb784d13dd2b31fd33b8a5316b85b", size = 46123 }, - { url = "https://files.pythonhosted.org/packages/d4/b0/911eda0865f90c0c7e9f0415d40a5bf681204da5fd7ca089361a64c16b28/propcache-0.3.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa8efd8c5adc5a2c9d3b952815ff8f7710cefdcaf5f2c36d26aff51aeca2f12f", size = 243031 }, - { url = "https://files.pythonhosted.org/packages/0a/06/0da53397c76a74271621807265b6eb61fb011451b1ddebf43213df763669/propcache-0.3.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2fe5c910f6007e716a06d269608d307b4f36e7babee5f36533722660e8c4a70", size = 249100 }, - { url = "https://files.pythonhosted.org/packages/f1/eb/13090e05bf6b963fc1653cdc922133ced467cb4b8dab53158db5a37aa21e/propcache-0.3.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a0ab8cf8cdd2194f8ff979a43ab43049b1df0b37aa64ab7eca04ac14429baeb7", size = 250170 }, - { url = "https://files.pythonhosted.org/packages/3b/4c/f72c9e1022b3b043ec7dc475a0f405d4c3e10b9b1d378a7330fecf0652da/propcache-0.3.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:563f9d8c03ad645597b8d010ef4e9eab359faeb11a0a2ac9f7b4bc8c28ebef25", size = 245000 }, - { url = "https://files.pythonhosted.org/packages/e8/fd/970ca0e22acc829f1adf5de3724085e778c1ad8a75bec010049502cb3a86/propcache-0.3.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb6e0faf8cb6b4beea5d6ed7b5a578254c6d7df54c36ccd3d8b3eb00d6770277", size = 230262 }, - { url = "https://files.pythonhosted.org/packages/c4/42/817289120c6b9194a44f6c3e6b2c3277c5b70bbad39e7df648f177cc3634/propcache-0.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1c5c7ab7f2bb3f573d1cb921993006ba2d39e8621019dffb1c5bc94cdbae81e8", size = 236772 }, - { url = "https://files.pythonhosted.org/packages/7c/9c/3b3942b302badd589ad6b672da3ca7b660a6c2f505cafd058133ddc73918/propcache-0.3.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:050b571b2e96ec942898f8eb46ea4bfbb19bd5502424747e83badc2d4a99a44e", size = 231133 }, - { url = "https://files.pythonhosted.org/packages/98/a1/75f6355f9ad039108ff000dfc2e19962c8dea0430da9a1428e7975cf24b2/propcache-0.3.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e1c4d24b804b3a87e9350f79e2371a705a188d292fd310e663483af6ee6718ee", size = 230741 }, - { url = "https://files.pythonhosted.org/packages/67/0c/3e82563af77d1f8731132166da69fdfd95e71210e31f18edce08a1eb11ea/propcache-0.3.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e4fe2a6d5ce975c117a6bb1e8ccda772d1e7029c1cca1acd209f91d30fa72815", size = 244047 }, - { url = "https://files.pythonhosted.org/packages/f7/50/9fb7cca01532a08c4d5186d7bb2da6c4c587825c0ae134b89b47c7d62628/propcache-0.3.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:feccd282de1f6322f56f6845bf1207a537227812f0a9bf5571df52bb418d79d5", size = 246467 }, - { url = "https://files.pythonhosted.org/packages/a9/02/ccbcf3e1c604c16cc525309161d57412c23cf2351523aedbb280eb7c9094/propcache-0.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ec314cde7314d2dd0510c6787326bbffcbdc317ecee6b7401ce218b3099075a7", size = 241022 }, - { url = "https://files.pythonhosted.org/packages/db/19/e777227545e09ca1e77a6e21274ae9ec45de0f589f0ce3eca2a41f366220/propcache-0.3.1-cp312-cp312-win32.whl", hash = "sha256:7d2d5a0028d920738372630870e7d9644ce437142197f8c827194fca404bf03b", size = 40647 }, - { url = "https://files.pythonhosted.org/packages/24/bb/3b1b01da5dd04c77a204c84e538ff11f624e31431cfde7201d9110b092b1/propcache-0.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:88c423efef9d7a59dae0614eaed718449c09a5ac79a5f224a8b9664d603f04a3", size = 44784 }, - { url = "https://files.pythonhosted.org/packages/58/60/f645cc8b570f99be3cf46714170c2de4b4c9d6b827b912811eff1eb8a412/propcache-0.3.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f1528ec4374617a7a753f90f20e2f551121bb558fcb35926f99e3c42367164b8", size = 77865 }, - { url = "https://files.pythonhosted.org/packages/6f/d4/c1adbf3901537582e65cf90fd9c26fde1298fde5a2c593f987112c0d0798/propcache-0.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc1915ec523b3b494933b5424980831b636fe483d7d543f7afb7b3bf00f0c10f", size = 45452 }, - { url = "https://files.pythonhosted.org/packages/d1/b5/fe752b2e63f49f727c6c1c224175d21b7d1727ce1d4873ef1c24c9216830/propcache-0.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a110205022d077da24e60b3df8bcee73971be9575dec5573dd17ae5d81751111", size = 44800 }, - { url = "https://files.pythonhosted.org/packages/62/37/fc357e345bc1971e21f76597028b059c3d795c5ca7690d7a8d9a03c9708a/propcache-0.3.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d249609e547c04d190e820d0d4c8ca03ed4582bcf8e4e160a6969ddfb57b62e5", size = 225804 }, - { url = "https://files.pythonhosted.org/packages/0d/f1/16e12c33e3dbe7f8b737809bad05719cff1dccb8df4dafbcff5575002c0e/propcache-0.3.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ced33d827625d0a589e831126ccb4f5c29dfdf6766cac441d23995a65825dcb", size = 230650 }, - { url = "https://files.pythonhosted.org/packages/3e/a2/018b9f2ed876bf5091e60153f727e8f9073d97573f790ff7cdf6bc1d1fb8/propcache-0.3.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4114c4ada8f3181af20808bedb250da6bae56660e4b8dfd9cd95d4549c0962f7", size = 234235 }, - { url = "https://files.pythonhosted.org/packages/45/5f/3faee66fc930dfb5da509e34c6ac7128870631c0e3582987fad161fcb4b1/propcache-0.3.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:975af16f406ce48f1333ec5e912fe11064605d5c5b3f6746969077cc3adeb120", size = 228249 }, - { url = "https://files.pythonhosted.org/packages/62/1e/a0d5ebda5da7ff34d2f5259a3e171a94be83c41eb1e7cd21a2105a84a02e/propcache-0.3.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a34aa3a1abc50740be6ac0ab9d594e274f59960d3ad253cd318af76b996dd654", size = 214964 }, - { url = "https://files.pythonhosted.org/packages/db/a0/d72da3f61ceab126e9be1f3bc7844b4e98c6e61c985097474668e7e52152/propcache-0.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9cec3239c85ed15bfaded997773fdad9fb5662b0a7cbc854a43f291eb183179e", size = 222501 }, - { url = "https://files.pythonhosted.org/packages/18/6d/a008e07ad7b905011253adbbd97e5b5375c33f0b961355ca0a30377504ac/propcache-0.3.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:05543250deac8e61084234d5fc54f8ebd254e8f2b39a16b1dce48904f45b744b", size = 217917 }, - { url = "https://files.pythonhosted.org/packages/98/37/02c9343ffe59e590e0e56dc5c97d0da2b8b19fa747ebacf158310f97a79a/propcache-0.3.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5cb5918253912e088edbf023788de539219718d3b10aef334476b62d2b53de53", size = 217089 }, - { url = "https://files.pythonhosted.org/packages/53/1b/d3406629a2c8a5666d4674c50f757a77be119b113eedd47b0375afdf1b42/propcache-0.3.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f3bbecd2f34d0e6d3c543fdb3b15d6b60dd69970c2b4c822379e5ec8f6f621d5", size = 228102 }, - { url = "https://files.pythonhosted.org/packages/cd/a7/3664756cf50ce739e5f3abd48febc0be1a713b1f389a502ca819791a6b69/propcache-0.3.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aca63103895c7d960a5b9b044a83f544b233c95e0dcff114389d64d762017af7", size = 230122 }, - { url = "https://files.pythonhosted.org/packages/35/36/0bbabaacdcc26dac4f8139625e930f4311864251276033a52fd52ff2a274/propcache-0.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a0a9898fdb99bf11786265468571e628ba60af80dc3f6eb89a3545540c6b0ef", size = 226818 }, - { url = "https://files.pythonhosted.org/packages/cc/27/4e0ef21084b53bd35d4dae1634b6d0bad35e9c58ed4f032511acca9d4d26/propcache-0.3.1-cp313-cp313-win32.whl", hash = "sha256:3a02a28095b5e63128bcae98eb59025924f121f048a62393db682f049bf4ac24", size = 40112 }, - { url = "https://files.pythonhosted.org/packages/a6/2c/a54614d61895ba6dd7ac8f107e2b2a0347259ab29cbf2ecc7b94fa38c4dc/propcache-0.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:813fbb8b6aea2fc9659815e585e548fe706d6f663fa73dff59a1677d4595a037", size = 44034 }, - { url = "https://files.pythonhosted.org/packages/5a/a8/0a4fd2f664fc6acc66438370905124ce62e84e2e860f2557015ee4a61c7e/propcache-0.3.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a444192f20f5ce8a5e52761a031b90f5ea6288b1eef42ad4c7e64fef33540b8f", size = 82613 }, - { url = "https://files.pythonhosted.org/packages/4d/e5/5ef30eb2cd81576256d7b6caaa0ce33cd1d2c2c92c8903cccb1af1a4ff2f/propcache-0.3.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fbe94666e62ebe36cd652f5fc012abfbc2342de99b523f8267a678e4dfdee3c", size = 47763 }, - { url = "https://files.pythonhosted.org/packages/87/9a/87091ceb048efeba4d28e903c0b15bcc84b7c0bf27dc0261e62335d9b7b8/propcache-0.3.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f011f104db880f4e2166bcdcf7f58250f7a465bc6b068dc84c824a3d4a5c94dc", size = 47175 }, - { url = "https://files.pythonhosted.org/packages/3e/2f/854e653c96ad1161f96194c6678a41bbb38c7947d17768e8811a77635a08/propcache-0.3.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e584b6d388aeb0001d6d5c2bd86b26304adde6d9bb9bfa9c4889805021b96de", size = 292265 }, - { url = "https://files.pythonhosted.org/packages/40/8d/090955e13ed06bc3496ba4a9fb26c62e209ac41973cb0d6222de20c6868f/propcache-0.3.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a17583515a04358b034e241f952f1715243482fc2c2945fd99a1b03a0bd77d6", size = 294412 }, - { url = "https://files.pythonhosted.org/packages/39/e6/d51601342e53cc7582449e6a3c14a0479fab2f0750c1f4d22302e34219c6/propcache-0.3.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5aed8d8308215089c0734a2af4f2e95eeb360660184ad3912686c181e500b2e7", size = 294290 }, - { url = "https://files.pythonhosted.org/packages/3b/4d/be5f1a90abc1881884aa5878989a1acdafd379a91d9c7e5e12cef37ec0d7/propcache-0.3.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d8e309ff9a0503ef70dc9a0ebd3e69cf7b3894c9ae2ae81fc10943c37762458", size = 282926 }, - { url = "https://files.pythonhosted.org/packages/57/2b/8f61b998c7ea93a2b7eca79e53f3e903db1787fca9373af9e2cf8dc22f9d/propcache-0.3.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b655032b202028a582d27aeedc2e813299f82cb232f969f87a4fde491a233f11", size = 267808 }, - { url = "https://files.pythonhosted.org/packages/11/1c/311326c3dfce59c58a6098388ba984b0e5fb0381ef2279ec458ef99bd547/propcache-0.3.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f64d91b751df77931336b5ff7bafbe8845c5770b06630e27acd5dbb71e1931c", size = 290916 }, - { url = "https://files.pythonhosted.org/packages/4b/74/91939924b0385e54dc48eb2e4edd1e4903ffd053cf1916ebc5347ac227f7/propcache-0.3.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:19a06db789a4bd896ee91ebc50d059e23b3639c25d58eb35be3ca1cbe967c3bf", size = 262661 }, - { url = "https://files.pythonhosted.org/packages/c2/d7/e6079af45136ad325c5337f5dd9ef97ab5dc349e0ff362fe5c5db95e2454/propcache-0.3.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:bef100c88d8692864651b5f98e871fb090bd65c8a41a1cb0ff2322db39c96c27", size = 264384 }, - { url = "https://files.pythonhosted.org/packages/b7/d5/ba91702207ac61ae6f1c2da81c5d0d6bf6ce89e08a2b4d44e411c0bbe867/propcache-0.3.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:87380fb1f3089d2a0b8b00f006ed12bd41bd858fabfa7330c954c70f50ed8757", size = 291420 }, - { url = "https://files.pythonhosted.org/packages/58/70/2117780ed7edcd7ba6b8134cb7802aada90b894a9810ec56b7bb6018bee7/propcache-0.3.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e474fc718e73ba5ec5180358aa07f6aded0ff5f2abe700e3115c37d75c947e18", size = 290880 }, - { url = "https://files.pythonhosted.org/packages/4a/1f/ecd9ce27710021ae623631c0146719280a929d895a095f6d85efb6a0be2e/propcache-0.3.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:17d1c688a443355234f3c031349da69444be052613483f3e4158eef751abcd8a", size = 287407 }, - { url = "https://files.pythonhosted.org/packages/3e/66/2e90547d6b60180fb29e23dc87bd8c116517d4255240ec6d3f7dc23d1926/propcache-0.3.1-cp313-cp313t-win32.whl", hash = "sha256:359e81a949a7619802eb601d66d37072b79b79c2505e6d3fd8b945538411400d", size = 42573 }, - { url = "https://files.pythonhosted.org/packages/cb/8f/50ad8599399d1861b4d2b6b45271f0ef6af1b09b0a2386a46dbaf19c9535/propcache-0.3.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e7fb9a84c9abbf2b2683fa3e7b0d7da4d8ecf139a1c635732a8bda29c5214b0e", size = 46757 }, - { url = "https://files.pythonhosted.org/packages/b8/d3/c3cb8f1d6ae3b37f83e1de806713a9b3642c5895f0215a62e1a4bd6e5e34/propcache-0.3.1-py3-none-any.whl", hash = "sha256:9a8ecf38de50a7f518c21568c80f985e776397b902f1ce0b01f799aba1608b40", size = 12376 }, +sdist = { url = "https://files.pythonhosted.org/packages/07/c8/fdc6686a986feae3541ea23dcaa661bd93972d3940460646c6bb96e21c40/propcache-0.3.1.tar.gz", hash = "sha256:40d980c33765359098837527e18eddefc9a24cea5b45e078a7f3bb5b032c6ecf", size = 43651, upload-time = "2025-03-26T03:06:12.05Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/aa/ca78d9be314d1e15ff517b992bebbed3bdfef5b8919e85bf4940e57b6137/propcache-0.3.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f78eb8422acc93d7b69964012ad7048764bb45a54ba7a39bb9e146c72ea29723", size = 80430, upload-time = "2025-03-26T03:04:26.436Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d8/f0c17c44d1cda0ad1979af2e593ea290defdde9eaeb89b08abbe02a5e8e1/propcache-0.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:89498dd49c2f9a026ee057965cdf8192e5ae070ce7d7a7bd4b66a8e257d0c976", size = 46637, upload-time = "2025-03-26T03:04:27.932Z" }, + { url = "https://files.pythonhosted.org/packages/ae/bd/c1e37265910752e6e5e8a4c1605d0129e5b7933c3dc3cf1b9b48ed83b364/propcache-0.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:09400e98545c998d57d10035ff623266927cb784d13dd2b31fd33b8a5316b85b", size = 46123, upload-time = "2025-03-26T03:04:30.659Z" }, + { url = "https://files.pythonhosted.org/packages/d4/b0/911eda0865f90c0c7e9f0415d40a5bf681204da5fd7ca089361a64c16b28/propcache-0.3.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa8efd8c5adc5a2c9d3b952815ff8f7710cefdcaf5f2c36d26aff51aeca2f12f", size = 243031, upload-time = "2025-03-26T03:04:31.977Z" }, + { url = "https://files.pythonhosted.org/packages/0a/06/0da53397c76a74271621807265b6eb61fb011451b1ddebf43213df763669/propcache-0.3.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2fe5c910f6007e716a06d269608d307b4f36e7babee5f36533722660e8c4a70", size = 249100, upload-time = "2025-03-26T03:04:33.45Z" }, + { url = "https://files.pythonhosted.org/packages/f1/eb/13090e05bf6b963fc1653cdc922133ced467cb4b8dab53158db5a37aa21e/propcache-0.3.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a0ab8cf8cdd2194f8ff979a43ab43049b1df0b37aa64ab7eca04ac14429baeb7", size = 250170, upload-time = "2025-03-26T03:04:35.542Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4c/f72c9e1022b3b043ec7dc475a0f405d4c3e10b9b1d378a7330fecf0652da/propcache-0.3.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:563f9d8c03ad645597b8d010ef4e9eab359faeb11a0a2ac9f7b4bc8c28ebef25", size = 245000, upload-time = "2025-03-26T03:04:37.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/fd/970ca0e22acc829f1adf5de3724085e778c1ad8a75bec010049502cb3a86/propcache-0.3.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb6e0faf8cb6b4beea5d6ed7b5a578254c6d7df54c36ccd3d8b3eb00d6770277", size = 230262, upload-time = "2025-03-26T03:04:39.532Z" }, + { url = "https://files.pythonhosted.org/packages/c4/42/817289120c6b9194a44f6c3e6b2c3277c5b70bbad39e7df648f177cc3634/propcache-0.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1c5c7ab7f2bb3f573d1cb921993006ba2d39e8621019dffb1c5bc94cdbae81e8", size = 236772, upload-time = "2025-03-26T03:04:41.109Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9c/3b3942b302badd589ad6b672da3ca7b660a6c2f505cafd058133ddc73918/propcache-0.3.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:050b571b2e96ec942898f8eb46ea4bfbb19bd5502424747e83badc2d4a99a44e", size = 231133, upload-time = "2025-03-26T03:04:42.544Z" }, + { url = "https://files.pythonhosted.org/packages/98/a1/75f6355f9ad039108ff000dfc2e19962c8dea0430da9a1428e7975cf24b2/propcache-0.3.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e1c4d24b804b3a87e9350f79e2371a705a188d292fd310e663483af6ee6718ee", size = 230741, upload-time = "2025-03-26T03:04:44.06Z" }, + { url = "https://files.pythonhosted.org/packages/67/0c/3e82563af77d1f8731132166da69fdfd95e71210e31f18edce08a1eb11ea/propcache-0.3.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e4fe2a6d5ce975c117a6bb1e8ccda772d1e7029c1cca1acd209f91d30fa72815", size = 244047, upload-time = "2025-03-26T03:04:45.983Z" }, + { url = "https://files.pythonhosted.org/packages/f7/50/9fb7cca01532a08c4d5186d7bb2da6c4c587825c0ae134b89b47c7d62628/propcache-0.3.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:feccd282de1f6322f56f6845bf1207a537227812f0a9bf5571df52bb418d79d5", size = 246467, upload-time = "2025-03-26T03:04:47.699Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/ccbcf3e1c604c16cc525309161d57412c23cf2351523aedbb280eb7c9094/propcache-0.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ec314cde7314d2dd0510c6787326bbffcbdc317ecee6b7401ce218b3099075a7", size = 241022, upload-time = "2025-03-26T03:04:49.195Z" }, + { url = "https://files.pythonhosted.org/packages/db/19/e777227545e09ca1e77a6e21274ae9ec45de0f589f0ce3eca2a41f366220/propcache-0.3.1-cp312-cp312-win32.whl", hash = "sha256:7d2d5a0028d920738372630870e7d9644ce437142197f8c827194fca404bf03b", size = 40647, upload-time = "2025-03-26T03:04:50.595Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/3b1b01da5dd04c77a204c84e538ff11f624e31431cfde7201d9110b092b1/propcache-0.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:88c423efef9d7a59dae0614eaed718449c09a5ac79a5f224a8b9664d603f04a3", size = 44784, upload-time = "2025-03-26T03:04:51.791Z" }, + { url = "https://files.pythonhosted.org/packages/58/60/f645cc8b570f99be3cf46714170c2de4b4c9d6b827b912811eff1eb8a412/propcache-0.3.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f1528ec4374617a7a753f90f20e2f551121bb558fcb35926f99e3c42367164b8", size = 77865, upload-time = "2025-03-26T03:04:53.406Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d4/c1adbf3901537582e65cf90fd9c26fde1298fde5a2c593f987112c0d0798/propcache-0.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc1915ec523b3b494933b5424980831b636fe483d7d543f7afb7b3bf00f0c10f", size = 45452, upload-time = "2025-03-26T03:04:54.624Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b5/fe752b2e63f49f727c6c1c224175d21b7d1727ce1d4873ef1c24c9216830/propcache-0.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a110205022d077da24e60b3df8bcee73971be9575dec5573dd17ae5d81751111", size = 44800, upload-time = "2025-03-26T03:04:55.844Z" }, + { url = "https://files.pythonhosted.org/packages/62/37/fc357e345bc1971e21f76597028b059c3d795c5ca7690d7a8d9a03c9708a/propcache-0.3.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d249609e547c04d190e820d0d4c8ca03ed4582bcf8e4e160a6969ddfb57b62e5", size = 225804, upload-time = "2025-03-26T03:04:57.158Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f1/16e12c33e3dbe7f8b737809bad05719cff1dccb8df4dafbcff5575002c0e/propcache-0.3.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ced33d827625d0a589e831126ccb4f5c29dfdf6766cac441d23995a65825dcb", size = 230650, upload-time = "2025-03-26T03:04:58.61Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a2/018b9f2ed876bf5091e60153f727e8f9073d97573f790ff7cdf6bc1d1fb8/propcache-0.3.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4114c4ada8f3181af20808bedb250da6bae56660e4b8dfd9cd95d4549c0962f7", size = 234235, upload-time = "2025-03-26T03:05:00.599Z" }, + { url = "https://files.pythonhosted.org/packages/45/5f/3faee66fc930dfb5da509e34c6ac7128870631c0e3582987fad161fcb4b1/propcache-0.3.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:975af16f406ce48f1333ec5e912fe11064605d5c5b3f6746969077cc3adeb120", size = 228249, upload-time = "2025-03-26T03:05:02.11Z" }, + { url = "https://files.pythonhosted.org/packages/62/1e/a0d5ebda5da7ff34d2f5259a3e171a94be83c41eb1e7cd21a2105a84a02e/propcache-0.3.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a34aa3a1abc50740be6ac0ab9d594e274f59960d3ad253cd318af76b996dd654", size = 214964, upload-time = "2025-03-26T03:05:03.599Z" }, + { url = "https://files.pythonhosted.org/packages/db/a0/d72da3f61ceab126e9be1f3bc7844b4e98c6e61c985097474668e7e52152/propcache-0.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9cec3239c85ed15bfaded997773fdad9fb5662b0a7cbc854a43f291eb183179e", size = 222501, upload-time = "2025-03-26T03:05:05.107Z" }, + { url = "https://files.pythonhosted.org/packages/18/6d/a008e07ad7b905011253adbbd97e5b5375c33f0b961355ca0a30377504ac/propcache-0.3.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:05543250deac8e61084234d5fc54f8ebd254e8f2b39a16b1dce48904f45b744b", size = 217917, upload-time = "2025-03-26T03:05:06.59Z" }, + { url = "https://files.pythonhosted.org/packages/98/37/02c9343ffe59e590e0e56dc5c97d0da2b8b19fa747ebacf158310f97a79a/propcache-0.3.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5cb5918253912e088edbf023788de539219718d3b10aef334476b62d2b53de53", size = 217089, upload-time = "2025-03-26T03:05:08.1Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/d3406629a2c8a5666d4674c50f757a77be119b113eedd47b0375afdf1b42/propcache-0.3.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f3bbecd2f34d0e6d3c543fdb3b15d6b60dd69970c2b4c822379e5ec8f6f621d5", size = 228102, upload-time = "2025-03-26T03:05:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/cd/a7/3664756cf50ce739e5f3abd48febc0be1a713b1f389a502ca819791a6b69/propcache-0.3.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aca63103895c7d960a5b9b044a83f544b233c95e0dcff114389d64d762017af7", size = 230122, upload-time = "2025-03-26T03:05:11.408Z" }, + { url = "https://files.pythonhosted.org/packages/35/36/0bbabaacdcc26dac4f8139625e930f4311864251276033a52fd52ff2a274/propcache-0.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a0a9898fdb99bf11786265468571e628ba60af80dc3f6eb89a3545540c6b0ef", size = 226818, upload-time = "2025-03-26T03:05:12.909Z" }, + { url = "https://files.pythonhosted.org/packages/cc/27/4e0ef21084b53bd35d4dae1634b6d0bad35e9c58ed4f032511acca9d4d26/propcache-0.3.1-cp313-cp313-win32.whl", hash = "sha256:3a02a28095b5e63128bcae98eb59025924f121f048a62393db682f049bf4ac24", size = 40112, upload-time = "2025-03-26T03:05:14.289Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2c/a54614d61895ba6dd7ac8f107e2b2a0347259ab29cbf2ecc7b94fa38c4dc/propcache-0.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:813fbb8b6aea2fc9659815e585e548fe706d6f663fa73dff59a1677d4595a037", size = 44034, upload-time = "2025-03-26T03:05:15.616Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a8/0a4fd2f664fc6acc66438370905124ce62e84e2e860f2557015ee4a61c7e/propcache-0.3.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a444192f20f5ce8a5e52761a031b90f5ea6288b1eef42ad4c7e64fef33540b8f", size = 82613, upload-time = "2025-03-26T03:05:16.913Z" }, + { url = "https://files.pythonhosted.org/packages/4d/e5/5ef30eb2cd81576256d7b6caaa0ce33cd1d2c2c92c8903cccb1af1a4ff2f/propcache-0.3.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fbe94666e62ebe36cd652f5fc012abfbc2342de99b523f8267a678e4dfdee3c", size = 47763, upload-time = "2025-03-26T03:05:18.607Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/87091ceb048efeba4d28e903c0b15bcc84b7c0bf27dc0261e62335d9b7b8/propcache-0.3.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f011f104db880f4e2166bcdcf7f58250f7a465bc6b068dc84c824a3d4a5c94dc", size = 47175, upload-time = "2025-03-26T03:05:19.85Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2f/854e653c96ad1161f96194c6678a41bbb38c7947d17768e8811a77635a08/propcache-0.3.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e584b6d388aeb0001d6d5c2bd86b26304adde6d9bb9bfa9c4889805021b96de", size = 292265, upload-time = "2025-03-26T03:05:21.654Z" }, + { url = "https://files.pythonhosted.org/packages/40/8d/090955e13ed06bc3496ba4a9fb26c62e209ac41973cb0d6222de20c6868f/propcache-0.3.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a17583515a04358b034e241f952f1715243482fc2c2945fd99a1b03a0bd77d6", size = 294412, upload-time = "2025-03-26T03:05:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/39/e6/d51601342e53cc7582449e6a3c14a0479fab2f0750c1f4d22302e34219c6/propcache-0.3.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5aed8d8308215089c0734a2af4f2e95eeb360660184ad3912686c181e500b2e7", size = 294290, upload-time = "2025-03-26T03:05:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4d/be5f1a90abc1881884aa5878989a1acdafd379a91d9c7e5e12cef37ec0d7/propcache-0.3.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d8e309ff9a0503ef70dc9a0ebd3e69cf7b3894c9ae2ae81fc10943c37762458", size = 282926, upload-time = "2025-03-26T03:05:26.459Z" }, + { url = "https://files.pythonhosted.org/packages/57/2b/8f61b998c7ea93a2b7eca79e53f3e903db1787fca9373af9e2cf8dc22f9d/propcache-0.3.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b655032b202028a582d27aeedc2e813299f82cb232f969f87a4fde491a233f11", size = 267808, upload-time = "2025-03-26T03:05:28.188Z" }, + { url = "https://files.pythonhosted.org/packages/11/1c/311326c3dfce59c58a6098388ba984b0e5fb0381ef2279ec458ef99bd547/propcache-0.3.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f64d91b751df77931336b5ff7bafbe8845c5770b06630e27acd5dbb71e1931c", size = 290916, upload-time = "2025-03-26T03:05:29.757Z" }, + { url = "https://files.pythonhosted.org/packages/4b/74/91939924b0385e54dc48eb2e4edd1e4903ffd053cf1916ebc5347ac227f7/propcache-0.3.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:19a06db789a4bd896ee91ebc50d059e23b3639c25d58eb35be3ca1cbe967c3bf", size = 262661, upload-time = "2025-03-26T03:05:31.472Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/e6079af45136ad325c5337f5dd9ef97ab5dc349e0ff362fe5c5db95e2454/propcache-0.3.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:bef100c88d8692864651b5f98e871fb090bd65c8a41a1cb0ff2322db39c96c27", size = 264384, upload-time = "2025-03-26T03:05:32.984Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d5/ba91702207ac61ae6f1c2da81c5d0d6bf6ce89e08a2b4d44e411c0bbe867/propcache-0.3.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:87380fb1f3089d2a0b8b00f006ed12bd41bd858fabfa7330c954c70f50ed8757", size = 291420, upload-time = "2025-03-26T03:05:34.496Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/2117780ed7edcd7ba6b8134cb7802aada90b894a9810ec56b7bb6018bee7/propcache-0.3.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e474fc718e73ba5ec5180358aa07f6aded0ff5f2abe700e3115c37d75c947e18", size = 290880, upload-time = "2025-03-26T03:05:36.256Z" }, + { url = "https://files.pythonhosted.org/packages/4a/1f/ecd9ce27710021ae623631c0146719280a929d895a095f6d85efb6a0be2e/propcache-0.3.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:17d1c688a443355234f3c031349da69444be052613483f3e4158eef751abcd8a", size = 287407, upload-time = "2025-03-26T03:05:37.799Z" }, + { url = "https://files.pythonhosted.org/packages/3e/66/2e90547d6b60180fb29e23dc87bd8c116517d4255240ec6d3f7dc23d1926/propcache-0.3.1-cp313-cp313t-win32.whl", hash = "sha256:359e81a949a7619802eb601d66d37072b79b79c2505e6d3fd8b945538411400d", size = 42573, upload-time = "2025-03-26T03:05:39.193Z" }, + { url = "https://files.pythonhosted.org/packages/cb/8f/50ad8599399d1861b4d2b6b45271f0ef6af1b09b0a2386a46dbaf19c9535/propcache-0.3.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e7fb9a84c9abbf2b2683fa3e7b0d7da4d8ecf139a1c635732a8bda29c5214b0e", size = 46757, upload-time = "2025-03-26T03:05:40.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d3/c3cb8f1d6ae3b37f83e1de806713a9b3642c5895f0215a62e1a4bd6e5e34/propcache-0.3.1-py3-none-any.whl", hash = "sha256:9a8ecf38de50a7f518c21568c80f985e776397b902f1ce0b01f799aba1608b40", size = 12376, upload-time = "2025-03-26T03:06:10.5Z" }, ] [[package]] @@ -732,9 +716,9 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/67/97/eea08f74f1c6dd2a02ee81b4ebfe5b558beb468ebbd11031adbf58d31be0/psycopg-3.2.6.tar.gz", hash = "sha256:16fa094efa2698f260f2af74f3710f781e4a6f226efe9d1fd0c37f384639ed8a", size = 156322 } +sdist = { url = "https://files.pythonhosted.org/packages/67/97/eea08f74f1c6dd2a02ee81b4ebfe5b558beb468ebbd11031adbf58d31be0/psycopg-3.2.6.tar.gz", hash = "sha256:16fa094efa2698f260f2af74f3710f781e4a6f226efe9d1fd0c37f384639ed8a", size = 156322, upload-time = "2025-03-12T20:43:12.228Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/7d/0ba52deff71f65df8ec8038adad86ba09368c945424a9bd8145d679a2c6a/psycopg-3.2.6-py3-none-any.whl", hash = "sha256:f3ff5488525890abb0566c429146add66b329e20d6d4835662b920cbbf90ac58", size = 199077 }, + { url = "https://files.pythonhosted.org/packages/d7/7d/0ba52deff71f65df8ec8038adad86ba09368c945424a9bd8145d679a2c6a/psycopg-3.2.6-py3-none-any.whl", hash = "sha256:f3ff5488525890abb0566c429146add66b329e20d6d4835662b920cbbf90ac58", size = 199077, upload-time = "2025-03-12T20:38:07.112Z" }, ] [package.optional-dependencies] @@ -747,28 +731,28 @@ name = "psycopg-binary" version = "3.2.6" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/c7/220b1273f0befb2cd9fe83d379b3484ae029a88798a90bc0d36f10bea5df/psycopg_binary-3.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f27a46ff0497e882e8c0286e8833c785b4d1a80f23e1bf606f4c90e5f9f3ce75", size = 3857986 }, - { url = "https://files.pythonhosted.org/packages/8a/d8/30176532826cf87c608a6f79dd668bf9aff0cdf8eb80209eddf4c5aa7229/psycopg_binary-3.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b30ee4821ded7de48b8048b14952512588e7c5477b0a5965221e1798afba61a1", size = 3940060 }, - { url = "https://files.pythonhosted.org/packages/54/7c/fa7cd1f057f33f7ae483d6bc5a03ec6eff111f8aa5c678d9aaef92705247/psycopg_binary-3.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e57edf3b1f5427f39660225b01f8e7b97f5cfab132092f014bf1638bc85d81d2", size = 4499082 }, - { url = "https://files.pythonhosted.org/packages/b8/81/1606966f6146187c273993ea6f88f2151b26741df8f4e01349a625983be9/psycopg_binary-3.2.6-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c5172ce3e4ae7a4fd450070210f801e2ce6bc0f11d1208d29268deb0cda34de", size = 4307509 }, - { url = "https://files.pythonhosted.org/packages/69/ad/01c87aab17a4b89128b8036800d11ab296c7c2c623940cc7e6f2668f375a/psycopg_binary-3.2.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcfab3804c43571a6615e559cdc4c4115785d258a4dd71a721be033f5f5f378d", size = 4547813 }, - { url = "https://files.pythonhosted.org/packages/65/30/f93a193846ee738ffe5d2a4837e7ddeb7279707af81d088cee96cae853a0/psycopg_binary-3.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fa1c920cce16f1205f37b20c685c58b9656b170b8b4c93629100d342d0d118e", size = 4259847 }, - { url = "https://files.pythonhosted.org/packages/8e/73/65c4ae71be86675a62154407c92af4b917146f9ff3baaf0e4166c0734aeb/psycopg_binary-3.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2e118d818101c1608c6b5ba52a6c977614d8f05aa89467501172ba4d10588e11", size = 3846550 }, - { url = "https://files.pythonhosted.org/packages/53/cc/a24626cac3f208c776bb22e15e9a5e483aa81145221e6427e50381f40811/psycopg_binary-3.2.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:763319a8bfeca77d31512da71f5a33459b9568a7621c481c3828c62f9c38f351", size = 3320269 }, - { url = "https://files.pythonhosted.org/packages/55/e6/68c76fb9d6c53d5e4170a0c9216c7aa6c2903808f626d84d002b47a16931/psycopg_binary-3.2.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2fbc05819560389dbece046966bc88e0f2ea77673497e274c4293b8b4c1d0703", size = 3399365 }, - { url = "https://files.pythonhosted.org/packages/b4/2c/55b140f5a2c582dae42ef38502c45ef69c938274242a40bd04c143081029/psycopg_binary-3.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5a57f99bb953b4bd6f32d0a9844664e7f6ca5ead9ba40e96635be3cd30794813", size = 3438908 }, - { url = "https://files.pythonhosted.org/packages/ae/f6/589c95cceccee2ab408b6b2e16f1ed6db4536fb24f2f5c9ce568cf43270c/psycopg_binary-3.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:5de6809e19a465dcb9c269675bded46a135f2d600cd99f0735afbb21ddad2af4", size = 2782886 }, - { url = "https://files.pythonhosted.org/packages/bf/32/3d06c478fd3070ac25a49c2e8ca46b6d76b0048fa9fa255b99ee32f32312/psycopg_binary-3.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54af3fbf871baa2eb19df96fd7dc0cbd88e628a692063c3d1ab5cdd00aa04322", size = 3852672 }, - { url = "https://files.pythonhosted.org/packages/34/97/e581030e279500ede3096adb510f0e6071874b97cfc047a9a87b7d71fc77/psycopg_binary-3.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ad5da1e4636776c21eaeacdec42f25fa4612631a12f25cd9ab34ddf2c346ffb9", size = 3936562 }, - { url = "https://files.pythonhosted.org/packages/74/b6/6a8df4cb23c3d327403a83406c06c9140f311cb56c4e4d720ee7abf6fddc/psycopg_binary-3.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7956b9ea56f79cd86eddcfbfc65ae2af1e4fe7932fa400755005d903c709370", size = 4499167 }, - { url = "https://files.pythonhosted.org/packages/e4/5b/950eafef61e5e0b8ddb5afc5b6b279756411aa4bf70a346a6f091ad679bb/psycopg_binary-3.2.6-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e2efb763188008cf2914820dcb9fb23c10fe2be0d2c97ef0fac7cec28e281d8", size = 4311651 }, - { url = "https://files.pythonhosted.org/packages/72/b9/b366c49afc854c26b3053d4d35376046eea9aebdc48ded18ea249ea1f80c/psycopg_binary-3.2.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b3aab3451679f1e7932270e950259ed48c3b79390022d3f660491c0e65e4838", size = 4547852 }, - { url = "https://files.pythonhosted.org/packages/ab/d4/0e047360e2ea387dc7171ca017ffcee5214a0762f74b9dd982035f2e52fb/psycopg_binary-3.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:849a370ac4e125f55f2ad37f928e588291a67ccf91fa33d0b1e042bb3ee1f986", size = 4261725 }, - { url = "https://files.pythonhosted.org/packages/e3/ea/a1b969804250183900959ebe845d86be7fed2cbd9be58f64cd0fc24b2892/psycopg_binary-3.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:566d4ace928419d91f1eb3227fc9ef7b41cf0ad22e93dd2c3368d693cf144408", size = 3850073 }, - { url = "https://files.pythonhosted.org/packages/e5/71/ec2907342f0675092b76aea74365b56f38d960c4c635984dcfe25d8178c8/psycopg_binary-3.2.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f1981f13b10de2f11cfa2f99a8738b35b3f0a0f3075861446894a8d3042430c0", size = 3320323 }, - { url = "https://files.pythonhosted.org/packages/d7/d7/0d2cb4b42f231e2efe8ea1799ce917973d47486212a2c4d33cd331e7ac28/psycopg_binary-3.2.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:36f598300b55b3c983ae8df06473ad27333d2fd9f3e2cfdb913b3a5aaa3a8bcf", size = 3402335 }, - { url = "https://files.pythonhosted.org/packages/66/92/7050c372f78e53eba14695cec6c3a91b2d9ca56feaf0bfe95fe90facf730/psycopg_binary-3.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0f4699fa5fe1fffb0d6b2d14b31fd8c29b7ea7375f89d5989f002aaf21728b21", size = 3440442 }, - { url = "https://files.pythonhosted.org/packages/5f/4c/bebcaf754189283b2f3d457822a3d9b233d08ff50973d8f1e8d51f4d35ed/psycopg_binary-3.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:afe697b8b0071f497c5d4c0f41df9e038391534f5614f7fb3a8c1ca32d66e860", size = 2783465 }, + { url = "https://files.pythonhosted.org/packages/a3/c7/220b1273f0befb2cd9fe83d379b3484ae029a88798a90bc0d36f10bea5df/psycopg_binary-3.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f27a46ff0497e882e8c0286e8833c785b4d1a80f23e1bf606f4c90e5f9f3ce75", size = 3857986, upload-time = "2025-03-12T20:39:54.482Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d8/30176532826cf87c608a6f79dd668bf9aff0cdf8eb80209eddf4c5aa7229/psycopg_binary-3.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b30ee4821ded7de48b8048b14952512588e7c5477b0a5965221e1798afba61a1", size = 3940060, upload-time = "2025-03-12T20:39:58.835Z" }, + { url = "https://files.pythonhosted.org/packages/54/7c/fa7cd1f057f33f7ae483d6bc5a03ec6eff111f8aa5c678d9aaef92705247/psycopg_binary-3.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e57edf3b1f5427f39660225b01f8e7b97f5cfab132092f014bf1638bc85d81d2", size = 4499082, upload-time = "2025-03-12T20:40:03.605Z" }, + { url = "https://files.pythonhosted.org/packages/b8/81/1606966f6146187c273993ea6f88f2151b26741df8f4e01349a625983be9/psycopg_binary-3.2.6-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c5172ce3e4ae7a4fd450070210f801e2ce6bc0f11d1208d29268deb0cda34de", size = 4307509, upload-time = "2025-03-12T20:40:07.996Z" }, + { url = "https://files.pythonhosted.org/packages/69/ad/01c87aab17a4b89128b8036800d11ab296c7c2c623940cc7e6f2668f375a/psycopg_binary-3.2.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcfab3804c43571a6615e559cdc4c4115785d258a4dd71a721be033f5f5f378d", size = 4547813, upload-time = "2025-03-12T20:40:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/f93a193846ee738ffe5d2a4837e7ddeb7279707af81d088cee96cae853a0/psycopg_binary-3.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fa1c920cce16f1205f37b20c685c58b9656b170b8b4c93629100d342d0d118e", size = 4259847, upload-time = "2025-03-12T20:40:17.684Z" }, + { url = "https://files.pythonhosted.org/packages/8e/73/65c4ae71be86675a62154407c92af4b917146f9ff3baaf0e4166c0734aeb/psycopg_binary-3.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2e118d818101c1608c6b5ba52a6c977614d8f05aa89467501172ba4d10588e11", size = 3846550, upload-time = "2025-03-12T20:40:22.336Z" }, + { url = "https://files.pythonhosted.org/packages/53/cc/a24626cac3f208c776bb22e15e9a5e483aa81145221e6427e50381f40811/psycopg_binary-3.2.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:763319a8bfeca77d31512da71f5a33459b9568a7621c481c3828c62f9c38f351", size = 3320269, upload-time = "2025-03-12T20:40:26.919Z" }, + { url = "https://files.pythonhosted.org/packages/55/e6/68c76fb9d6c53d5e4170a0c9216c7aa6c2903808f626d84d002b47a16931/psycopg_binary-3.2.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2fbc05819560389dbece046966bc88e0f2ea77673497e274c4293b8b4c1d0703", size = 3399365, upload-time = "2025-03-12T20:40:30.945Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2c/55b140f5a2c582dae42ef38502c45ef69c938274242a40bd04c143081029/psycopg_binary-3.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5a57f99bb953b4bd6f32d0a9844664e7f6ca5ead9ba40e96635be3cd30794813", size = 3438908, upload-time = "2025-03-12T20:40:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f6/589c95cceccee2ab408b6b2e16f1ed6db4536fb24f2f5c9ce568cf43270c/psycopg_binary-3.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:5de6809e19a465dcb9c269675bded46a135f2d600cd99f0735afbb21ddad2af4", size = 2782886, upload-time = "2025-03-12T20:40:38.493Z" }, + { url = "https://files.pythonhosted.org/packages/bf/32/3d06c478fd3070ac25a49c2e8ca46b6d76b0048fa9fa255b99ee32f32312/psycopg_binary-3.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54af3fbf871baa2eb19df96fd7dc0cbd88e628a692063c3d1ab5cdd00aa04322", size = 3852672, upload-time = "2025-03-12T20:40:42.083Z" }, + { url = "https://files.pythonhosted.org/packages/34/97/e581030e279500ede3096adb510f0e6071874b97cfc047a9a87b7d71fc77/psycopg_binary-3.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ad5da1e4636776c21eaeacdec42f25fa4612631a12f25cd9ab34ddf2c346ffb9", size = 3936562, upload-time = "2025-03-12T20:40:46.709Z" }, + { url = "https://files.pythonhosted.org/packages/74/b6/6a8df4cb23c3d327403a83406c06c9140f311cb56c4e4d720ee7abf6fddc/psycopg_binary-3.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7956b9ea56f79cd86eddcfbfc65ae2af1e4fe7932fa400755005d903c709370", size = 4499167, upload-time = "2025-03-12T20:40:51.978Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/950eafef61e5e0b8ddb5afc5b6b279756411aa4bf70a346a6f091ad679bb/psycopg_binary-3.2.6-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e2efb763188008cf2914820dcb9fb23c10fe2be0d2c97ef0fac7cec28e281d8", size = 4311651, upload-time = "2025-03-12T20:40:56.99Z" }, + { url = "https://files.pythonhosted.org/packages/72/b9/b366c49afc854c26b3053d4d35376046eea9aebdc48ded18ea249ea1f80c/psycopg_binary-3.2.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b3aab3451679f1e7932270e950259ed48c3b79390022d3f660491c0e65e4838", size = 4547852, upload-time = "2025-03-12T20:41:01.379Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d4/0e047360e2ea387dc7171ca017ffcee5214a0762f74b9dd982035f2e52fb/psycopg_binary-3.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:849a370ac4e125f55f2ad37f928e588291a67ccf91fa33d0b1e042bb3ee1f986", size = 4261725, upload-time = "2025-03-12T20:41:05.576Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ea/a1b969804250183900959ebe845d86be7fed2cbd9be58f64cd0fc24b2892/psycopg_binary-3.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:566d4ace928419d91f1eb3227fc9ef7b41cf0ad22e93dd2c3368d693cf144408", size = 3850073, upload-time = "2025-03-12T20:41:10.362Z" }, + { url = "https://files.pythonhosted.org/packages/e5/71/ec2907342f0675092b76aea74365b56f38d960c4c635984dcfe25d8178c8/psycopg_binary-3.2.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f1981f13b10de2f11cfa2f99a8738b35b3f0a0f3075861446894a8d3042430c0", size = 3320323, upload-time = "2025-03-12T20:41:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d7/0d2cb4b42f231e2efe8ea1799ce917973d47486212a2c4d33cd331e7ac28/psycopg_binary-3.2.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:36f598300b55b3c983ae8df06473ad27333d2fd9f3e2cfdb913b3a5aaa3a8bcf", size = 3402335, upload-time = "2025-03-12T20:41:19.103Z" }, + { url = "https://files.pythonhosted.org/packages/66/92/7050c372f78e53eba14695cec6c3a91b2d9ca56feaf0bfe95fe90facf730/psycopg_binary-3.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0f4699fa5fe1fffb0d6b2d14b31fd8c29b7ea7375f89d5989f002aaf21728b21", size = 3440442, upload-time = "2025-03-12T20:41:23.979Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4c/bebcaf754189283b2f3d457822a3d9b233d08ff50973d8f1e8d51f4d35ed/psycopg_binary-3.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:afe697b8b0071f497c5d4c0f41df9e038391534f5614f7fb3a8c1ca32d66e860", size = 2783465, upload-time = "2025-03-12T20:41:30.32Z" }, ] [[package]] @@ -778,9 +762,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cf/13/1e7850bb2c69a63267c3dbf37387d3f71a00fd0e2fa55c5db14d64ba1af4/psycopg_pool-3.2.6.tar.gz", hash = "sha256:0f92a7817719517212fbfe2fd58b8c35c1850cdd2a80d36b581ba2085d9148e5", size = 29770 } +sdist = { url = "https://files.pythonhosted.org/packages/cf/13/1e7850bb2c69a63267c3dbf37387d3f71a00fd0e2fa55c5db14d64ba1af4/psycopg_pool-3.2.6.tar.gz", hash = "sha256:0f92a7817719517212fbfe2fd58b8c35c1850cdd2a80d36b581ba2085d9148e5", size = 29770, upload-time = "2025-02-26T12:03:47.129Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/fd/4feb52a55c1a4bd748f2acaed1903ab54a723c47f6d0242780f4d97104d4/psycopg_pool-3.2.6-py3-none-any.whl", hash = "sha256:5887318a9f6af906d041a0b1dc1c60f8f0dda8340c2572b74e10907b51ed5da7", size = 38252 }, + { url = "https://files.pythonhosted.org/packages/47/fd/4feb52a55c1a4bd748f2acaed1903ab54a723c47f6d0242780f4d97104d4/psycopg_pool-3.2.6-py3-none-any.whl", hash = "sha256:5887318a9f6af906d041a0b1dc1c60f8f0dda8340c2572b74e10907b51ed5da7", size = 38252, upload-time = "2025-02-26T12:03:45.073Z" }, ] [[package]] @@ -793,9 +777,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/93/a3/698b87a4d4d303d7c5f62ea5fbf7a79cab236ccfbd0a17847b7f77f8163e/pydantic-2.11.1.tar.gz", hash = "sha256:442557d2910e75c991c39f4b4ab18963d57b9b55122c8b2a9cd176d8c29ce968", size = 782817 } +sdist = { url = "https://files.pythonhosted.org/packages/93/a3/698b87a4d4d303d7c5f62ea5fbf7a79cab236ccfbd0a17847b7f77f8163e/pydantic-2.11.1.tar.gz", hash = "sha256:442557d2910e75c991c39f4b4ab18963d57b9b55122c8b2a9cd176d8c29ce968", size = 782817, upload-time = "2025-03-28T21:14:58.347Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/12/f9221a949f2419e2e23847303c002476c26fbcfd62dc7f3d25d0bec5ca99/pydantic-2.11.1-py3-none-any.whl", hash = "sha256:5b6c415eee9f8123a14d859be0c84363fec6b1feb6b688d6435801230b56e0b8", size = 442648 }, + { url = "https://files.pythonhosted.org/packages/cc/12/f9221a949f2419e2e23847303c002476c26fbcfd62dc7f3d25d0bec5ca99/pydantic-2.11.1-py3-none-any.whl", hash = "sha256:5b6c415eee9f8123a14d859be0c84363fec6b1feb6b688d6435801230b56e0b8", size = 442648, upload-time = "2025-03-28T21:14:55.856Z" }, ] [[package]] @@ -805,39 +789,39 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/05/91ce14dfd5a3a99555fce436318cc0fd1f08c4daa32b3248ad63669ea8b4/pydantic_core-2.33.0.tar.gz", hash = "sha256:40eb8af662ba409c3cbf4a8150ad32ae73514cd7cb1f1a2113af39763dd616b3", size = 434080 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/c4/c9381323cbdc1bb26d352bc184422ce77c4bc2f2312b782761093a59fafc/pydantic_core-2.33.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6c32a40712e3662bebe524abe8abb757f2fa2000028d64cc5a1006016c06af43", size = 2025127 }, - { url = "https://files.pythonhosted.org/packages/6f/bd/af35278080716ecab8f57e84515c7dc535ed95d1c7f52c1c6f7b313a9dab/pydantic_core-2.33.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ec86b5baa36f0a0bfb37db86c7d52652f8e8aa076ab745ef7725784183c3fdd", size = 1851687 }, - { url = "https://files.pythonhosted.org/packages/12/e4/a01461225809c3533c23bd1916b1e8c2e21727f0fea60ab1acbffc4e2fca/pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4deac83a8cc1d09e40683be0bc6d1fa4cde8df0a9bf0cda5693f9b0569ac01b6", size = 1892232 }, - { url = "https://files.pythonhosted.org/packages/51/17/3d53d62a328fb0a49911c2962036b9e7a4f781b7d15e9093c26299e5f76d/pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:175ab598fb457a9aee63206a1993874badf3ed9a456e0654273e56f00747bbd6", size = 1977896 }, - { url = "https://files.pythonhosted.org/packages/30/98/01f9d86e02ec4a38f4b02086acf067f2c776b845d43f901bd1ee1c21bc4b/pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f36afd0d56a6c42cf4e8465b6441cf546ed69d3a4ec92724cc9c8c61bd6ecf4", size = 2127717 }, - { url = "https://files.pythonhosted.org/packages/3c/43/6f381575c61b7c58b0fd0b92134c5a1897deea4cdfc3d47567b3ff460a4e/pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a98257451164666afafc7cbf5fb00d613e33f7e7ebb322fbcd99345695a9a61", size = 2680287 }, - { url = "https://files.pythonhosted.org/packages/01/42/c0d10d1451d161a9a0da9bbef023b8005aa26e9993a8cc24dc9e3aa96c93/pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecc6d02d69b54a2eb83ebcc6f29df04957f734bcf309d346b4f83354d8376862", size = 2008276 }, - { url = "https://files.pythonhosted.org/packages/20/ca/e08df9dba546905c70bae44ced9f3bea25432e34448d95618d41968f40b7/pydantic_core-2.33.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a69b7596c6603afd049ce7f3835bcf57dd3892fc7279f0ddf987bebed8caa5a", size = 2115305 }, - { url = "https://files.pythonhosted.org/packages/03/1f/9b01d990730a98833113581a78e595fd40ed4c20f9693f5a658fb5f91eff/pydantic_core-2.33.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ea30239c148b6ef41364c6f51d103c2988965b643d62e10b233b5efdca8c0099", size = 2068999 }, - { url = "https://files.pythonhosted.org/packages/20/18/fe752476a709191148e8b1e1139147841ea5d2b22adcde6ee6abb6c8e7cf/pydantic_core-2.33.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:abfa44cf2f7f7d7a199be6c6ec141c9024063205545aa09304349781b9a125e6", size = 2241488 }, - { url = "https://files.pythonhosted.org/packages/81/22/14738ad0a0bf484b928c9e52004f5e0b81dd8dabbdf23b843717b37a71d1/pydantic_core-2.33.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20d4275f3c4659d92048c70797e5fdc396c6e4446caf517ba5cad2db60cd39d3", size = 2248430 }, - { url = "https://files.pythonhosted.org/packages/e8/27/be7571e215ac8d321712f2433c445b03dbcd645366a18f67b334df8912bc/pydantic_core-2.33.0-cp312-cp312-win32.whl", hash = "sha256:918f2013d7eadea1d88d1a35fd4a1e16aaf90343eb446f91cb091ce7f9b431a2", size = 1908353 }, - { url = "https://files.pythonhosted.org/packages/be/3a/be78f28732f93128bd0e3944bdd4b3970b389a1fbd44907c97291c8dcdec/pydantic_core-2.33.0-cp312-cp312-win_amd64.whl", hash = "sha256:aec79acc183865bad120b0190afac467c20b15289050648b876b07777e67ea48", size = 1955956 }, - { url = "https://files.pythonhosted.org/packages/21/26/b8911ac74faa994694b76ee6a22875cc7a4abea3c381fdba4edc6c6bef84/pydantic_core-2.33.0-cp312-cp312-win_arm64.whl", hash = "sha256:5461934e895968655225dfa8b3be79e7e927e95d4bd6c2d40edd2fa7052e71b6", size = 1903259 }, - { url = "https://files.pythonhosted.org/packages/79/20/de2ad03ce8f5b3accf2196ea9b44f31b0cd16ac6e8cfc6b21976ed45ec35/pydantic_core-2.33.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f00e8b59e1fc8f09d05594aa7d2b726f1b277ca6155fc84c0396db1b373c4555", size = 2032214 }, - { url = "https://files.pythonhosted.org/packages/f9/af/6817dfda9aac4958d8b516cbb94af507eb171c997ea66453d4d162ae8948/pydantic_core-2.33.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a73be93ecef45786d7d95b0c5e9b294faf35629d03d5b145b09b81258c7cd6d", size = 1852338 }, - { url = "https://files.pythonhosted.org/packages/44/f3/49193a312d9c49314f2b953fb55740b7c530710977cabe7183b8ef111b7f/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff48a55be9da6930254565ff5238d71d5e9cd8c5487a191cb85df3bdb8c77365", size = 1896913 }, - { url = "https://files.pythonhosted.org/packages/06/e0/c746677825b2e29a2fa02122a8991c83cdd5b4c5f638f0664d4e35edd4b2/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26a4ea04195638dcd8c53dadb545d70badba51735b1594810e9768c2c0b4a5da", size = 1986046 }, - { url = "https://files.pythonhosted.org/packages/11/ec/44914e7ff78cef16afb5e5273d480c136725acd73d894affdbe2a1bbaad5/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41d698dcbe12b60661f0632b543dbb119e6ba088103b364ff65e951610cb7ce0", size = 2128097 }, - { url = "https://files.pythonhosted.org/packages/fe/f5/c6247d424d01f605ed2e3802f338691cae17137cee6484dce9f1ac0b872b/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae62032ef513fe6281ef0009e30838a01057b832dc265da32c10469622613885", size = 2681062 }, - { url = "https://files.pythonhosted.org/packages/f0/85/114a2113b126fdd7cf9a9443b1b1fe1b572e5bd259d50ba9d5d3e1927fa9/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f225f3a3995dbbc26affc191d0443c6c4aa71b83358fd4c2b7d63e2f6f0336f9", size = 2007487 }, - { url = "https://files.pythonhosted.org/packages/e6/40/3c05ed28d225c7a9acd2b34c5c8010c279683a870219b97e9f164a5a8af0/pydantic_core-2.33.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5bdd36b362f419c78d09630cbaebc64913f66f62bda6d42d5fbb08da8cc4f181", size = 2121382 }, - { url = "https://files.pythonhosted.org/packages/8a/22/e70c086f41eebd323e6baa92cc906c3f38ddce7486007eb2bdb3b11c8f64/pydantic_core-2.33.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2a0147c0bef783fd9abc9f016d66edb6cac466dc54a17ec5f5ada08ff65caf5d", size = 2072473 }, - { url = "https://files.pythonhosted.org/packages/3e/84/d1614dedd8fe5114f6a0e348bcd1535f97d76c038d6102f271433cd1361d/pydantic_core-2.33.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:c860773a0f205926172c6644c394e02c25421dc9a456deff16f64c0e299487d3", size = 2249468 }, - { url = "https://files.pythonhosted.org/packages/b0/c0/787061eef44135e00fddb4b56b387a06c303bfd3884a6df9bea5cb730230/pydantic_core-2.33.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:138d31e3f90087f42aa6286fb640f3c7a8eb7bdae829418265e7e7474bd2574b", size = 2254716 }, - { url = "https://files.pythonhosted.org/packages/ae/e2/27262eb04963201e89f9c280f1e10c493a7a37bc877e023f31aa72d2f911/pydantic_core-2.33.0-cp313-cp313-win32.whl", hash = "sha256:d20cbb9d3e95114325780f3cfe990f3ecae24de7a2d75f978783878cce2ad585", size = 1916450 }, - { url = "https://files.pythonhosted.org/packages/13/8d/25ff96f1e89b19e0b70b3cd607c9ea7ca27e1dcb810a9cd4255ed6abf869/pydantic_core-2.33.0-cp313-cp313-win_amd64.whl", hash = "sha256:ca1103d70306489e3d006b0f79db8ca5dd3c977f6f13b2c59ff745249431a606", size = 1956092 }, - { url = "https://files.pythonhosted.org/packages/1b/64/66a2efeff657b04323ffcd7b898cb0354d36dae3a561049e092134a83e9c/pydantic_core-2.33.0-cp313-cp313-win_arm64.whl", hash = "sha256:6291797cad239285275558e0a27872da735b05c75d5237bbade8736f80e4c225", size = 1908367 }, - { url = "https://files.pythonhosted.org/packages/52/54/295e38769133363d7ec4a5863a4d579f331728c71a6644ff1024ee529315/pydantic_core-2.33.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7b79af799630af263eca9ec87db519426d8c9b3be35016eddad1832bac812d87", size = 1813331 }, - { url = "https://files.pythonhosted.org/packages/4c/9c/0c8ea02db8d682aa1ef48938abae833c1d69bdfa6e5ec13b21734b01ae70/pydantic_core-2.33.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eabf946a4739b5237f4f56d77fa6668263bc466d06a8036c055587c130a46f7b", size = 1986653 }, - { url = "https://files.pythonhosted.org/packages/8e/4f/3fb47d6cbc08c7e00f92300e64ba655428c05c56b8ab6723bd290bae6458/pydantic_core-2.33.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8a1d581e8cdbb857b0e0e81df98603376c1a5c34dc5e54039dcc00f043df81e7", size = 1931234 }, +sdist = { url = "https://files.pythonhosted.org/packages/b9/05/91ce14dfd5a3a99555fce436318cc0fd1f08c4daa32b3248ad63669ea8b4/pydantic_core-2.33.0.tar.gz", hash = "sha256:40eb8af662ba409c3cbf4a8150ad32ae73514cd7cb1f1a2113af39763dd616b3", size = 434080, upload-time = "2025-03-26T20:30:05.906Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/c4/c9381323cbdc1bb26d352bc184422ce77c4bc2f2312b782761093a59fafc/pydantic_core-2.33.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6c32a40712e3662bebe524abe8abb757f2fa2000028d64cc5a1006016c06af43", size = 2025127, upload-time = "2025-03-26T20:27:27.704Z" }, + { url = "https://files.pythonhosted.org/packages/6f/bd/af35278080716ecab8f57e84515c7dc535ed95d1c7f52c1c6f7b313a9dab/pydantic_core-2.33.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ec86b5baa36f0a0bfb37db86c7d52652f8e8aa076ab745ef7725784183c3fdd", size = 1851687, upload-time = "2025-03-26T20:27:29.67Z" }, + { url = "https://files.pythonhosted.org/packages/12/e4/a01461225809c3533c23bd1916b1e8c2e21727f0fea60ab1acbffc4e2fca/pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4deac83a8cc1d09e40683be0bc6d1fa4cde8df0a9bf0cda5693f9b0569ac01b6", size = 1892232, upload-time = "2025-03-26T20:27:31.374Z" }, + { url = "https://files.pythonhosted.org/packages/51/17/3d53d62a328fb0a49911c2962036b9e7a4f781b7d15e9093c26299e5f76d/pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:175ab598fb457a9aee63206a1993874badf3ed9a456e0654273e56f00747bbd6", size = 1977896, upload-time = "2025-03-26T20:27:33.055Z" }, + { url = "https://files.pythonhosted.org/packages/30/98/01f9d86e02ec4a38f4b02086acf067f2c776b845d43f901bd1ee1c21bc4b/pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f36afd0d56a6c42cf4e8465b6441cf546ed69d3a4ec92724cc9c8c61bd6ecf4", size = 2127717, upload-time = "2025-03-26T20:27:34.768Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/6f381575c61b7c58b0fd0b92134c5a1897deea4cdfc3d47567b3ff460a4e/pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a98257451164666afafc7cbf5fb00d613e33f7e7ebb322fbcd99345695a9a61", size = 2680287, upload-time = "2025-03-26T20:27:36.826Z" }, + { url = "https://files.pythonhosted.org/packages/01/42/c0d10d1451d161a9a0da9bbef023b8005aa26e9993a8cc24dc9e3aa96c93/pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecc6d02d69b54a2eb83ebcc6f29df04957f734bcf309d346b4f83354d8376862", size = 2008276, upload-time = "2025-03-26T20:27:38.609Z" }, + { url = "https://files.pythonhosted.org/packages/20/ca/e08df9dba546905c70bae44ced9f3bea25432e34448d95618d41968f40b7/pydantic_core-2.33.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a69b7596c6603afd049ce7f3835bcf57dd3892fc7279f0ddf987bebed8caa5a", size = 2115305, upload-time = "2025-03-26T20:27:41.717Z" }, + { url = "https://files.pythonhosted.org/packages/03/1f/9b01d990730a98833113581a78e595fd40ed4c20f9693f5a658fb5f91eff/pydantic_core-2.33.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ea30239c148b6ef41364c6f51d103c2988965b643d62e10b233b5efdca8c0099", size = 2068999, upload-time = "2025-03-26T20:27:43.42Z" }, + { url = "https://files.pythonhosted.org/packages/20/18/fe752476a709191148e8b1e1139147841ea5d2b22adcde6ee6abb6c8e7cf/pydantic_core-2.33.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:abfa44cf2f7f7d7a199be6c6ec141c9024063205545aa09304349781b9a125e6", size = 2241488, upload-time = "2025-03-26T20:27:46.744Z" }, + { url = "https://files.pythonhosted.org/packages/81/22/14738ad0a0bf484b928c9e52004f5e0b81dd8dabbdf23b843717b37a71d1/pydantic_core-2.33.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20d4275f3c4659d92048c70797e5fdc396c6e4446caf517ba5cad2db60cd39d3", size = 2248430, upload-time = "2025-03-26T20:27:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/e8/27/be7571e215ac8d321712f2433c445b03dbcd645366a18f67b334df8912bc/pydantic_core-2.33.0-cp312-cp312-win32.whl", hash = "sha256:918f2013d7eadea1d88d1a35fd4a1e16aaf90343eb446f91cb091ce7f9b431a2", size = 1908353, upload-time = "2025-03-26T20:27:50.488Z" }, + { url = "https://files.pythonhosted.org/packages/be/3a/be78f28732f93128bd0e3944bdd4b3970b389a1fbd44907c97291c8dcdec/pydantic_core-2.33.0-cp312-cp312-win_amd64.whl", hash = "sha256:aec79acc183865bad120b0190afac467c20b15289050648b876b07777e67ea48", size = 1955956, upload-time = "2025-03-26T20:27:52.239Z" }, + { url = "https://files.pythonhosted.org/packages/21/26/b8911ac74faa994694b76ee6a22875cc7a4abea3c381fdba4edc6c6bef84/pydantic_core-2.33.0-cp312-cp312-win_arm64.whl", hash = "sha256:5461934e895968655225dfa8b3be79e7e927e95d4bd6c2d40edd2fa7052e71b6", size = 1903259, upload-time = "2025-03-26T20:27:54.06Z" }, + { url = "https://files.pythonhosted.org/packages/79/20/de2ad03ce8f5b3accf2196ea9b44f31b0cd16ac6e8cfc6b21976ed45ec35/pydantic_core-2.33.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f00e8b59e1fc8f09d05594aa7d2b726f1b277ca6155fc84c0396db1b373c4555", size = 2032214, upload-time = "2025-03-26T20:27:56.197Z" }, + { url = "https://files.pythonhosted.org/packages/f9/af/6817dfda9aac4958d8b516cbb94af507eb171c997ea66453d4d162ae8948/pydantic_core-2.33.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a73be93ecef45786d7d95b0c5e9b294faf35629d03d5b145b09b81258c7cd6d", size = 1852338, upload-time = "2025-03-26T20:27:57.876Z" }, + { url = "https://files.pythonhosted.org/packages/44/f3/49193a312d9c49314f2b953fb55740b7c530710977cabe7183b8ef111b7f/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff48a55be9da6930254565ff5238d71d5e9cd8c5487a191cb85df3bdb8c77365", size = 1896913, upload-time = "2025-03-26T20:27:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/06/e0/c746677825b2e29a2fa02122a8991c83cdd5b4c5f638f0664d4e35edd4b2/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26a4ea04195638dcd8c53dadb545d70badba51735b1594810e9768c2c0b4a5da", size = 1986046, upload-time = "2025-03-26T20:28:01.583Z" }, + { url = "https://files.pythonhosted.org/packages/11/ec/44914e7ff78cef16afb5e5273d480c136725acd73d894affdbe2a1bbaad5/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41d698dcbe12b60661f0632b543dbb119e6ba088103b364ff65e951610cb7ce0", size = 2128097, upload-time = "2025-03-26T20:28:03.437Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f5/c6247d424d01f605ed2e3802f338691cae17137cee6484dce9f1ac0b872b/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae62032ef513fe6281ef0009e30838a01057b832dc265da32c10469622613885", size = 2681062, upload-time = "2025-03-26T20:28:05.498Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/114a2113b126fdd7cf9a9443b1b1fe1b572e5bd259d50ba9d5d3e1927fa9/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f225f3a3995dbbc26affc191d0443c6c4aa71b83358fd4c2b7d63e2f6f0336f9", size = 2007487, upload-time = "2025-03-26T20:28:07.879Z" }, + { url = "https://files.pythonhosted.org/packages/e6/40/3c05ed28d225c7a9acd2b34c5c8010c279683a870219b97e9f164a5a8af0/pydantic_core-2.33.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5bdd36b362f419c78d09630cbaebc64913f66f62bda6d42d5fbb08da8cc4f181", size = 2121382, upload-time = "2025-03-26T20:28:09.651Z" }, + { url = "https://files.pythonhosted.org/packages/8a/22/e70c086f41eebd323e6baa92cc906c3f38ddce7486007eb2bdb3b11c8f64/pydantic_core-2.33.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2a0147c0bef783fd9abc9f016d66edb6cac466dc54a17ec5f5ada08ff65caf5d", size = 2072473, upload-time = "2025-03-26T20:28:11.69Z" }, + { url = "https://files.pythonhosted.org/packages/3e/84/d1614dedd8fe5114f6a0e348bcd1535f97d76c038d6102f271433cd1361d/pydantic_core-2.33.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:c860773a0f205926172c6644c394e02c25421dc9a456deff16f64c0e299487d3", size = 2249468, upload-time = "2025-03-26T20:28:13.651Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/787061eef44135e00fddb4b56b387a06c303bfd3884a6df9bea5cb730230/pydantic_core-2.33.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:138d31e3f90087f42aa6286fb640f3c7a8eb7bdae829418265e7e7474bd2574b", size = 2254716, upload-time = "2025-03-26T20:28:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e2/27262eb04963201e89f9c280f1e10c493a7a37bc877e023f31aa72d2f911/pydantic_core-2.33.0-cp313-cp313-win32.whl", hash = "sha256:d20cbb9d3e95114325780f3cfe990f3ecae24de7a2d75f978783878cce2ad585", size = 1916450, upload-time = "2025-03-26T20:28:18.252Z" }, + { url = "https://files.pythonhosted.org/packages/13/8d/25ff96f1e89b19e0b70b3cd607c9ea7ca27e1dcb810a9cd4255ed6abf869/pydantic_core-2.33.0-cp313-cp313-win_amd64.whl", hash = "sha256:ca1103d70306489e3d006b0f79db8ca5dd3c977f6f13b2c59ff745249431a606", size = 1956092, upload-time = "2025-03-26T20:28:20.129Z" }, + { url = "https://files.pythonhosted.org/packages/1b/64/66a2efeff657b04323ffcd7b898cb0354d36dae3a561049e092134a83e9c/pydantic_core-2.33.0-cp313-cp313-win_arm64.whl", hash = "sha256:6291797cad239285275558e0a27872da735b05c75d5237bbade8736f80e4c225", size = 1908367, upload-time = "2025-03-26T20:28:22.498Z" }, + { url = "https://files.pythonhosted.org/packages/52/54/295e38769133363d7ec4a5863a4d579f331728c71a6644ff1024ee529315/pydantic_core-2.33.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7b79af799630af263eca9ec87db519426d8c9b3be35016eddad1832bac812d87", size = 1813331, upload-time = "2025-03-26T20:28:25.004Z" }, + { url = "https://files.pythonhosted.org/packages/4c/9c/0c8ea02db8d682aa1ef48938abae833c1d69bdfa6e5ec13b21734b01ae70/pydantic_core-2.33.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eabf946a4739b5237f4f56d77fa6668263bc466d06a8036c055587c130a46f7b", size = 1986653, upload-time = "2025-03-26T20:28:27.02Z" }, + { url = "https://files.pythonhosted.org/packages/8e/4f/3fb47d6cbc08c7e00f92300e64ba655428c05c56b8ab6723bd290bae6458/pydantic_core-2.33.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8a1d581e8cdbb857b0e0e81df98603376c1a5c34dc5e54039dcc00f043df81e7", size = 1931234, upload-time = "2025-03-26T20:28:29.237Z" }, ] [[package]] @@ -848,18 +832,18 @@ dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550 } +sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550, upload-time = "2025-02-27T10:10:32.338Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839 }, + { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839, upload-time = "2025-02-27T10:10:30.711Z" }, ] [[package]] name = "pygments" version = "2.19.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, ] [[package]] @@ -870,9 +854,9 @@ dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/24/d6/48740f1d029e9fc4194880d1ad03dcf0ba3a8f802e0e166b8f63350b3584/pyright-1.1.398.tar.gz", hash = "sha256:357a13edd9be8082dc73be51190913e475fa41a6efb6ec0d4b7aab3bc11638d8", size = 3892675 } +sdist = { url = "https://files.pythonhosted.org/packages/24/d6/48740f1d029e9fc4194880d1ad03dcf0ba3a8f802e0e166b8f63350b3584/pyright-1.1.398.tar.gz", hash = "sha256:357a13edd9be8082dc73be51190913e475fa41a6efb6ec0d4b7aab3bc11638d8", size = 3892675, upload-time = "2025-03-26T10:06:06.063Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/e0/5283593f61b3c525d6d7e94cfb6b3ded20b3df66e953acaf7bb4f23b3f6e/pyright-1.1.398-py3-none-any.whl", hash = "sha256:0a70bfd007d9ea7de1cf9740e1ad1a40a122592cfe22a3f6791b06162ad08753", size = 5780235 }, + { url = "https://files.pythonhosted.org/packages/58/e0/5283593f61b3c525d6d7e94cfb6b3ded20b3df66e953acaf7bb4f23b3f6e/pyright-1.1.398-py3-none-any.whl", hash = "sha256:0a70bfd007d9ea7de1cf9740e1ad1a40a122592cfe22a3f6791b06162ad08753", size = 5780235, upload-time = "2025-03-26T10:06:03.994Z" }, ] [[package]] @@ -885,9 +869,9 @@ dependencies = [ { name = "packaging" }, { name = "pluggy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, ] [[package]] @@ -897,31 +881,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8e/c4/453c52c659521066969523e87d85d54139bbd17b78f09532fb8eb8cdb58e/pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f", size = 54156 } +sdist = { url = "https://files.pythonhosted.org/packages/8e/c4/453c52c659521066969523e87d85d54139bbd17b78f09532fb8eb8cdb58e/pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f", size = 54156, upload-time = "2025-03-25T06:22:28.883Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/7f/338843f449ace853647ace35870874f69a764d251872ed1b4de9f234822c/pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0", size = 19694 }, + { url = "https://files.pythonhosted.org/packages/20/7f/338843f449ace853647ace35870874f69a764d251872ed1b4de9f234822c/pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0", size = 19694, upload-time = "2025-03-25T06:22:27.807Z" }, ] [[package]] name = "python-dotenv" version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920 } +sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920, upload-time = "2025-03-25T10:14:56.835Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256 }, -] - -[[package]] -name = "pywin32" -version = "310" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/ec/4fdbe47932f671d6e348474ea35ed94227fb5df56a7c30cbbb42cd396ed0/pywin32-310-cp312-cp312-win32.whl", hash = "sha256:8a75a5cc3893e83a108c05d82198880704c44bbaee4d06e442e471d3c9ea4f3d", size = 8796239 }, - { url = "https://files.pythonhosted.org/packages/e3/e5/b0627f8bb84e06991bea89ad8153a9e50ace40b2e1195d68e9dff6b03d0f/pywin32-310-cp312-cp312-win_amd64.whl", hash = "sha256:bf5c397c9a9a19a6f62f3fb821fbf36cac08f03770056711f765ec1503972060", size = 9503839 }, - { url = "https://files.pythonhosted.org/packages/1f/32/9ccf53748df72301a89713936645a664ec001abd35ecc8578beda593d37d/pywin32-310-cp312-cp312-win_arm64.whl", hash = "sha256:2349cc906eae872d0663d4d6290d13b90621eaf78964bb1578632ff20e152966", size = 8459470 }, - { url = "https://files.pythonhosted.org/packages/1c/09/9c1b978ffc4ae53999e89c19c77ba882d9fce476729f23ef55211ea1c034/pywin32-310-cp313-cp313-win32.whl", hash = "sha256:5d241a659c496ada3253cd01cfaa779b048e90ce4b2b38cd44168ad555ce74ab", size = 8794384 }, - { url = "https://files.pythonhosted.org/packages/45/3c/b4640f740ffebadd5d34df35fecba0e1cfef8fde9f3e594df91c28ad9b50/pywin32-310-cp313-cp313-win_amd64.whl", hash = "sha256:667827eb3a90208ddbdcc9e860c81bde63a135710e21e4cb3348968e4bd5249e", size = 9503039 }, - { url = "https://files.pythonhosted.org/packages/b4/f4/f785020090fb050e7fb6d34b780f2231f302609dc964672f72bfaeb59a28/pywin32-310-cp313-cp313-win_arm64.whl", hash = "sha256:e308f831de771482b7cf692a1f308f8fca701b2d8f9dde6cc440c7da17e47b33", size = 8458152 }, + { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256, upload-time = "2025-03-25T10:14:55.034Z" }, ] [[package]] @@ -934,9 +905,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" }, ] [[package]] @@ -947,61 +918,61 @@ dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149, upload-time = "2024-11-01T16:43:57.873Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424, upload-time = "2024-11-01T16:43:55.817Z" }, ] [[package]] name = "ruff" version = "0.11.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/90/61/fb87430f040e4e577e784e325351186976516faef17d6fcd921fe28edfd7/ruff-0.11.2.tar.gz", hash = "sha256:ec47591497d5a1050175bdf4e1a4e6272cddff7da88a2ad595e1e326041d8d94", size = 3857511 } +sdist = { url = "https://files.pythonhosted.org/packages/90/61/fb87430f040e4e577e784e325351186976516faef17d6fcd921fe28edfd7/ruff-0.11.2.tar.gz", hash = "sha256:ec47591497d5a1050175bdf4e1a4e6272cddff7da88a2ad595e1e326041d8d94", size = 3857511, upload-time = "2025-03-21T13:31:17.419Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/99/102578506f0f5fa29fd7e0df0a273864f79af044757aef73d1cae0afe6ad/ruff-0.11.2-py3-none-linux_armv6l.whl", hash = "sha256:c69e20ea49e973f3afec2c06376eb56045709f0212615c1adb0eda35e8a4e477", size = 10113146 }, - { url = "https://files.pythonhosted.org/packages/74/ad/5cd4ba58ab602a579997a8494b96f10f316e874d7c435bcc1a92e6da1b12/ruff-0.11.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2c5424cc1c4eb1d8ecabe6d4f1b70470b4f24a0c0171356290b1953ad8f0e272", size = 10867092 }, - { url = "https://files.pythonhosted.org/packages/fc/3e/d3f13619e1d152c7b600a38c1a035e833e794c6625c9a6cea6f63dbf3af4/ruff-0.11.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ecf20854cc73f42171eedb66f006a43d0a21bfb98a2523a809931cda569552d9", size = 10224082 }, - { url = "https://files.pythonhosted.org/packages/90/06/f77b3d790d24a93f38e3806216f263974909888fd1e826717c3ec956bbcd/ruff-0.11.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c543bf65d5d27240321604cee0633a70c6c25c9a2f2492efa9f6d4b8e4199bb", size = 10394818 }, - { url = "https://files.pythonhosted.org/packages/99/7f/78aa431d3ddebfc2418cd95b786642557ba8b3cb578c075239da9ce97ff9/ruff-0.11.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20967168cc21195db5830b9224be0e964cc9c8ecf3b5a9e3ce19876e8d3a96e3", size = 9952251 }, - { url = "https://files.pythonhosted.org/packages/30/3e/f11186d1ddfaca438c3bbff73c6a2fdb5b60e6450cc466129c694b0ab7a2/ruff-0.11.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:955a9ce63483999d9f0b8f0b4a3ad669e53484232853054cc8b9d51ab4c5de74", size = 11563566 }, - { url = "https://files.pythonhosted.org/packages/22/6c/6ca91befbc0a6539ee133d9a9ce60b1a354db12c3c5d11cfdbf77140f851/ruff-0.11.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:86b3a27c38b8fce73bcd262b0de32e9a6801b76d52cdb3ae4c914515f0cef608", size = 12208721 }, - { url = "https://files.pythonhosted.org/packages/19/b0/24516a3b850d55b17c03fc399b681c6a549d06ce665915721dc5d6458a5c/ruff-0.11.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3b66a03b248c9fcd9d64d445bafdf1589326bee6fc5c8e92d7562e58883e30f", size = 11662274 }, - { url = "https://files.pythonhosted.org/packages/d7/65/76be06d28ecb7c6070280cef2bcb20c98fbf99ff60b1c57d2fb9b8771348/ruff-0.11.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0397c2672db015be5aa3d4dac54c69aa012429097ff219392c018e21f5085147", size = 13792284 }, - { url = "https://files.pythonhosted.org/packages/ce/d2/4ceed7147e05852876f3b5f3fdc23f878ce2b7e0b90dd6e698bda3d20787/ruff-0.11.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:869bcf3f9abf6457fbe39b5a37333aa4eecc52a3b99c98827ccc371a8e5b6f1b", size = 11327861 }, - { url = "https://files.pythonhosted.org/packages/c4/78/4935ecba13706fd60ebe0e3dc50371f2bdc3d9bc80e68adc32ff93914534/ruff-0.11.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2a2b50ca35457ba785cd8c93ebbe529467594087b527a08d487cf0ee7b3087e9", size = 10276560 }, - { url = "https://files.pythonhosted.org/packages/81/7f/1b2435c3f5245d410bb5dc80f13ec796454c21fbda12b77d7588d5cf4e29/ruff-0.11.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7c69c74bf53ddcfbc22e6eb2f31211df7f65054bfc1f72288fc71e5f82db3eab", size = 9945091 }, - { url = "https://files.pythonhosted.org/packages/39/c4/692284c07e6bf2b31d82bb8c32f8840f9d0627d92983edaac991a2b66c0a/ruff-0.11.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6e8fb75e14560f7cf53b15bbc55baf5ecbe373dd5f3aab96ff7aa7777edd7630", size = 10977133 }, - { url = "https://files.pythonhosted.org/packages/94/cf/8ab81cb7dd7a3b0a3960c2769825038f3adcd75faf46dd6376086df8b128/ruff-0.11.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:842a472d7b4d6f5924e9297aa38149e5dcb1e628773b70e6387ae2c97a63c58f", size = 11378514 }, - { url = "https://files.pythonhosted.org/packages/d9/3a/a647fa4f316482dacf2fd68e8a386327a33d6eabd8eb2f9a0c3d291ec549/ruff-0.11.2-py3-none-win32.whl", hash = "sha256:aca01ccd0eb5eb7156b324cfaa088586f06a86d9e5314b0eb330cb48415097cc", size = 10319835 }, - { url = "https://files.pythonhosted.org/packages/86/54/3c12d3af58012a5e2cd7ebdbe9983f4834af3f8cbea0e8a8c74fa1e23b2b/ruff-0.11.2-py3-none-win_amd64.whl", hash = "sha256:3170150172a8f994136c0c66f494edf199a0bbea7a409f649e4bc8f4d7084080", size = 11373713 }, - { url = "https://files.pythonhosted.org/packages/d6/d4/dd813703af8a1e2ac33bf3feb27e8a5ad514c9f219df80c64d69807e7f71/ruff-0.11.2-py3-none-win_arm64.whl", hash = "sha256:52933095158ff328f4c77af3d74f0379e34fd52f175144cefc1b192e7ccd32b4", size = 10441990 }, + { url = "https://files.pythonhosted.org/packages/62/99/102578506f0f5fa29fd7e0df0a273864f79af044757aef73d1cae0afe6ad/ruff-0.11.2-py3-none-linux_armv6l.whl", hash = "sha256:c69e20ea49e973f3afec2c06376eb56045709f0212615c1adb0eda35e8a4e477", size = 10113146, upload-time = "2025-03-21T13:30:26.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/ad/5cd4ba58ab602a579997a8494b96f10f316e874d7c435bcc1a92e6da1b12/ruff-0.11.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2c5424cc1c4eb1d8ecabe6d4f1b70470b4f24a0c0171356290b1953ad8f0e272", size = 10867092, upload-time = "2025-03-21T13:30:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3e/d3f13619e1d152c7b600a38c1a035e833e794c6625c9a6cea6f63dbf3af4/ruff-0.11.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ecf20854cc73f42171eedb66f006a43d0a21bfb98a2523a809931cda569552d9", size = 10224082, upload-time = "2025-03-21T13:30:39.962Z" }, + { url = "https://files.pythonhosted.org/packages/90/06/f77b3d790d24a93f38e3806216f263974909888fd1e826717c3ec956bbcd/ruff-0.11.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c543bf65d5d27240321604cee0633a70c6c25c9a2f2492efa9f6d4b8e4199bb", size = 10394818, upload-time = "2025-03-21T13:30:42.551Z" }, + { url = "https://files.pythonhosted.org/packages/99/7f/78aa431d3ddebfc2418cd95b786642557ba8b3cb578c075239da9ce97ff9/ruff-0.11.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20967168cc21195db5830b9224be0e964cc9c8ecf3b5a9e3ce19876e8d3a96e3", size = 9952251, upload-time = "2025-03-21T13:30:45.196Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/f11186d1ddfaca438c3bbff73c6a2fdb5b60e6450cc466129c694b0ab7a2/ruff-0.11.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:955a9ce63483999d9f0b8f0b4a3ad669e53484232853054cc8b9d51ab4c5de74", size = 11563566, upload-time = "2025-03-21T13:30:47.516Z" }, + { url = "https://files.pythonhosted.org/packages/22/6c/6ca91befbc0a6539ee133d9a9ce60b1a354db12c3c5d11cfdbf77140f851/ruff-0.11.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:86b3a27c38b8fce73bcd262b0de32e9a6801b76d52cdb3ae4c914515f0cef608", size = 12208721, upload-time = "2025-03-21T13:30:49.56Z" }, + { url = "https://files.pythonhosted.org/packages/19/b0/24516a3b850d55b17c03fc399b681c6a549d06ce665915721dc5d6458a5c/ruff-0.11.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3b66a03b248c9fcd9d64d445bafdf1589326bee6fc5c8e92d7562e58883e30f", size = 11662274, upload-time = "2025-03-21T13:30:52.055Z" }, + { url = "https://files.pythonhosted.org/packages/d7/65/76be06d28ecb7c6070280cef2bcb20c98fbf99ff60b1c57d2fb9b8771348/ruff-0.11.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0397c2672db015be5aa3d4dac54c69aa012429097ff219392c018e21f5085147", size = 13792284, upload-time = "2025-03-21T13:30:54.24Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/4ceed7147e05852876f3b5f3fdc23f878ce2b7e0b90dd6e698bda3d20787/ruff-0.11.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:869bcf3f9abf6457fbe39b5a37333aa4eecc52a3b99c98827ccc371a8e5b6f1b", size = 11327861, upload-time = "2025-03-21T13:30:56.757Z" }, + { url = "https://files.pythonhosted.org/packages/c4/78/4935ecba13706fd60ebe0e3dc50371f2bdc3d9bc80e68adc32ff93914534/ruff-0.11.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2a2b50ca35457ba785cd8c93ebbe529467594087b527a08d487cf0ee7b3087e9", size = 10276560, upload-time = "2025-03-21T13:30:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/81/7f/1b2435c3f5245d410bb5dc80f13ec796454c21fbda12b77d7588d5cf4e29/ruff-0.11.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7c69c74bf53ddcfbc22e6eb2f31211df7f65054bfc1f72288fc71e5f82db3eab", size = 9945091, upload-time = "2025-03-21T13:31:01.45Z" }, + { url = "https://files.pythonhosted.org/packages/39/c4/692284c07e6bf2b31d82bb8c32f8840f9d0627d92983edaac991a2b66c0a/ruff-0.11.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6e8fb75e14560f7cf53b15bbc55baf5ecbe373dd5f3aab96ff7aa7777edd7630", size = 10977133, upload-time = "2025-03-21T13:31:04.013Z" }, + { url = "https://files.pythonhosted.org/packages/94/cf/8ab81cb7dd7a3b0a3960c2769825038f3adcd75faf46dd6376086df8b128/ruff-0.11.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:842a472d7b4d6f5924e9297aa38149e5dcb1e628773b70e6387ae2c97a63c58f", size = 11378514, upload-time = "2025-03-21T13:31:06.166Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/a647fa4f316482dacf2fd68e8a386327a33d6eabd8eb2f9a0c3d291ec549/ruff-0.11.2-py3-none-win32.whl", hash = "sha256:aca01ccd0eb5eb7156b324cfaa088586f06a86d9e5314b0eb330cb48415097cc", size = 10319835, upload-time = "2025-03-21T13:31:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/86/54/3c12d3af58012a5e2cd7ebdbe9983f4834af3f8cbea0e8a8c74fa1e23b2b/ruff-0.11.2-py3-none-win_amd64.whl", hash = "sha256:3170150172a8f994136c0c66f494edf199a0bbea7a409f649e4bc8f4d7084080", size = 11373713, upload-time = "2025-03-21T13:31:13.148Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d4/dd813703af8a1e2ac33bf3feb27e8a5ad514c9f219df80c64d69807e7f71/ruff-0.11.2-py3-none-win_arm64.whl", hash = "sha256:52933095158ff328f4c77af3d74f0379e34fd52f175144cefc1b192e7ccd32b4", size = 10441990, upload-time = "2025-03-21T13:31:15.206Z" }, ] [[package]] name = "setuptools" version = "78.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a9/5a/0db4da3bc908df06e5efae42b44e75c81dd52716e10192ff36d0c1c8e379/setuptools-78.1.0.tar.gz", hash = "sha256:18fd474d4a82a5f83dac888df697af65afa82dec7323d09c3e37d1f14288da54", size = 1367827 } +sdist = { url = "https://files.pythonhosted.org/packages/a9/5a/0db4da3bc908df06e5efae42b44e75c81dd52716e10192ff36d0c1c8e379/setuptools-78.1.0.tar.gz", hash = "sha256:18fd474d4a82a5f83dac888df697af65afa82dec7323d09c3e37d1f14288da54", size = 1367827, upload-time = "2025-03-25T22:49:35.332Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/21/f43f0a1fa8b06b32812e0975981f4677d28e0f3271601dc88ac5a5b83220/setuptools-78.1.0-py3-none-any.whl", hash = "sha256:3e386e96793c8702ae83d17b853fb93d3e09ef82ec62722e61da5cd22376dcd8", size = 1256108 }, + { url = "https://files.pythonhosted.org/packages/54/21/f43f0a1fa8b06b32812e0975981f4677d28e0f3271601dc88ac5a5b83220/setuptools-78.1.0-py3-none-any.whl", hash = "sha256:3e386e96793c8702ae83d17b853fb93d3e09ef82ec62722e61da5cd22376dcd8", size = 1256108, upload-time = "2025-03-25T22:49:33.13Z" }, ] [[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 } +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 }, + { 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 = "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 } +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 }, + { 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]] @@ -1012,9 +983,9 @@ dependencies = [ { name = "anyio" }, { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376 } +sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376, upload-time = "2024-12-25T09:09:30.616Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120 }, + { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120, upload-time = "2024-12-25T09:09:26.761Z" }, ] [[package]] @@ -1024,18 +995,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/1b/52b27f2e13ceedc79a908e29eac426a63465a1a01248e5f24aa36a62aeb3/starlette-0.46.1.tar.gz", hash = "sha256:3c88d58ee4bd1bb807c0d1acb381838afc7752f9ddaec81bbe4383611d833230", size = 2580102 } +sdist = { url = "https://files.pythonhosted.org/packages/04/1b/52b27f2e13ceedc79a908e29eac426a63465a1a01248e5f24aa36a62aeb3/starlette-0.46.1.tar.gz", hash = "sha256:3c88d58ee4bd1bb807c0d1acb381838afc7752f9ddaec81bbe4383611d833230", size = 2580102, upload-time = "2025-03-08T10:55:34.504Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/4b/528ccf7a982216885a1ff4908e886b8fb5f19862d1962f56a3fce2435a70/starlette-0.46.1-py3-none-any.whl", hash = "sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227", size = 71995 }, + { url = "https://files.pythonhosted.org/packages/a0/4b/528ccf7a982216885a1ff4908e886b8fb5f19862d1962f56a3fce2435a70/starlette-0.46.1-py3-none-any.whl", hash = "sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227", size = 71995, upload-time = "2025-03-08T10:55:32.662Z" }, ] [[package]] name = "tenacity" version = "9.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036 } +sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248 }, + { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, ] [[package]] @@ -1045,9 +1016,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737 } +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540 }, + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, ] [[package]] @@ -1060,18 +1031,18 @@ dependencies = [ { name = "shellingham" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/6f/3991f0f1c7fcb2df31aef28e0594d8d54b05393a0e4e34c65e475c2a5d41/typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5", size = 100711 } +sdist = { url = "https://files.pythonhosted.org/packages/8b/6f/3991f0f1c7fcb2df31aef28e0594d8d54b05393a0e4e34c65e475c2a5d41/typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5", size = 100711, upload-time = "2025-02-27T19:17:34.807Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/fc/5b29fea8cee020515ca82cc68e3b8e1e34bb19a3535ad854cac9257b414c/typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc", size = 45061 }, + { url = "https://files.pythonhosted.org/packages/7f/fc/5b29fea8cee020515ca82cc68e3b8e1e34bb19a3535ad854cac9257b414c/typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc", size = 45061, upload-time = "2025-02-27T19:17:32.111Z" }, ] [[package]] name = "typing-extensions" version = "4.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0e/3e/b00a62db91a83fff600de219b6ea9908e6918664899a2d85db222f4fbf19/typing_extensions-4.13.0.tar.gz", hash = "sha256:0a4ac55a5820789d87e297727d229866c9650f6521b64206413c4fbada24d95b", size = 106520 } +sdist = { url = "https://files.pythonhosted.org/packages/0e/3e/b00a62db91a83fff600de219b6ea9908e6918664899a2d85db222f4fbf19/typing_extensions-4.13.0.tar.gz", hash = "sha256:0a4ac55a5820789d87e297727d229866c9650f6521b64206413c4fbada24d95b", size = 106520, upload-time = "2025-03-26T03:49:41.628Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/86/39b65d676ec5732de17b7e3c476e45bb80ec64eb50737a8dce1a4178aba1/typing_extensions-4.13.0-py3-none-any.whl", hash = "sha256:c8dd92cc0d6425a97c18fbb9d1954e5ff92c1ca881a309c45f06ebc0b79058e5", size = 45683 }, + { url = "https://files.pythonhosted.org/packages/e0/86/39b65d676ec5732de17b7e3c476e45bb80ec64eb50737a8dce1a4178aba1/typing_extensions-4.13.0-py3-none-any.whl", hash = "sha256:c8dd92cc0d6425a97c18fbb9d1954e5ff92c1ca881a309c45f06ebc0b79058e5", size = 45683, upload-time = "2025-03-26T03:49:40.35Z" }, ] [[package]] @@ -1081,27 +1052,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222 } +sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222, upload-time = "2025-02-25T17:27:59.638Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125 }, + { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125, upload-time = "2025-02-25T17:27:57.754Z" }, ] [[package]] name = "tzdata" version = "2025.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380 } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 }, + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, ] [[package]] name = "urllib3" version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } +sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268, upload-time = "2024-12-22T07:47:30.032Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, + { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369, upload-time = "2024-12-22T07:47:28.074Z" }, ] [[package]] @@ -1112,9 +1083,9 @@ dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 } +sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568, upload-time = "2024-12-15T13:33:30.42Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 }, + { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315, upload-time = "2024-12-15T13:33:27.467Z" }, ] [[package]] @@ -1126,58 +1097,58 @@ dependencies = [ { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/62/51/c0edba5219027f6eab262e139f73e2417b0f4efffa23bf562f6e18f76ca5/yarl-1.20.0.tar.gz", hash = "sha256:686d51e51ee5dfe62dec86e4866ee0e9ed66df700d55c828a615640adc885307", size = 185258 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/e8/3efdcb83073df978bb5b1a9cc0360ce596680e6c3fac01f2a994ccbb8939/yarl-1.20.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e06b9f6cdd772f9b665e5ba8161968e11e403774114420737f7884b5bd7bdf6f", size = 147089 }, - { url = "https://files.pythonhosted.org/packages/60/c3/9e776e98ea350f76f94dd80b408eaa54e5092643dbf65fd9babcffb60509/yarl-1.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b9ae2fbe54d859b3ade40290f60fe40e7f969d83d482e84d2c31b9bff03e359e", size = 97706 }, - { url = "https://files.pythonhosted.org/packages/0c/5b/45cdfb64a3b855ce074ae607b9fc40bc82e7613b94e7612b030255c93a09/yarl-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d12b8945250d80c67688602c891237994d203d42427cb14e36d1a732eda480e", size = 95719 }, - { url = "https://files.pythonhosted.org/packages/2d/4e/929633b249611eeed04e2f861a14ed001acca3ef9ec2a984a757b1515889/yarl-1.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:087e9731884621b162a3e06dc0d2d626e1542a617f65ba7cc7aeab279d55ad33", size = 343972 }, - { url = "https://files.pythonhosted.org/packages/49/fd/047535d326c913f1a90407a3baf7ff535b10098611eaef2c527e32e81ca1/yarl-1.20.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69df35468b66c1a6e6556248e6443ef0ec5f11a7a4428cf1f6281f1879220f58", size = 339639 }, - { url = "https://files.pythonhosted.org/packages/48/2f/11566f1176a78f4bafb0937c0072410b1b0d3640b297944a6a7a556e1d0b/yarl-1.20.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b2992fe29002fd0d4cbaea9428b09af9b8686a9024c840b8a2b8f4ea4abc16f", size = 353745 }, - { url = "https://files.pythonhosted.org/packages/26/17/07dfcf034d6ae8837b33988be66045dd52f878dfb1c4e8f80a7343f677be/yarl-1.20.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c903e0b42aab48abfbac668b5a9d7b6938e721a6341751331bcd7553de2dcae", size = 354178 }, - { url = "https://files.pythonhosted.org/packages/15/45/212604d3142d84b4065d5f8cab6582ed3d78e4cc250568ef2a36fe1cf0a5/yarl-1.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf099e2432131093cc611623e0b0bcc399b8cddd9a91eded8bfb50402ec35018", size = 349219 }, - { url = "https://files.pythonhosted.org/packages/e6/e0/a10b30f294111c5f1c682461e9459935c17d467a760c21e1f7db400ff499/yarl-1.20.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a7f62f5dc70a6c763bec9ebf922be52aa22863d9496a9a30124d65b489ea672", size = 337266 }, - { url = "https://files.pythonhosted.org/packages/33/a6/6efa1d85a675d25a46a167f9f3e80104cde317dfdf7f53f112ae6b16a60a/yarl-1.20.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:54ac15a8b60382b2bcefd9a289ee26dc0920cf59b05368c9b2b72450751c6eb8", size = 360873 }, - { url = "https://files.pythonhosted.org/packages/77/67/c8ab718cb98dfa2ae9ba0f97bf3cbb7d45d37f13fe1fbad25ac92940954e/yarl-1.20.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:25b3bc0763a7aca16a0f1b5e8ef0f23829df11fb539a1b70476dcab28bd83da7", size = 360524 }, - { url = "https://files.pythonhosted.org/packages/bd/e8/c3f18660cea1bc73d9f8a2b3ef423def8dadbbae6c4afabdb920b73e0ead/yarl-1.20.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b2586e36dc070fc8fad6270f93242124df68b379c3a251af534030a4a33ef594", size = 365370 }, - { url = "https://files.pythonhosted.org/packages/c9/99/33f3b97b065e62ff2d52817155a89cfa030a1a9b43fee7843ef560ad9603/yarl-1.20.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:866349da9d8c5290cfefb7fcc47721e94de3f315433613e01b435473be63daa6", size = 373297 }, - { url = "https://files.pythonhosted.org/packages/3d/89/7519e79e264a5f08653d2446b26d4724b01198a93a74d2e259291d538ab1/yarl-1.20.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:33bb660b390a0554d41f8ebec5cd4475502d84104b27e9b42f5321c5192bfcd1", size = 378771 }, - { url = "https://files.pythonhosted.org/packages/3a/58/6c460bbb884abd2917c3eef6f663a4a873f8dc6f498561fc0ad92231c113/yarl-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:737e9f171e5a07031cbee5e9180f6ce21a6c599b9d4b2c24d35df20a52fabf4b", size = 375000 }, - { url = "https://files.pythonhosted.org/packages/3b/2a/dd7ed1aa23fea996834278d7ff178f215b24324ee527df53d45e34d21d28/yarl-1.20.0-cp312-cp312-win32.whl", hash = "sha256:839de4c574169b6598d47ad61534e6981979ca2c820ccb77bf70f4311dd2cc64", size = 86355 }, - { url = "https://files.pythonhosted.org/packages/ca/c6/333fe0338305c0ac1c16d5aa7cc4841208d3252bbe62172e0051006b5445/yarl-1.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:3d7dbbe44b443b0c4aa0971cb07dcb2c2060e4a9bf8d1301140a33a93c98e18c", size = 92904 }, - { url = "https://files.pythonhosted.org/packages/0f/6f/514c9bff2900c22a4f10e06297714dbaf98707143b37ff0bcba65a956221/yarl-1.20.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2137810a20b933b1b1b7e5cf06a64c3ed3b4747b0e5d79c9447c00db0e2f752f", size = 145030 }, - { url = "https://files.pythonhosted.org/packages/4e/9d/f88da3fa319b8c9c813389bfb3463e8d777c62654c7168e580a13fadff05/yarl-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:447c5eadd750db8389804030d15f43d30435ed47af1313303ed82a62388176d3", size = 96894 }, - { url = "https://files.pythonhosted.org/packages/cd/57/92e83538580a6968b2451d6c89c5579938a7309d4785748e8ad42ddafdce/yarl-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42fbe577272c203528d402eec8bf4b2d14fd49ecfec92272334270b850e9cd7d", size = 94457 }, - { url = "https://files.pythonhosted.org/packages/e9/ee/7ee43bd4cf82dddd5da97fcaddb6fa541ab81f3ed564c42f146c83ae17ce/yarl-1.20.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18e321617de4ab170226cd15006a565d0fa0d908f11f724a2c9142d6b2812ab0", size = 343070 }, - { url = "https://files.pythonhosted.org/packages/4a/12/b5eccd1109e2097bcc494ba7dc5de156e41cf8309fab437ebb7c2b296ce3/yarl-1.20.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4345f58719825bba29895011e8e3b545e6e00257abb984f9f27fe923afca2501", size = 337739 }, - { url = "https://files.pythonhosted.org/packages/7d/6b/0eade8e49af9fc2585552f63c76fa59ef469c724cc05b29519b19aa3a6d5/yarl-1.20.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5d9b980d7234614bc4674468ab173ed77d678349c860c3af83b1fffb6a837ddc", size = 351338 }, - { url = "https://files.pythonhosted.org/packages/45/cb/aaaa75d30087b5183c7b8a07b4fb16ae0682dd149a1719b3a28f54061754/yarl-1.20.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af4baa8a445977831cbaa91a9a84cc09debb10bc8391f128da2f7bd070fc351d", size = 353636 }, - { url = "https://files.pythonhosted.org/packages/98/9d/d9cb39ec68a91ba6e66fa86d97003f58570327d6713833edf7ad6ce9dde5/yarl-1.20.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:123393db7420e71d6ce40d24885a9e65eb1edefc7a5228db2d62bcab3386a5c0", size = 348061 }, - { url = "https://files.pythonhosted.org/packages/72/6b/103940aae893d0cc770b4c36ce80e2ed86fcb863d48ea80a752b8bda9303/yarl-1.20.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab47acc9332f3de1b39e9b702d9c916af7f02656b2a86a474d9db4e53ef8fd7a", size = 334150 }, - { url = "https://files.pythonhosted.org/packages/ef/b2/986bd82aa222c3e6b211a69c9081ba46484cffa9fab2a5235e8d18ca7a27/yarl-1.20.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4a34c52ed158f89876cba9c600b2c964dfc1ca52ba7b3ab6deb722d1d8be6df2", size = 362207 }, - { url = "https://files.pythonhosted.org/packages/14/7c/63f5922437b873795d9422cbe7eb2509d4b540c37ae5548a4bb68fd2c546/yarl-1.20.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:04d8cfb12714158abf2618f792c77bc5c3d8c5f37353e79509608be4f18705c9", size = 361277 }, - { url = "https://files.pythonhosted.org/packages/81/83/450938cccf732466953406570bdb42c62b5ffb0ac7ac75a1f267773ab5c8/yarl-1.20.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7dc63ad0d541c38b6ae2255aaa794434293964677d5c1ec5d0116b0e308031f5", size = 364990 }, - { url = "https://files.pythonhosted.org/packages/b4/de/af47d3a47e4a833693b9ec8e87debb20f09d9fdc9139b207b09a3e6cbd5a/yarl-1.20.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f9d02b591a64e4e6ca18c5e3d925f11b559c763b950184a64cf47d74d7e41877", size = 374684 }, - { url = "https://files.pythonhosted.org/packages/62/0b/078bcc2d539f1faffdc7d32cb29a2d7caa65f1a6f7e40795d8485db21851/yarl-1.20.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:95fc9876f917cac7f757df80a5dda9de59d423568460fe75d128c813b9af558e", size = 382599 }, - { url = "https://files.pythonhosted.org/packages/74/a9/4fdb1a7899f1fb47fd1371e7ba9e94bff73439ce87099d5dd26d285fffe0/yarl-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bb769ae5760cd1c6a712135ee7915f9d43f11d9ef769cb3f75a23e398a92d384", size = 378573 }, - { url = "https://files.pythonhosted.org/packages/fd/be/29f5156b7a319e4d2e5b51ce622b4dfb3aa8d8204cd2a8a339340fbfad40/yarl-1.20.0-cp313-cp313-win32.whl", hash = "sha256:70e0c580a0292c7414a1cead1e076c9786f685c1fc4757573d2967689b370e62", size = 86051 }, - { url = "https://files.pythonhosted.org/packages/52/56/05fa52c32c301da77ec0b5f63d2d9605946fe29defacb2a7ebd473c23b81/yarl-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:4c43030e4b0af775a85be1fa0433119b1565673266a70bf87ef68a9d5ba3174c", size = 92742 }, - { url = "https://files.pythonhosted.org/packages/d4/2f/422546794196519152fc2e2f475f0e1d4d094a11995c81a465faf5673ffd/yarl-1.20.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b6c4c3d0d6a0ae9b281e492b1465c72de433b782e6b5001c8e7249e085b69051", size = 163575 }, - { url = "https://files.pythonhosted.org/packages/90/fc/67c64ddab6c0b4a169d03c637fb2d2a212b536e1989dec8e7e2c92211b7f/yarl-1.20.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8681700f4e4df891eafa4f69a439a6e7d480d64e52bf460918f58e443bd3da7d", size = 106121 }, - { url = "https://files.pythonhosted.org/packages/6d/00/29366b9eba7b6f6baed7d749f12add209b987c4cfbfa418404dbadc0f97c/yarl-1.20.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:84aeb556cb06c00652dbf87c17838eb6d92cfd317799a8092cee0e570ee11229", size = 103815 }, - { url = "https://files.pythonhosted.org/packages/28/f4/a2a4c967c8323c03689383dff73396281ced3b35d0ed140580825c826af7/yarl-1.20.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f166eafa78810ddb383e930d62e623d288fb04ec566d1b4790099ae0f31485f1", size = 408231 }, - { url = "https://files.pythonhosted.org/packages/0f/a1/66f7ffc0915877d726b70cc7a896ac30b6ac5d1d2760613603b022173635/yarl-1.20.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5d3d6d14754aefc7a458261027a562f024d4f6b8a798adb472277f675857b1eb", size = 390221 }, - { url = "https://files.pythonhosted.org/packages/41/15/cc248f0504610283271615e85bf38bc014224122498c2016d13a3a1b8426/yarl-1.20.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2a8f64df8ed5d04c51260dbae3cc82e5649834eebea9eadfd829837b8093eb00", size = 411400 }, - { url = "https://files.pythonhosted.org/packages/5c/af/f0823d7e092bfb97d24fce6c7269d67fcd1aefade97d0a8189c4452e4d5e/yarl-1.20.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d9949eaf05b4d30e93e4034a7790634bbb41b8be2d07edd26754f2e38e491de", size = 411714 }, - { url = "https://files.pythonhosted.org/packages/83/70/be418329eae64b9f1b20ecdaac75d53aef098797d4c2299d82ae6f8e4663/yarl-1.20.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c366b254082d21cc4f08f522ac201d0d83a8b8447ab562732931d31d80eb2a5", size = 404279 }, - { url = "https://files.pythonhosted.org/packages/19/f5/52e02f0075f65b4914eb890eea1ba97e6fd91dd821cc33a623aa707b2f67/yarl-1.20.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91bc450c80a2e9685b10e34e41aef3d44ddf99b3a498717938926d05ca493f6a", size = 384044 }, - { url = "https://files.pythonhosted.org/packages/6a/36/b0fa25226b03d3f769c68d46170b3e92b00ab3853d73127273ba22474697/yarl-1.20.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9c2aa4387de4bc3a5fe158080757748d16567119bef215bec643716b4fbf53f9", size = 416236 }, - { url = "https://files.pythonhosted.org/packages/cb/3a/54c828dd35f6831dfdd5a79e6c6b4302ae2c5feca24232a83cb75132b205/yarl-1.20.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:d2cbca6760a541189cf87ee54ff891e1d9ea6406079c66341008f7ef6ab61145", size = 402034 }, - { url = "https://files.pythonhosted.org/packages/10/97/c7bf5fba488f7e049f9ad69c1b8fdfe3daa2e8916b3d321aa049e361a55a/yarl-1.20.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:798a5074e656f06b9fad1a162be5a32da45237ce19d07884d0b67a0aa9d5fdda", size = 407943 }, - { url = "https://files.pythonhosted.org/packages/fd/a4/022d2555c1e8fcff08ad7f0f43e4df3aba34f135bff04dd35d5526ce54ab/yarl-1.20.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f106e75c454288472dbe615accef8248c686958c2e7dd3b8d8ee2669770d020f", size = 423058 }, - { url = "https://files.pythonhosted.org/packages/4c/f6/0873a05563e5df29ccf35345a6ae0ac9e66588b41fdb7043a65848f03139/yarl-1.20.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3b60a86551669c23dc5445010534d2c5d8a4e012163218fc9114e857c0586fdd", size = 423792 }, - { url = "https://files.pythonhosted.org/packages/9e/35/43fbbd082708fa42e923f314c24f8277a28483d219e049552e5007a9aaca/yarl-1.20.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e429857e341d5e8e15806118e0294f8073ba9c4580637e59ab7b238afca836f", size = 422242 }, - { url = "https://files.pythonhosted.org/packages/ed/f7/f0f2500cf0c469beb2050b522c7815c575811627e6d3eb9ec7550ddd0bfe/yarl-1.20.0-cp313-cp313t-win32.whl", hash = "sha256:65a4053580fe88a63e8e4056b427224cd01edfb5f951498bfefca4052f0ce0ac", size = 93816 }, - { url = "https://files.pythonhosted.org/packages/3f/93/f73b61353b2a699d489e782c3f5998b59f974ec3156a2050a52dfd7e8946/yarl-1.20.0-cp313-cp313t-win_amd64.whl", hash = "sha256:53b2da3a6ca0a541c1ae799c349788d480e5144cac47dba0266c7cb6c76151fe", size = 101093 }, - { url = "https://files.pythonhosted.org/packages/ea/1f/70c57b3d7278e94ed22d85e09685d3f0a38ebdd8c5c73b65ba4c0d0fe002/yarl-1.20.0-py3-none-any.whl", hash = "sha256:5d0fe6af927a47a230f31e6004621fd0959eaa915fc62acfafa67ff7229a3124", size = 46124 }, +sdist = { url = "https://files.pythonhosted.org/packages/62/51/c0edba5219027f6eab262e139f73e2417b0f4efffa23bf562f6e18f76ca5/yarl-1.20.0.tar.gz", hash = "sha256:686d51e51ee5dfe62dec86e4866ee0e9ed66df700d55c828a615640adc885307", size = 185258, upload-time = "2025-04-17T00:45:14.661Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/e8/3efdcb83073df978bb5b1a9cc0360ce596680e6c3fac01f2a994ccbb8939/yarl-1.20.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e06b9f6cdd772f9b665e5ba8161968e11e403774114420737f7884b5bd7bdf6f", size = 147089, upload-time = "2025-04-17T00:42:39.602Z" }, + { url = "https://files.pythonhosted.org/packages/60/c3/9e776e98ea350f76f94dd80b408eaa54e5092643dbf65fd9babcffb60509/yarl-1.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b9ae2fbe54d859b3ade40290f60fe40e7f969d83d482e84d2c31b9bff03e359e", size = 97706, upload-time = "2025-04-17T00:42:41.469Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/45cdfb64a3b855ce074ae607b9fc40bc82e7613b94e7612b030255c93a09/yarl-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d12b8945250d80c67688602c891237994d203d42427cb14e36d1a732eda480e", size = 95719, upload-time = "2025-04-17T00:42:43.666Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4e/929633b249611eeed04e2f861a14ed001acca3ef9ec2a984a757b1515889/yarl-1.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:087e9731884621b162a3e06dc0d2d626e1542a617f65ba7cc7aeab279d55ad33", size = 343972, upload-time = "2025-04-17T00:42:45.391Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/047535d326c913f1a90407a3baf7ff535b10098611eaef2c527e32e81ca1/yarl-1.20.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69df35468b66c1a6e6556248e6443ef0ec5f11a7a4428cf1f6281f1879220f58", size = 339639, upload-time = "2025-04-17T00:42:47.552Z" }, + { url = "https://files.pythonhosted.org/packages/48/2f/11566f1176a78f4bafb0937c0072410b1b0d3640b297944a6a7a556e1d0b/yarl-1.20.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b2992fe29002fd0d4cbaea9428b09af9b8686a9024c840b8a2b8f4ea4abc16f", size = 353745, upload-time = "2025-04-17T00:42:49.406Z" }, + { url = "https://files.pythonhosted.org/packages/26/17/07dfcf034d6ae8837b33988be66045dd52f878dfb1c4e8f80a7343f677be/yarl-1.20.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c903e0b42aab48abfbac668b5a9d7b6938e721a6341751331bcd7553de2dcae", size = 354178, upload-time = "2025-04-17T00:42:51.588Z" }, + { url = "https://files.pythonhosted.org/packages/15/45/212604d3142d84b4065d5f8cab6582ed3d78e4cc250568ef2a36fe1cf0a5/yarl-1.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf099e2432131093cc611623e0b0bcc399b8cddd9a91eded8bfb50402ec35018", size = 349219, upload-time = "2025-04-17T00:42:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e0/a10b30f294111c5f1c682461e9459935c17d467a760c21e1f7db400ff499/yarl-1.20.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a7f62f5dc70a6c763bec9ebf922be52aa22863d9496a9a30124d65b489ea672", size = 337266, upload-time = "2025-04-17T00:42:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/33/a6/6efa1d85a675d25a46a167f9f3e80104cde317dfdf7f53f112ae6b16a60a/yarl-1.20.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:54ac15a8b60382b2bcefd9a289ee26dc0920cf59b05368c9b2b72450751c6eb8", size = 360873, upload-time = "2025-04-17T00:42:57.895Z" }, + { url = "https://files.pythonhosted.org/packages/77/67/c8ab718cb98dfa2ae9ba0f97bf3cbb7d45d37f13fe1fbad25ac92940954e/yarl-1.20.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:25b3bc0763a7aca16a0f1b5e8ef0f23829df11fb539a1b70476dcab28bd83da7", size = 360524, upload-time = "2025-04-17T00:43:00.094Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e8/c3f18660cea1bc73d9f8a2b3ef423def8dadbbae6c4afabdb920b73e0ead/yarl-1.20.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b2586e36dc070fc8fad6270f93242124df68b379c3a251af534030a4a33ef594", size = 365370, upload-time = "2025-04-17T00:43:02.242Z" }, + { url = "https://files.pythonhosted.org/packages/c9/99/33f3b97b065e62ff2d52817155a89cfa030a1a9b43fee7843ef560ad9603/yarl-1.20.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:866349da9d8c5290cfefb7fcc47721e94de3f315433613e01b435473be63daa6", size = 373297, upload-time = "2025-04-17T00:43:04.189Z" }, + { url = "https://files.pythonhosted.org/packages/3d/89/7519e79e264a5f08653d2446b26d4724b01198a93a74d2e259291d538ab1/yarl-1.20.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:33bb660b390a0554d41f8ebec5cd4475502d84104b27e9b42f5321c5192bfcd1", size = 378771, upload-time = "2025-04-17T00:43:06.609Z" }, + { url = "https://files.pythonhosted.org/packages/3a/58/6c460bbb884abd2917c3eef6f663a4a873f8dc6f498561fc0ad92231c113/yarl-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:737e9f171e5a07031cbee5e9180f6ce21a6c599b9d4b2c24d35df20a52fabf4b", size = 375000, upload-time = "2025-04-17T00:43:09.01Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/dd7ed1aa23fea996834278d7ff178f215b24324ee527df53d45e34d21d28/yarl-1.20.0-cp312-cp312-win32.whl", hash = "sha256:839de4c574169b6598d47ad61534e6981979ca2c820ccb77bf70f4311dd2cc64", size = 86355, upload-time = "2025-04-17T00:43:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/ca/c6/333fe0338305c0ac1c16d5aa7cc4841208d3252bbe62172e0051006b5445/yarl-1.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:3d7dbbe44b443b0c4aa0971cb07dcb2c2060e4a9bf8d1301140a33a93c98e18c", size = 92904, upload-time = "2025-04-17T00:43:13.087Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/514c9bff2900c22a4f10e06297714dbaf98707143b37ff0bcba65a956221/yarl-1.20.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2137810a20b933b1b1b7e5cf06a64c3ed3b4747b0e5d79c9447c00db0e2f752f", size = 145030, upload-time = "2025-04-17T00:43:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9d/f88da3fa319b8c9c813389bfb3463e8d777c62654c7168e580a13fadff05/yarl-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:447c5eadd750db8389804030d15f43d30435ed47af1313303ed82a62388176d3", size = 96894, upload-time = "2025-04-17T00:43:17.372Z" }, + { url = "https://files.pythonhosted.org/packages/cd/57/92e83538580a6968b2451d6c89c5579938a7309d4785748e8ad42ddafdce/yarl-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42fbe577272c203528d402eec8bf4b2d14fd49ecfec92272334270b850e9cd7d", size = 94457, upload-time = "2025-04-17T00:43:19.431Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ee/7ee43bd4cf82dddd5da97fcaddb6fa541ab81f3ed564c42f146c83ae17ce/yarl-1.20.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18e321617de4ab170226cd15006a565d0fa0d908f11f724a2c9142d6b2812ab0", size = 343070, upload-time = "2025-04-17T00:43:21.426Z" }, + { url = "https://files.pythonhosted.org/packages/4a/12/b5eccd1109e2097bcc494ba7dc5de156e41cf8309fab437ebb7c2b296ce3/yarl-1.20.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4345f58719825bba29895011e8e3b545e6e00257abb984f9f27fe923afca2501", size = 337739, upload-time = "2025-04-17T00:43:23.634Z" }, + { url = "https://files.pythonhosted.org/packages/7d/6b/0eade8e49af9fc2585552f63c76fa59ef469c724cc05b29519b19aa3a6d5/yarl-1.20.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5d9b980d7234614bc4674468ab173ed77d678349c860c3af83b1fffb6a837ddc", size = 351338, upload-time = "2025-04-17T00:43:25.695Z" }, + { url = "https://files.pythonhosted.org/packages/45/cb/aaaa75d30087b5183c7b8a07b4fb16ae0682dd149a1719b3a28f54061754/yarl-1.20.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af4baa8a445977831cbaa91a9a84cc09debb10bc8391f128da2f7bd070fc351d", size = 353636, upload-time = "2025-04-17T00:43:27.876Z" }, + { url = "https://files.pythonhosted.org/packages/98/9d/d9cb39ec68a91ba6e66fa86d97003f58570327d6713833edf7ad6ce9dde5/yarl-1.20.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:123393db7420e71d6ce40d24885a9e65eb1edefc7a5228db2d62bcab3386a5c0", size = 348061, upload-time = "2025-04-17T00:43:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/72/6b/103940aae893d0cc770b4c36ce80e2ed86fcb863d48ea80a752b8bda9303/yarl-1.20.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab47acc9332f3de1b39e9b702d9c916af7f02656b2a86a474d9db4e53ef8fd7a", size = 334150, upload-time = "2025-04-17T00:43:31.742Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b2/986bd82aa222c3e6b211a69c9081ba46484cffa9fab2a5235e8d18ca7a27/yarl-1.20.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4a34c52ed158f89876cba9c600b2c964dfc1ca52ba7b3ab6deb722d1d8be6df2", size = 362207, upload-time = "2025-04-17T00:43:34.099Z" }, + { url = "https://files.pythonhosted.org/packages/14/7c/63f5922437b873795d9422cbe7eb2509d4b540c37ae5548a4bb68fd2c546/yarl-1.20.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:04d8cfb12714158abf2618f792c77bc5c3d8c5f37353e79509608be4f18705c9", size = 361277, upload-time = "2025-04-17T00:43:36.202Z" }, + { url = "https://files.pythonhosted.org/packages/81/83/450938cccf732466953406570bdb42c62b5ffb0ac7ac75a1f267773ab5c8/yarl-1.20.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7dc63ad0d541c38b6ae2255aaa794434293964677d5c1ec5d0116b0e308031f5", size = 364990, upload-time = "2025-04-17T00:43:38.551Z" }, + { url = "https://files.pythonhosted.org/packages/b4/de/af47d3a47e4a833693b9ec8e87debb20f09d9fdc9139b207b09a3e6cbd5a/yarl-1.20.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f9d02b591a64e4e6ca18c5e3d925f11b559c763b950184a64cf47d74d7e41877", size = 374684, upload-time = "2025-04-17T00:43:40.481Z" }, + { url = "https://files.pythonhosted.org/packages/62/0b/078bcc2d539f1faffdc7d32cb29a2d7caa65f1a6f7e40795d8485db21851/yarl-1.20.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:95fc9876f917cac7f757df80a5dda9de59d423568460fe75d128c813b9af558e", size = 382599, upload-time = "2025-04-17T00:43:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/74/a9/4fdb1a7899f1fb47fd1371e7ba9e94bff73439ce87099d5dd26d285fffe0/yarl-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bb769ae5760cd1c6a712135ee7915f9d43f11d9ef769cb3f75a23e398a92d384", size = 378573, upload-time = "2025-04-17T00:43:44.797Z" }, + { url = "https://files.pythonhosted.org/packages/fd/be/29f5156b7a319e4d2e5b51ce622b4dfb3aa8d8204cd2a8a339340fbfad40/yarl-1.20.0-cp313-cp313-win32.whl", hash = "sha256:70e0c580a0292c7414a1cead1e076c9786f685c1fc4757573d2967689b370e62", size = 86051, upload-time = "2025-04-17T00:43:47.076Z" }, + { url = "https://files.pythonhosted.org/packages/52/56/05fa52c32c301da77ec0b5f63d2d9605946fe29defacb2a7ebd473c23b81/yarl-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:4c43030e4b0af775a85be1fa0433119b1565673266a70bf87ef68a9d5ba3174c", size = 92742, upload-time = "2025-04-17T00:43:49.193Z" }, + { url = "https://files.pythonhosted.org/packages/d4/2f/422546794196519152fc2e2f475f0e1d4d094a11995c81a465faf5673ffd/yarl-1.20.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b6c4c3d0d6a0ae9b281e492b1465c72de433b782e6b5001c8e7249e085b69051", size = 163575, upload-time = "2025-04-17T00:43:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/90/fc/67c64ddab6c0b4a169d03c637fb2d2a212b536e1989dec8e7e2c92211b7f/yarl-1.20.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8681700f4e4df891eafa4f69a439a6e7d480d64e52bf460918f58e443bd3da7d", size = 106121, upload-time = "2025-04-17T00:43:53.506Z" }, + { url = "https://files.pythonhosted.org/packages/6d/00/29366b9eba7b6f6baed7d749f12add209b987c4cfbfa418404dbadc0f97c/yarl-1.20.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:84aeb556cb06c00652dbf87c17838eb6d92cfd317799a8092cee0e570ee11229", size = 103815, upload-time = "2025-04-17T00:43:55.41Z" }, + { url = "https://files.pythonhosted.org/packages/28/f4/a2a4c967c8323c03689383dff73396281ced3b35d0ed140580825c826af7/yarl-1.20.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f166eafa78810ddb383e930d62e623d288fb04ec566d1b4790099ae0f31485f1", size = 408231, upload-time = "2025-04-17T00:43:57.825Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a1/66f7ffc0915877d726b70cc7a896ac30b6ac5d1d2760613603b022173635/yarl-1.20.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5d3d6d14754aefc7a458261027a562f024d4f6b8a798adb472277f675857b1eb", size = 390221, upload-time = "2025-04-17T00:44:00.526Z" }, + { url = "https://files.pythonhosted.org/packages/41/15/cc248f0504610283271615e85bf38bc014224122498c2016d13a3a1b8426/yarl-1.20.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2a8f64df8ed5d04c51260dbae3cc82e5649834eebea9eadfd829837b8093eb00", size = 411400, upload-time = "2025-04-17T00:44:02.853Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/f0823d7e092bfb97d24fce6c7269d67fcd1aefade97d0a8189c4452e4d5e/yarl-1.20.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d9949eaf05b4d30e93e4034a7790634bbb41b8be2d07edd26754f2e38e491de", size = 411714, upload-time = "2025-04-17T00:44:04.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/70/be418329eae64b9f1b20ecdaac75d53aef098797d4c2299d82ae6f8e4663/yarl-1.20.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c366b254082d21cc4f08f522ac201d0d83a8b8447ab562732931d31d80eb2a5", size = 404279, upload-time = "2025-04-17T00:44:07.721Z" }, + { url = "https://files.pythonhosted.org/packages/19/f5/52e02f0075f65b4914eb890eea1ba97e6fd91dd821cc33a623aa707b2f67/yarl-1.20.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91bc450c80a2e9685b10e34e41aef3d44ddf99b3a498717938926d05ca493f6a", size = 384044, upload-time = "2025-04-17T00:44:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/6a/36/b0fa25226b03d3f769c68d46170b3e92b00ab3853d73127273ba22474697/yarl-1.20.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9c2aa4387de4bc3a5fe158080757748d16567119bef215bec643716b4fbf53f9", size = 416236, upload-time = "2025-04-17T00:44:11.734Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3a/54c828dd35f6831dfdd5a79e6c6b4302ae2c5feca24232a83cb75132b205/yarl-1.20.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:d2cbca6760a541189cf87ee54ff891e1d9ea6406079c66341008f7ef6ab61145", size = 402034, upload-time = "2025-04-17T00:44:13.975Z" }, + { url = "https://files.pythonhosted.org/packages/10/97/c7bf5fba488f7e049f9ad69c1b8fdfe3daa2e8916b3d321aa049e361a55a/yarl-1.20.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:798a5074e656f06b9fad1a162be5a32da45237ce19d07884d0b67a0aa9d5fdda", size = 407943, upload-time = "2025-04-17T00:44:16.052Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a4/022d2555c1e8fcff08ad7f0f43e4df3aba34f135bff04dd35d5526ce54ab/yarl-1.20.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f106e75c454288472dbe615accef8248c686958c2e7dd3b8d8ee2669770d020f", size = 423058, upload-time = "2025-04-17T00:44:18.547Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f6/0873a05563e5df29ccf35345a6ae0ac9e66588b41fdb7043a65848f03139/yarl-1.20.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3b60a86551669c23dc5445010534d2c5d8a4e012163218fc9114e857c0586fdd", size = 423792, upload-time = "2025-04-17T00:44:20.639Z" }, + { url = "https://files.pythonhosted.org/packages/9e/35/43fbbd082708fa42e923f314c24f8277a28483d219e049552e5007a9aaca/yarl-1.20.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e429857e341d5e8e15806118e0294f8073ba9c4580637e59ab7b238afca836f", size = 422242, upload-time = "2025-04-17T00:44:22.851Z" }, + { url = "https://files.pythonhosted.org/packages/ed/f7/f0f2500cf0c469beb2050b522c7815c575811627e6d3eb9ec7550ddd0bfe/yarl-1.20.0-cp313-cp313t-win32.whl", hash = "sha256:65a4053580fe88a63e8e4056b427224cd01edfb5f951498bfefca4052f0ce0ac", size = 93816, upload-time = "2025-04-17T00:44:25.491Z" }, + { url = "https://files.pythonhosted.org/packages/3f/93/f73b61353b2a699d489e782c3f5998b59f974ec3156a2050a52dfd7e8946/yarl-1.20.0-cp313-cp313t-win_amd64.whl", hash = "sha256:53b2da3a6ca0a541c1ae799c349788d480e5144cac47dba0266c7cb6c76151fe", size = 101093, upload-time = "2025-04-17T00:44:27.418Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1f/70c57b3d7278e94ed22d85e09685d3f0a38ebdd8c5c73b65ba4c0d0fe002/yarl-1.20.0-py3-none-any.whl", hash = "sha256:5d0fe6af927a47a230f31e6004621fd0959eaa915fc62acfafa67ff7229a3124", size = 46124, upload-time = "2025-04-17T00:45:12.199Z" }, ] From 936d37162e374af802f2dbf4f1a0204eaf9b1509 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20C=2E=20Andersen?= Date: Sat, 18 Oct 2025 13:59:31 +0200 Subject: [PATCH 05/14] Adds .claude/settings.local.json to .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index d613531e..76c055b8 100644 --- a/.gitignore +++ b/.gitignore @@ -185,3 +185,5 @@ devenv.local.nix *.sql .idea/ + +.claude/settings.local.json From 8f23a9dfadd8bdb0d00e6e69731be6fa72737b5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20C=2E=20Andersen?= Date: Sat, 18 Oct 2025 18:51:30 +0200 Subject: [PATCH 06/14] Rename, refactor, unix line endings, --- .gitattributes | 24 ++++ .github/workflows/build.yml | 2 +- README.md | 104 +++++++++--------- assets/postgres-mcp-lite.png | Bin 0 -> 52503 bytes assets/postgres-mcp-pro.png | Bin 23480 -> 0 bytes pyproject.toml | 8 +- smithery.yaml | 2 +- src/{postgres_mcp/sql => pg_mcp}/__init__.py | 0 .../sql => pg_mcp}/bind_params.py | 0 .../sql => pg_mcp}/extension_utils.py | 0 src/{postgres_mcp/sql => pg_mcp}/index.py | 0 src/{postgres_mcp/sql => pg_mcp}/safe_sql.py | 0 src/{postgres_mcp => pg_mcp}/server.py | 11 +- .../sql => pg_mcp}/sql_driver.py | 21 +--- src/postgres_mcp/__init__.py | 22 ---- tests/conftest.py | 2 +- tests/unit/sql/test_db_conn_pool.py | 18 +-- tests/unit/sql/test_obfuscate_password.py | 2 +- tests/unit/sql/test_readonly_enforcement.py | 16 +-- tests/unit/sql/test_safe_sql.py | 4 +- tests/unit/sql/test_sql_driver.py | 6 +- tests/unit/test_access_mode.py | 40 +++---- uv.lock | 82 +++++++------- 23 files changed, 179 insertions(+), 185 deletions(-) create mode 100644 .gitattributes create mode 100644 assets/postgres-mcp-lite.png delete mode 100644 assets/postgres-mcp-pro.png rename src/{postgres_mcp/sql => pg_mcp}/__init__.py (100%) rename src/{postgres_mcp/sql => pg_mcp}/bind_params.py (100%) rename src/{postgres_mcp/sql => pg_mcp}/extension_utils.py (100%) rename src/{postgres_mcp/sql => pg_mcp}/index.py (100%) rename src/{postgres_mcp/sql => pg_mcp}/safe_sql.py (100%) rename src/{postgres_mcp => pg_mcp}/server.py (98%) rename src/{postgres_mcp/sql => pg_mcp}/sql_driver.py (95%) delete mode 100644 src/postgres_mcp/__init__.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..445ed4ba --- /dev/null +++ b/.gitattributes @@ -0,0 +1,24 @@ +# Set default behavior to automatically normalize line endings to LF +* text=auto eol=lf + +# Explicitly declare text files you want to always be normalized and converted to LF +*.py text eol=lf +*.md text eol=lf +*.txt text eol=lf +*.yaml text eol=lf +*.yml text eol=lf +*.toml text eol=lf +*.json text eol=lf +*.sh text eol=lf +*.sql text eol=lf + +# Denote all files that are truly binary and should not be modified +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.woff binary +*.woff2 binary +*.ttf binary +*.eot binary diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0ace83a6..3f4fdd4b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,7 @@ on: - "**/README.md" jobs: - postgres-mcp-ci: + pg-mcp-ci: runs-on: ubuntu-latest steps: diff --git a/README.md b/README.md index 6787ba63..cdaa7bc8 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@
-Postgres MCP Pro Logo +Postgres MCP Lite Logo [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) -[![PyPI - Version](https://img.shields.io/pypi/v/postgres-mcp)](https://pypi.org/project/postgres-mcp/) +[![PyPI - Version](https://img.shields.io/pypi/v/pg-mcp)](https://pypi.org/project/pg-mcp/) [![Discord](https://img.shields.io/discord/1336769798603931789?label=Discord)](https://discord.gg/4BEHC7ZM) [![Twitter Follow](https://img.shields.io/twitter/follow/auto_dba?style=flat)](https://x.com/auto_dba) -[![Contributors](https://img.shields.io/github/contributors/crystaldba/postgres-mcp)](https://github.com/crystaldba/postgres-mcp/graphs/contributors) +[![Contributors](https://img.shields.io/github/contributors/crystaldba/pg-mcp)](https://github.com/crystaldba/pg-mcp/graphs/contributors)

A Postgres MCP server with index tuning, explain plans, health checks, and safe sql execution.

@@ -24,9 +24,9 @@ ## Overview -**Postgres MCP Pro** is an open source Model Context Protocol (MCP) server built to support you and your AI agents throughout the **entire development process**—from initial coding, through testing and deployment, and to production tuning and maintenance. +**Postgres MCP Lite** is an open source Model Context Protocol (MCP) server built to support you and your AI agents throughout the **entire development process**—from initial coding, through testing and deployment, and to production tuning and maintenance. -Postgres MCP Pro does much more than wrap a database connection. +Postgres MCP Lite does much more than wrap a database connection. Features include: @@ -36,15 +36,15 @@ Features include: - **🧠 Schema Intelligence** - context-aware SQL generation based on detailed understanding of the database schema. - **🛡️ Safe SQL Execution** - configurable access control, including support for read-only mode and safe SQL parsing, making it usable for both development and production. -Postgres MCP Pro supports both the [Standard Input/Output (stdio)](https://modelcontextprotocol.io/docs/concepts/transports#standard-input%2Foutput-stdio) and [Server-Sent Events (SSE)](https://modelcontextprotocol.io/docs/concepts/transports#server-sent-events-sse) transports, for flexibility in different environments. +Postgres MCP Lite supports both the [Standard Input/Output (stdio)](https://modelcontextprotocol.io/docs/concepts/transports#standard-input%2Foutput-stdio) and [Server-Sent Events (SSE)](https://modelcontextprotocol.io/docs/concepts/transports#server-sent-events-sse) transports, for flexibility in different environments. -For additional background on why we built Postgres MCP Pro, see [our launch blog post](https://www.crystaldba.ai/blog/post/announcing-postgres-mcp-server-pro). +For additional background on why we built Postgres MCP Lite, see [our launch blog post](https://www.crystaldba.ai/blog/post/announcing-pg-mcp-server-pro). ## Demo *From Unusable to Lightning Fast* - **Challenge:** We generated a movie app using an AI assistant, but the SQLAlchemy ORM code ran painfully slow. -- **Solution:** Using Postgres MCP Pro with Cursor, we fixed the performance issues in minutes. +- **Solution:** Using Postgres MCP Lite with Cursor, we fixed the performance issues in minutes. What we did: - 🚀 Fixed performance - including ORM queries, indexing, and caching @@ -72,16 +72,16 @@ Before getting started, ensure you have: ### Installation -If you have `pipx` installed you can install Postgres MCP Pro with: +If you have `pipx` installed you can install Postgres MCP Lite with: ```bash -pipx install postgres-mcp +pipx install pg-mcp ``` -Otherwise, install Postgres MCP Pro with `uv`: +Otherwise, install Postgres MCP Lite with `uv`: ```bash -uv pip install postgres-mcp +uv pip install pg-mcp ``` If you need to install `uv`, see the [uv installation instructions](https://docs.astral.sh/uv/getting-started/installation/). @@ -89,12 +89,12 @@ If you need to install `uv`, see the [uv installation instructions](https://docs ### Configure Your AI Assistant -We provide full instructions for configuring Postgres MCP Pro with Claude Desktop. +We provide full instructions for configuring Postgres MCP Lite with Claude Desktop. Many MCP clients have similar configuration files, you can adapt these steps to work with the client of your choice. #### Claude Desktop Configuration -You will need to edit the Claude Desktop configuration file to add Postgres MCP Pro. +You will need to edit the Claude Desktop configuration file to add Postgres MCP Lite. The location of this file depends on your operating system: - MacOS: `~/Library/Application Support/Claude/claude_desktop_config.json` - Windows: `%APPDATA%/Claude/claude_desktop_config.json` @@ -109,7 +109,7 @@ You will now edit the `mcpServers` section of the configuration file. { "mcpServers": { "postgres": { - "command": "postgres-mcp", + "command": "pg-mcp", "args": [ "--access-mode=unrestricted" ], @@ -131,7 +131,7 @@ You will now edit the `mcpServers` section of the configuration file. "command": "uv", "args": [ "run", - "postgres-mcp", + "pg-mcp", "--access-mode=unrestricted" ], "env": { @@ -150,7 +150,7 @@ Replace `postgresql://...` with your [Postgres database connection URI](https:// ##### Multiple Database Connections -Postgres MCP Pro supports connecting to multiple databases simultaneously. This is useful when you need to work across different databases (e.g., application database, ETL database, analytics database). +Postgres MCP Lite supports connecting to multiple databases simultaneously. This is useful when you need to work across different databases (e.g., application database, ETL database, analytics database). To configure multiple connections, define additional environment variables with the pattern `DATABASE_URI_`: @@ -158,7 +158,7 @@ To configure multiple connections, define additional environment variables with { "mcpServers": { "postgres": { - "command": "postgres-mcp", + "command": "pg-mcp", "args": ["--access-mode=unrestricted"], "env": { "DATABASE_URI_APP": "postgresql://user:pass@localhost:5432/app_db", @@ -191,7 +191,7 @@ For backward compatibility, `DATABASE_URI` (without a suffix) maps to the connec ##### Access Mode -Postgres MCP Pro supports multiple *access modes* to give you control over the operations that the AI agent can perform on the database: +Postgres MCP Lite supports multiple *access modes* to give you control over the operations that the AI agent can perform on the database: - **Unrestricted Mode**: Allows full read/write access to modify data and schema. It is suitable for development environments. - **Restricted Mode**: Limits operations to read-only transactions and imposes constraints on resource utilization (presently only execution time). It is suitable for production environments. @@ -208,14 +208,14 @@ Many MCP clients have similar configuration files to Claude Desktop, and you can ## SSE Transport -Postgres MCP Pro supports the [SSE transport](https://modelcontextprotocol.io/docs/concepts/transports#server-sent-events-sse), which allows multiple MCP clients to share one server, possibly a remote server. +Postgres MCP Lite supports the [SSE transport](https://modelcontextprotocol.io/docs/concepts/transports#server-sent-events-sse), which allows multiple MCP clients to share one server, possibly a remote server. To use the SSE transport, you need to start the server with the `--transport=sse` option. For example, run: ```bash DATABASE_URI=postgresql://username:password@localhost:5432/dbname \ - postgres-mcp --access-mode=unrestricted --transport=sse + pg-mcp --access-mode=unrestricted --transport=sse ``` Then update your MCP client configuration to call the MCP server. @@ -249,9 +249,9 @@ For Windsurf, the format in `mcp_config.json` is slightly different: To enable index tuning and comprehensive performance analysis you need to load the `pg_statements` and `hypopg` extensions on your database. -- The `pg_statements` extension allows Postgres MCP Pro to analyze query execution statistics. +- The `pg_statements` extension allows Postgres MCP Lite to analyze query execution statistics. For example, this allows it to understand which queries are running slow or consuming significant resources. -- The `hypopg` extension allows Postgres MCP Pro to simulate the behavior of the Postgres query planner after adding indexes. +- The `hypopg` extension allows Postgres MCP Lite to simulate the behavior of the Postgres query planner after adding indexes. ### Installing extensions on AWS RDS, Azure SQL, or Google Cloud SQL @@ -300,12 +300,12 @@ Ask: The [MCP standard](https://modelcontextprotocol.io/) defines various types of endpoints: Tools, Resources, Prompts, and others. -Postgres MCP Pro provides functionality via [MCP tools](https://modelcontextprotocol.io/docs/concepts/tools) alone. +Postgres MCP Lite provides functionality via [MCP tools](https://modelcontextprotocol.io/docs/concepts/tools) alone. We chose this approach because the [MCP client ecosystem](https://modelcontextprotocol.io/clients) has widespread support for MCP tools. This contrasts with the approach of other Postgres MCP servers, including the [Reference Postgres MCP Server](https://github.com/modelcontextprotocol/servers/tree/main/src/postgres), which use [MCP resources](https://modelcontextprotocol.io/docs/concepts/resources) to expose schema information. -Postgres MCP Pro Tools: +Postgres MCP Lite Tools: | Tool Name | Description | |-----------|-------------| @@ -341,24 +341,24 @@ Postgres MCP Pro Tools: **Postgres Utilities** - [Dexter](https://github.com/DexterDB/dexter). A tool for generating and testing hypothetical indexes on PostgreSQL. - [PgHero](https://github.com/ankane/pghero). A performance dashboard for Postgres, with recommendations. -Postgres MCP Pro incorporates health checks from PgHero. +Postgres MCP Lite incorporates health checks from PgHero. - [PgTune](https://github.com/le0pard/pgtune?tab=readme-ov-file). Heuristics for tuning Postgres configuration. ## Frequently Asked Questions -*How is Postgres MCP Pro different from other Postgres MCP servers?* +*How is Postgres MCP Lite different from other Postgres MCP servers?* There are many MCP servers allow an AI agent to run queries against a Postgres database. -Postgres MCP Pro does that too, but also adds tools for understanding and improving the performance of your Postgres database. +Postgres MCP Lite does that too, but also adds tools for understanding and improving the performance of your Postgres database. For example, it implements a version of the [Anytime Algorithm of Database Tuning Advisor for Microsoft SQL Server](https://www.microsoft.com/en-us/research/wp-content/uploads/2020/06/Anytime-Algorithm-of-Database-Tuning-Advisor-for-Microsoft-SQL-Server.pdf), a modern industrial-strength algorithm for automatic index tuning. -| Postgres MCP Pro | Other Postgres MCP Servers | +| Postgres MCP Lite | Other Postgres MCP Servers | |--------------|----------------------------| | ✅ Deterministic database health checks | ❌ Unrepeatable LLM-generated health queries | | ✅ Principled indexing search strategies | ❌ Gen-AI guesses at indexing improvements | | ✅ Workload analysis to find top problems | ❌ Inconsistent problem analysis | | ✅ Simulates performance improvements | ❌ Try it yourself and see if it works | -Postgres MCP Pro complements generative AI by adding deterministic tools and classical optimization algorithms +Postgres MCP Lite complements generative AI by adding deterministic tools and classical optimization algorithms The combination is both reliable and flexible. @@ -366,11 +366,11 @@ The combination is both reliable and flexible. LLMs are invaluable for tasks that involve ambiguity, reasoning, or natural language. When compared to procedural code, however, they can be slow, expensive, non-deterministic, and sometimes produce unreliable results. In the case of database tuning, we have well established algorithms, developed over decades, that are proven to work. -Postgres MCP Pro lets you combine the best of both worlds by pairing LLMs with classical optimization algorithms and other procedural tools. +Postgres MCP Lite lets you combine the best of both worlds by pairing LLMs with classical optimization algorithms and other procedural tools. -*How do you test Postgres MCP Pro?* -Testing is critical to ensuring that Postgres MCP Pro is reliable and accurate. -We are building out a suite of AI-generated adversarial workloads designed to challenge Postgres MCP Pro and ensure it performs under a broad variety of scenarios. +*How do you test Postgres MCP Lite?* +Testing is critical to ensuring that Postgres MCP Lite is reliable and accurate. +We are building out a suite of AI-generated adversarial workloads designed to challenge Postgres MCP Lite and ensure it performs under a broad variety of scenarios. *What Postgres versions are supported?* Our testing presently focuses on Postgres 15, 16, and 17. @@ -384,12 +384,12 @@ This project is created and maintained by [Crystal DBA](https://www.crystaldba.a *TBD* You and your needs are a critical driver for what we build. -Tell us what you want to see by opening an [issue](https://github.com/crystaldba/postgres-mcp/issues) or a [pull request](https://github.com/crystaldba/postgres-mcp/pulls). +Tell us what you want to see by opening an [issue](https://github.com/crystaldba/pg-mcp/issues) or a [pull request](https://github.com/crystaldba/pg-mcp/pulls). You can also contact us on [Discord](https://discord.gg/4BEHC7ZM). ## Technical Notes -This section includes a high-level overview technical considerations that influenced the design of Postgres MCP Pro. +This section includes a high-level overview technical considerations that influenced the design of Postgres MCP Lite. ### Index Tuning @@ -397,11 +397,11 @@ Developers know that missing indexes are one of the most common causes of databa Indexes provide access methods that allow Postgres to quickly locate data that is required to execute a query. When tables are small, indexes make little difference, but as the size of the data grows, the difference in algorithmic complexity between a table scan and an index lookup becomes significant (typically *O*(*n*) vs *O*(*log* *n*), potentially more if joins on multiple tables are involved). -Generating suggested indexes in Postgres MCP Pro proceeds in several stages: +Generating suggested indexes in Postgres MCP Lite proceeds in several stages: 1. *Identify SQL queries in need of tuning*. If you know you are having a problem with a specific SQL query you can provide it. - Postgres MCP Pro can also analyze the workload to identify index tuning targets. + Postgres MCP Lite can also analyze the workload to identify index tuning targets. To do this, it relies on the `pg_stat_statements` extension, which records the runtime and resource consumption of each query. A query is a candidate for index tuning if it is a top resource consumer, either on a per-execution basis or in aggregate. @@ -410,7 +410,7 @@ Generating suggested indexes in Postgres MCP Pro proceeds in several stages: Agents may also call `get_top_queries`, which accepts a parameter for mean vs. total execution time, then pass these queries `analyze_query_indexes` to get index recommendations. Sophisticated index tuning systems use "workload compression" to produce a representative subset of queries that reflects the characteristics of the workload as a whole, reducing the problem for downstream algorithms. - Postgres MCP Pro performs a limited form of workload compression by normalizing queries so that those generated from the same template appear as one. + Postgres MCP Lite performs a limited form of workload compression by normalizing queries so that those generated from the same template appear as one. It weights each query equally, a simplification that works when the benefits to indexing are large. 2. *Generate candidate indexes* @@ -461,7 +461,7 @@ This give the LLM additional context that it can use when responding to the inde ### Experimental: Index Tuning by LLM -Postgres MCP Pro includes an experimental index tuning feature based on [Optimization by LLM](https://arxiv.org/abs/2309.03409). +Postgres MCP Lite includes an experimental index tuning feature based on [Optimization by LLM](https://arxiv.org/abs/2309.03409). Instead of using heuristics to explore possible index configurations, we provide the database schema and query plans to an LLM and ask it to propose index configurations. We then use `hypopg` to predict performance with the proposed indexes, then feed those results back into the LLM to produce a new set of suggestions. We repeat this process until multiple rounds of iteration produce no further improvements. @@ -475,7 +475,7 @@ In order to perform index optimization by LLM, you must provide an OpenAI API ke ### Database Health Database health checks identify tuning opportunities and maintenance needs before they lead to critical issues. -In the present release, Postgres MCP Pro adapts the database health checks directly from [PgHero](https://github.com/ankane/pghero). +In the present release, Postgres MCP Lite adapts the database health checks directly from [PgHero](https://github.com/ankane/pghero). We are working to fully validate these checks and may extend them in the future. - *Index Health*. Looks for unused indexes, duplicate indexes, and indexes that are bloated. Bloated indexes make inefficient use of database pages. @@ -500,7 +500,7 @@ We are working to fully validate these checks and may extend them in the future. ### Postgres Client Library -Postgres MCP Pro uses [psycopg3](https://www.psycopg.org/) to connect to Postgres using asynchronous I/O. +Postgres MCP Lite uses [psycopg3](https://www.psycopg.org/) to connect to Postgres using asynchronous I/O. Under the hood, psycopg3 uses the [libpq](https://www.postgresql.org/docs/current/libpq.html) library to connect to Postgres, providing access to the full Postgres feature set and an underlying implementation fully supported by the Postgres community. Some other Python-based MCP servers use [asyncpg](https://github.com/MagicStack/asyncpg), which may simplify installation by eliminating the `libpq` dependency. @@ -513,7 +513,7 @@ We remain open to revising this decision in the future. ### Connection Configuration -Like the [Reference PostgreSQL MCP Server](https://github.com/modelcontextprotocol/servers/tree/main/src/postgres), Postgres MCP Pro takes Postgres connection information at startup. +Like the [Reference PostgreSQL MCP Server](https://github.com/modelcontextprotocol/servers/tree/main/src/postgres), Postgres MCP Lite takes Postgres connection information at startup. This is convenient for users who always connect to the same database but can be cumbersome when users switch databases. An alternative approach, taken by [PG-MCP](https://github.com/stuzero/pg-mcp-server), is provide connection details via MCP tool calls at the time of use. @@ -550,15 +550,15 @@ AI amplifies longstanding challenges of protecting databases from a range of thr Whether the threat is accidental or malicious, a similar security framework applies, with aims that fall into three categories: confidentiality, integrity, and availability. The familiar tension between convenience and safety is also evident and pronounced. -Postgres MCP Pro's protected SQL execution mode focuses on integrity. +Postgres MCP Lite's protected SQL execution mode focuses on integrity. In the context of MCP, we are most concerned with LLM-generated SQL causing damage—for example, unintended data modification or deletion, or other changes that might circumvent an organization's change management process. The simplest way to provide integrity is to ensure that all SQL executed against the database is read-only. One way to do this is by creating a database user with read-only access permissions. While this is a good approach, many find this cumbersome in practice. -Postgres does not provide a way to place a connection or session into read-only mode, so Postgres MCP Pro uses a more complex approach to ensure read-only SQL execution on top of a read-write connection. +Postgres does not provide a way to place a connection or session into read-only mode, so Postgres MCP Lite uses a more complex approach to ensure read-only SQL execution on top of a read-write connection. -Postgres MCP Provides a read-only transaction mode that prevents data and schema modifications. +Postgres MCP Litevides a read-only transaction mode that prevents data and schema modifications. Like the [Reference PostgreSQL MCP Server](https://github.com/modelcontextprotocol/servers/tree/main/src/postgres), we use read-only transactions to provide protected SQL execution. To make this mechanism robust, we need to ensure that the SQL does not somehow circumvent the read-only transaction mode, say by issuing a `COMMIT` or `ROLLBACK` statement and then beginning a new transaction. @@ -574,7 +574,7 @@ We reject any SQL that contains `commit` or `rollback` statements. Helpfully, the popular Postgres stored procedure languages, including PL/pgSQL and PL/Python, do not allow for `COMMIT` or `ROLLBACK` statements. If you have unsafe stored procedure languages enabled on your database, then our read-only protections could be circumvented. -At present, Postgres MCP Pro provides two levels of protection for the database, one at either extreme of the convenience/safety spectrum. +At present, Postgres MCP Lite provides two levels of protection for the database, one at either extreme of the convenience/safety spectrum. - "Unrestricted" provides maximum flexibility. It is suitable for development environments where speed and flexibility are paramount, and where there is no need to protect valuable or sensitive data. - "Restricted" provides a balance between flexibility and safety. @@ -588,9 +588,9 @@ Restricted mode is limited to read-only operations, and we limit query execution We may add measures in the future to make sure that restricted mode is safe to use with production databases. -## Postgres MCP Pro Development +## Postgres MCP Lite Development -The instructions below are for developers who want to work on Postgres MCP Pro, or users who prefer to install Postgres MCP Pro from source. +The instructions below are for developers who want to work on Postgres MCP Lite, or users who prefer to install Postgres MCP Lite from source. ### Local Development Setup @@ -603,8 +603,8 @@ The instructions below are for developers who want to work on Postgres MCP Pro, 2. **Clone the repository**: ```bash - git clone https://github.com/crystaldba/postgres-mcp.git - cd postgres-mcp + git clone https://github.com/crystaldba/pg-mcp.git + cd pg-mcp ``` 3. **Install dependencies**: @@ -616,5 +616,5 @@ The instructions below are for developers who want to work on Postgres MCP Pro, 4. **Run the server**: ```bash - uv run postgres-mcp "postgres://user:password@localhost:5432/dbname" + uv run pg-mcp "postgres://user:password@localhost:5432/dbname" ``` diff --git a/assets/postgres-mcp-lite.png b/assets/postgres-mcp-lite.png new file mode 100644 index 0000000000000000000000000000000000000000..24be83b9952b92ea763414e567066d022918e242 GIT binary patch literal 52503 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�%4J7r&q{vBP87$Errn}5cY z^fCR8IUzxsnotZO1YorLLJf~1>up&azclK|5eOL;U1edf+M@A*m%1>{0_|x2b@b=% zjWSV&N`j1v|D3RrJf33w$Lf(KmZ>k1zazi9bXI%|g zYVs@ZIs)d&hZ5`g>#Z#nfK$H@hn)euu8aoCq`am*b0K?C z1FA{}Iy?78OD4sgv7}QvSVENKr$(93I)~Y9YkVX3c#2W{rI1{@dZHQWZtAd!)5QMI zaGvAG0P~lBK+rg|33bnDKdq-+C#G91QtRQ+2`4o*RdH@@#jTBeGfIj_>Q;QE3<#uY zLfFN`oVvztiMa!I^B@$4bZTuEf z2cO2u${t^jw&TCt7M9ohl1}KYR=4Hv1a#KOT?yc1Zk|o}iSHj%F)((fKcpPhJp(*#m-uS_E#;vc1|tFU=}O;xP}N2AA^6 zUS+I+#hU1t(hN_vvxPe^^*8D+sOsOj%{&@Irahq;>osLd>}prsYSAB!LS`Smex5C7 zU2sW7Y()KbDfJeUm7!*W)N#!Klfc$tUdqTqm@L z3S1I;QT$A@Z{fl0rRjn4#?xPS{*7_N#9o~Bhw?ja{=+G_)#V>C6@mq5e$DWPdwZg! z3D;;HZ$n9$R*N8JDwcK^LsCTL6UE}9?B`Pp$UJZwDU6l<`MYdqt^Ct2ruteWvdLA4 zttE;wr=|*Fm;JX;Ld3^(PCJuAK`D>M?SI_jY6S0I)3A~lp@DZ;NPfgt=d`)XE^OeOFb zlDX{08TatsqCY2<PR@o_sTQ`lHIo$|Yw~kX*c>;U^NaVyYT; zbBJ36+x<8_1$L->YGWjz*>s(NXHYT=-=3%LCBGH=;5)iEfoX`cGS03~RLvyrOz9%D zDAhe6?B%TTUVPX#VzLnzqU>iE6 zfvxsu?-ODk%8{Z?bWc07WV3w!WFjCAs0+SOptmFxNZktCfJov{wQBa@TnRfDnOA&$;AjGik*Ff`!$s=Dg zje?@mhU^-lf)^<)_s1m$K!vAXnNI}3&E#S^zc9T-`motfm=CUA7U0^PwEwt>aqy3B zTvJ8n=r}65#!)$NszMJFS%TzO!+nHo(Wg3%c)Rombidn6CmH$kZt-zC)U}vZ*B%Pe z@T>mqVt5tZr?4TJ`-S#nNhW2$6*iK?5=~wa>XMRQlqGAjXsIZT5(iOoD&@j(&@jFo zmZsGw4)BI*d%IWkxnguy0uPwf8|%qX^Ud)-y5G(~3h=~o5;}-`J`9hrdWZL179nIZ zZnMfvbrejTk3=grF9)RTTt%GwNlQ6q>Cfn-#{uZv(r2o4=@v{H>w9DdRN$lzQ}rzL z3WXXhj{ZSG5uiFc*r7bd&Hu$e&$vmSC*E>Z_8WQJdov6XNAiAIRs6M}6l{-{SDP5< zZ>HE%qd$#_S;L-n7pA|ZvL;9ZZx*d)gF44KYiJ3^vqR_VjdU?{-XihN5XzsGKinF| zRE0mMm-P~e&2@{n62s7_5_nk1B|r#vB&IyC$A~@*BqopQvG7&Tb{tGalnD7~cvNQM zNIP^Ta|?`{OXV}RYI60dB&(Vv0^n<4jv_∓u|Ia13oo7dGn6*Z(FY-qoHcOd@gJ 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 FO#lo>@U{Q| literal 0 HcmV?d00001 diff --git a/assets/postgres-mcp-pro.png b/assets/postgres-mcp-pro.png deleted file mode 100644 index db605fe13fc22b031e68978a7134eb59cd66d37d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 23480 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 ({command: 'postgres-mcp', args: ['--access-mode', config.accessMode], env: {DATABASE_URI: config.databaseUri}}) \ No newline at end of file + (config) => ({command: 'pg-mcp', args: ['--access-mode', config.accessMode], env: {DATABASE_URI: config.databaseUri}}) \ No newline at end of file diff --git a/src/postgres_mcp/sql/__init__.py b/src/pg_mcp/__init__.py similarity index 100% rename from src/postgres_mcp/sql/__init__.py rename to src/pg_mcp/__init__.py diff --git a/src/postgres_mcp/sql/bind_params.py b/src/pg_mcp/bind_params.py similarity index 100% rename from src/postgres_mcp/sql/bind_params.py rename to src/pg_mcp/bind_params.py diff --git a/src/postgres_mcp/sql/extension_utils.py b/src/pg_mcp/extension_utils.py similarity index 100% rename from src/postgres_mcp/sql/extension_utils.py rename to src/pg_mcp/extension_utils.py diff --git a/src/postgres_mcp/sql/index.py b/src/pg_mcp/index.py similarity index 100% rename from src/postgres_mcp/sql/index.py rename to src/pg_mcp/index.py diff --git a/src/postgres_mcp/sql/safe_sql.py b/src/pg_mcp/safe_sql.py similarity index 100% rename from src/postgres_mcp/sql/safe_sql.py rename to src/pg_mcp/safe_sql.py diff --git a/src/postgres_mcp/server.py b/src/pg_mcp/server.py similarity index 98% rename from src/postgres_mcp/server.py rename to src/pg_mcp/server.py index c1e782d9..729c2b42 100644 --- a/src/postgres_mcp/server.py +++ b/src/pg_mcp/server.py @@ -6,15 +6,16 @@ import signal import sys from enum import Enum +from typing import Any import mcp.types as types from mcp.server.fastmcp import FastMCP from pydantic import Field -from .sql import ConnectionRegistry -from .sql import SafeSqlDriver -from .sql import SqlDriver -from .sql import obfuscate_password +from pg_mcp import ConnectionRegistry +from pg_mcp import SafeSqlDriver +from pg_mcp import SqlDriver +from pg_mcp import obfuscate_password # Initialize FastMCP with default settings # Note: Server instructions will be updated after database connections are discovered @@ -387,7 +388,7 @@ async def main(): instructions.append(f"- {info['name']}") # Set the server instructions to include connection information - mcp._instructions = "\n".join(instructions) + mcp._instructions = "\n".join(instructions) # type: ignore logger.info(f"Updated server context with {len(conn_info)} connection(s)") except Exception as e: logger.warning( diff --git a/src/postgres_mcp/sql/sql_driver.py b/src/pg_mcp/sql_driver.py similarity index 95% rename from src/postgres_mcp/sql/sql_driver.py rename to src/pg_mcp/sql_driver.py index 45291750..8362e69e 100644 --- a/src/postgres_mcp/sql/sql_driver.py +++ b/src/pg_mcp/sql_driver.py @@ -160,7 +160,7 @@ def discover_connections(self) -> dict[str, str]: discovered["default"] = url elif env_var.startswith("DATABASE_URI_"): # Extract postfix and lowercase it - postfix = env_var[len("DATABASE_URI_"):] + postfix = env_var[len("DATABASE_URI_") :] conn_name = postfix.lower() discovered[conn_name] = url @@ -183,7 +183,7 @@ def discover_descriptions(self) -> dict[str, str]: descriptions["default"] = desc elif env_var.startswith("DATABASE_DESC_"): # Extract postfix and lowercase it - postfix = env_var[len("DATABASE_DESC_"):] + postfix = env_var[len("DATABASE_DESC_") :] conn_name = postfix.lower() descriptions[conn_name] = desc @@ -197,9 +197,7 @@ async def discover_and_connect(self) -> None: discovered = self.discover_connections() if not discovered: - raise ValueError( - "No database connections found. Please set DATABASE_URI or DATABASE_URI_* environment variables." - ) + raise ValueError("No database connections found. Please set DATABASE_URI or DATABASE_URI_* environment variables.") logger.info(f"Discovered {len(discovered)} database connection(s): {', '.join(discovered.keys())}") @@ -223,10 +221,7 @@ async def connect_single(conn_name: str, pool: DbConnPool) -> tuple[str, bool, s return (conn_name, False, error_msg) # Execute all connections in parallel - results = await asyncio.gather( - *[connect_single(name, pool) for name, pool in self.connections.items()], - return_exceptions=False - ) + results = await asyncio.gather(*[connect_single(name, pool) for name, pool in self.connections.items()], return_exceptions=False) # Log results for conn_name, success, error in results: @@ -250,18 +245,14 @@ def get_connection(self, conn_name: str) -> DbConnPool: """ if conn_name not in self.connections: available = ", ".join(f"'{name}'" for name in sorted(self.connections.keys())) - raise ValueError( - f"Connection '{conn_name}' not found. Available connections: {available}" - ) + raise ValueError(f"Connection '{conn_name}' not found. Available connections: {available}") pool = self.connections[conn_name] # Check if connection is valid if not pool.is_valid: error_msg = pool.last_error or "Unknown error" - raise ValueError( - f"Connection '{conn_name}' is not available: {obfuscate_password(error_msg)}" - ) + raise ValueError(f"Connection '{conn_name}' is not available: {obfuscate_password(error_msg)}") return pool diff --git a/src/postgres_mcp/__init__.py b/src/postgres_mcp/__init__.py deleted file mode 100644 index fb3254f0..00000000 --- a/src/postgres_mcp/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -import asyncio -import sys - -from . import server - - -def main(): - """Main entry point for the package.""" - # As of version 3.3.0 Psycopg on Windows is not compatible with the default - # ProactorEventLoop. - # See: https://www.psycopg.org/psycopg3/docs/advanced/async.html#async - if sys.platform == "win32": - asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) - - asyncio.run(server.main()) - - -# Optionally expose other important items at package level -__all__ = [ - "main", - "server", -] diff --git a/tests/conftest.py b/tests/conftest.py index d1a47db6..a4fffc5e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,7 @@ import pytest from dotenv import load_dotenv -from postgres_mcp.sql import reset_postgres_version_cache +from pg_mcp import reset_postgres_version_cache load_dotenv() diff --git a/tests/unit/sql/test_db_conn_pool.py b/tests/unit/sql/test_db_conn_pool.py index e916d03a..16d2ce4f 100644 --- a/tests/unit/sql/test_db_conn_pool.py +++ b/tests/unit/sql/test_db_conn_pool.py @@ -5,7 +5,7 @@ import pytest -from postgres_mcp.sql.sql_driver import DbConnPool +from pg_mcp.sql_driver import DbConnPool class AsyncContextManagerMock(AsyncMock): @@ -48,7 +48,7 @@ def mock_pool(): @pytest.mark.asyncio async def test_pool_connect_success(mock_pool): """Test successful connection to the database pool.""" - with patch("postgres_mcp.sql.sql_driver.AsyncConnectionPool", return_value=mock_pool): + with patch("pg_mcp.AsyncConnectionPool", return_value=mock_pool): # Patch the connection test part to skip it with patch.object(DbConnPool, "pool_connect", new=AsyncMock(return_value=mock_pool)) as mock_connect: db_pool = DbConnPool("postgresql://user:pass@localhost/db") @@ -80,8 +80,8 @@ async def mock_pool_connect(self, connection_url=None): self._is_valid = True return mock_pool - with patch("postgres_mcp.sql.sql_driver.AsyncConnectionPool", return_value=mock_pool): - with patch("postgres_mcp.server.asyncio.sleep", AsyncMock()) as mock_sleep: + with patch("pg_mcp.AsyncConnectionPool", return_value=mock_pool): + with patch("pg_mcp.server.asyncio.sleep", AsyncMock()) as mock_sleep: with patch.object(DbConnPool, "pool_connect", mock_pool_connect): db_pool = DbConnPool("postgresql://user:pass@localhost/db") @@ -105,7 +105,7 @@ async def test_pool_connect_all_retries_fail(mock_pool): mock_pool.open.side_effect = Exception("Persistent connection error") # Configure AsyncConnectionPool's constructor to return our mock - with patch("postgres_mcp.sql.sql_driver.AsyncConnectionPool", return_value=mock_pool): + with patch("pg_mcp.AsyncConnectionPool", return_value=mock_pool): # Mock sleep to speed up test with patch("asyncio.sleep", AsyncMock()): db_pool = DbConnPool("postgresql://user:pass@localhost/db") @@ -123,7 +123,7 @@ async def test_pool_connect_all_retries_fail(mock_pool): @pytest.mark.asyncio async def test_close_pool(mock_pool): """Test closing the connection pool.""" - with patch("postgres_mcp.sql.sql_driver.AsyncConnectionPool", return_value=mock_pool): + with patch("pg_mcp.AsyncConnectionPool", return_value=mock_pool): db_pool = DbConnPool("postgresql://user:pass@localhost/db") # Mock the pool_connect method to avoid actual connection @@ -146,7 +146,7 @@ async def test_close_handles_errors(mock_pool): """Test that close() handles exceptions gracefully.""" mock_pool.close.side_effect = Exception("Error closing pool") - with patch("postgres_mcp.sql.sql_driver.AsyncConnectionPool", return_value=mock_pool): + with patch("pg_mcp.AsyncConnectionPool", return_value=mock_pool): db_pool = DbConnPool("postgresql://user:pass@localhost/db") # Mock the pool_connect method to avoid actual connection @@ -166,7 +166,7 @@ async def test_close_handles_errors(mock_pool): @pytest.mark.asyncio async def test_pool_connect_initialized(mock_pool): """Test pool_connect when pool is already initialized.""" - with patch("postgres_mcp.sql.sql_driver.AsyncConnectionPool", return_value=mock_pool): + with patch("pg_mcp.AsyncConnectionPool", return_value=mock_pool): db_pool = DbConnPool("postgresql://user:pass@localhost/db") # Mock the pool_connect method to avoid actual connection @@ -189,7 +189,7 @@ async def test_pool_connect_initialized(mock_pool): @pytest.mark.asyncio async def test_pool_connect_not_initialized(mock_pool): """Test pool_connect when pool is not yet initialized.""" - with patch("postgres_mcp.sql.sql_driver.AsyncConnectionPool", return_value=mock_pool): + with patch("pg_mcp.AsyncConnectionPool", return_value=mock_pool): db_pool = DbConnPool("postgresql://user:pass@localhost/db") # Mock the pool_connect method to avoid actual connection diff --git a/tests/unit/sql/test_obfuscate_password.py b/tests/unit/sql/test_obfuscate_password.py index 62858e92..0bb90e01 100644 --- a/tests/unit/sql/test_obfuscate_password.py +++ b/tests/unit/sql/test_obfuscate_password.py @@ -1,4 +1,4 @@ -from postgres_mcp.sql import obfuscate_password +from pg_mcp import obfuscate_password def test_obfuscate_none_or_empty(): diff --git a/tests/unit/sql/test_readonly_enforcement.py b/tests/unit/sql/test_readonly_enforcement.py index 4f2ba97a..622e0867 100644 --- a/tests/unit/sql/test_readonly_enforcement.py +++ b/tests/unit/sql/test_readonly_enforcement.py @@ -4,10 +4,10 @@ import pytest -from postgres_mcp.server import AccessMode -from postgres_mcp.server import get_sql_driver -from postgres_mcp.sql import SafeSqlDriver -from postgres_mcp.sql import SqlDriver +from pg_mcp import SafeSqlDriver +from pg_mcp import SqlDriver +from pg_mcp.server import AccessMode +from pg_mcp.server import get_sql_driver @pytest.mark.asyncio @@ -26,8 +26,8 @@ async def test_force_readonly_enforcement(): mock_execute.return_value = [SqlDriver.RowResult(cells={"test": "value"})] # Test UNRESTRICTED mode - with patch("postgres_mcp.server.current_access_mode", AccessMode.UNRESTRICTED), patch( - "postgres_mcp.server.connection_registry.get_connection", return_value=mock_conn_pool + with patch("pg_mcp.server.current_access_mode", AccessMode.UNRESTRICTED), patch( + "pg_mcp.server.connection_registry.get_connection", return_value=mock_conn_pool ), patch.object(SqlDriver, "_execute_with_connection", mock_execute): driver = await get_sql_driver(conn_name="default") assert isinstance(driver, SqlDriver) @@ -55,8 +55,8 @@ async def test_force_readonly_enforcement(): assert mock_execute.call_args[1]["force_readonly"] is False # Test RESTRICTED mode - with patch("postgres_mcp.server.current_access_mode", AccessMode.RESTRICTED), patch( - "postgres_mcp.server.connection_registry.get_connection", return_value=mock_conn_pool + with patch("pg_mcp.server.current_access_mode", AccessMode.RESTRICTED), patch( + "pg_mcp.server.connection_registry.get_connection", return_value=mock_conn_pool ), patch.object(SqlDriver, "_execute_with_connection", mock_execute): driver = await get_sql_driver(conn_name="default") assert isinstance(driver, SafeSqlDriver) diff --git a/tests/unit/sql/test_safe_sql.py b/tests/unit/sql/test_safe_sql.py index c55d2530..f5751a57 100644 --- a/tests/unit/sql/test_safe_sql.py +++ b/tests/unit/sql/test_safe_sql.py @@ -7,8 +7,8 @@ from psycopg.sql import SQL from psycopg.sql import Literal -from postgres_mcp.sql import SafeSqlDriver -from postgres_mcp.sql import SqlDriver +from pg_mcp import SafeSqlDriver +from pg_mcp import SqlDriver @pytest_asyncio.fixture diff --git a/tests/unit/sql/test_sql_driver.py b/tests/unit/sql/test_sql_driver.py index 4033537d..997d2645 100644 --- a/tests/unit/sql/test_sql_driver.py +++ b/tests/unit/sql/test_sql_driver.py @@ -6,8 +6,8 @@ import pytest -from postgres_mcp.sql import DbConnPool -from postgres_mcp.sql import SqlDriver +from pg_mcp import DbConnPool +from pg_mcp import SqlDriver class AsyncContextManagerMock(AsyncMock): @@ -355,7 +355,7 @@ async def test_engine_url_connection(): """Test connecting with engine_url instead of connection object.""" db_pool = MagicMock(spec=DbConnPool) - with patch("postgres_mcp.sql.DbConnPool", return_value=db_pool): + with patch("pg_mcp.DbConnPool", return_value=db_pool): # Create SqlDriver with engine_url driver = SqlDriver(engine_url="postgresql://user:pass@localhost/db") diff --git a/tests/unit/test_access_mode.py b/tests/unit/test_access_mode.py index b772e1d4..0f9bdb5f 100644 --- a/tests/unit/test_access_mode.py +++ b/tests/unit/test_access_mode.py @@ -5,11 +5,11 @@ import pytest -from postgres_mcp.server import AccessMode -from postgres_mcp.server import get_sql_driver -from postgres_mcp.sql.safe_sql import SafeSqlDriver -from postgres_mcp.sql.sql_driver import DbConnPool -from postgres_mcp.sql.sql_driver import SqlDriver +from pg_mcp.safe_sql import SafeSqlDriver +from pg_mcp.server import AccessMode +from pg_mcp.server import get_sql_driver +from pg_mcp.sql_driver import DbConnPool +from pg_mcp.sql_driver import SqlDriver @pytest.fixture @@ -31,8 +31,8 @@ def mock_db_connection(): async def test_get_sql_driver_returns_correct_driver(access_mode, expected_driver_type, mock_db_connection): """Test that get_sql_driver returns the correct driver type based on access mode.""" with ( - patch("postgres_mcp.server.current_access_mode", access_mode), - patch("postgres_mcp.server.connection_registry.get_connection", return_value=mock_db_connection), + patch("pg_mcp.server.current_access_mode", access_mode), + patch("pg_mcp.server.connection_registry.get_connection", return_value=mock_db_connection), ): driver = await get_sql_driver(conn_name="default") assert isinstance(driver, expected_driver_type) @@ -47,8 +47,8 @@ async def test_get_sql_driver_returns_correct_driver(access_mode, expected_drive async def test_get_sql_driver_sets_timeout_in_restricted_mode(mock_db_connection): """Test that get_sql_driver sets the timeout in restricted mode.""" with ( - patch("postgres_mcp.server.current_access_mode", AccessMode.RESTRICTED), - patch("postgres_mcp.server.connection_registry.get_connection", return_value=mock_db_connection), + patch("pg_mcp.server.current_access_mode", AccessMode.RESTRICTED), + patch("pg_mcp.server.connection_registry.get_connection", return_value=mock_db_connection), ): driver = await get_sql_driver(conn_name="default") assert isinstance(driver, SafeSqlDriver) @@ -60,8 +60,8 @@ async def test_get_sql_driver_sets_timeout_in_restricted_mode(mock_db_connection async def test_get_sql_driver_in_unrestricted_mode_no_timeout(mock_db_connection): """Test that get_sql_driver in unrestricted mode is a regular SqlDriver.""" with ( - patch("postgres_mcp.server.current_access_mode", AccessMode.UNRESTRICTED), - patch("postgres_mcp.server.connection_registry.get_connection", return_value=mock_db_connection), + patch("pg_mcp.server.current_access_mode", AccessMode.UNRESTRICTED), + patch("pg_mcp.server.connection_registry.get_connection", return_value=mock_db_connection), ): driver = await get_sql_driver(conn_name="default") assert isinstance(driver, SqlDriver) @@ -73,7 +73,7 @@ async def test_command_line_parsing(): """Test that command-line arguments correctly set the access mode.""" import sys - from postgres_mcp.server import main + from pg_mcp.server import main # Mock sys.argv and asyncio.run original_argv = sys.argv @@ -82,22 +82,22 @@ async def test_command_line_parsing(): try: # Test with --access-mode=restricted sys.argv = [ - "postgres_mcp", + "pg_mcp", "postgresql://user:password@localhost/db", "--access-mode=restricted", ] asyncio.run = AsyncMock() with ( - patch("postgres_mcp.server.current_access_mode", AccessMode.UNRESTRICTED), - patch("postgres_mcp.server.connection_registry.discover_and_connect", AsyncMock()), - patch("postgres_mcp.server.mcp.run_stdio_async", AsyncMock()), - patch("postgres_mcp.server.shutdown", AsyncMock()), + patch("pg_mcp.server.current_access_mode", AccessMode.UNRESTRICTED), + patch("pg_mcp.server.connection_registry.discover_and_connect", AsyncMock()), + patch("pg_mcp.server.mcp.run_stdio_async", AsyncMock()), + patch("pg_mcp.server.shutdown", AsyncMock()), ): # Reset the current_access_mode to UNRESTRICTED - import postgres_mcp.server + import pg_mcp.server - postgres_mcp.server.current_access_mode = AccessMode.UNRESTRICTED + pg_mcp.server.current_access_mode = AccessMode.UNRESTRICTED # Run main (partially mocked to avoid actual connection) try: @@ -106,7 +106,7 @@ async def test_command_line_parsing(): pass # Verify the mode was changed to RESTRICTED - assert postgres_mcp.server.current_access_mode == AccessMode.RESTRICTED + assert pg_mcp.server.current_access_mode == AccessMode.RESTRICTED finally: # Restore original values diff --git a/uv.lock b/uv.lock index 21c90705..1be58bb1 100644 --- a/uv.lock +++ b/uv.lock @@ -571,47 +571,7 @@ wheels = [ ] [[package]] -name = "pglast" -version = "7.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "setuptools" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/39/4e/f0aac6a336fb52ab37485c5ce530880a78179e55948bb684945de6a22bd8/pglast-7.2.tar.gz", hash = "sha256:c0e9619a58af9323bbf51af8b5472638f1aba3916665f0b6540e4638783172be", size = 3366690, upload-time = "2024-12-21T08:10:54.343Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/fe/75fb9f8e80556effe55ddfc4d225d3bdc2ebe047d84f6321548a259743d2/pglast-7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a046fefb17286e591ed6eaf04500d4ea1f0afa5049f6bacb4f3b66e73eae44c5", size = 1146473, upload-time = "2024-12-21T09:17:26.747Z" }, - { url = "https://files.pythonhosted.org/packages/ee/cf/e93ca2eb500ece9eb09ab8d95c7e6c404ee9a94f3967cef8944d2302474f/pglast-7.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82e56c0007e8de85e5d306557b38cab7e1f7dda3a108f7ccd34419dc9589ba85", size = 1083071, upload-time = "2024-12-21T09:17:30.191Z" }, - { url = "https://files.pythonhosted.org/packages/1d/59/38ea481972978d7ac7bf7c49bb714a46ff2322dbd4f5a5159eb835136b27/pglast-7.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7eb5728b1a2418a5ed3f8549ba8e24d089b99e582e2bf38b229b6d52ffa20ba", size = 5548938, upload-time = "2024-12-21T09:17:32.123Z" }, - { url = "https://files.pythonhosted.org/packages/58/e6/bf739bd61518c4a90ff05dbd456c973b21ca2a7b664d1f9910c1fe4f3088/pglast-7.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2613ad4228165125047706c26925230202d63adc4d5a10cbe8a8896af024ced", size = 5643862, upload-time = "2024-12-21T09:17:38.737Z" }, - { url = "https://files.pythonhosted.org/packages/ba/9f/f6c71865c3c09417de1a1a94664ba83850e83b8bf0780e14b96ea1248154/pglast-7.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a339993019619e2939086a14d4c02cd5e4e3833c17d116ac711e84c40d5b979e", size = 5415528, upload-time = "2024-12-21T09:17:41.054Z" }, - { url = "https://files.pythonhosted.org/packages/35/a5/4f300161cb8105aca7a55bf22b13d0a916116c550e6720db0cebb353fdc5/pglast-7.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:acdb5404ba6bcf5b1cd43a8777a7beb2afd7cba66bbf512559ab8760bf1712c8", size = 5297725, upload-time = "2024-12-21T09:17:44.579Z" }, - { url = "https://files.pythonhosted.org/packages/b2/b9/f1ed29d9d7e26fff80dbe95779c8c8803bc55ee9700c86a802e18a5b8862/pglast-7.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45d5fecada160562fe0c699f5cdd032a37f65a952be52d88dbd50df186886fbf", size = 5253535, upload-time = "2024-12-21T09:17:46.961Z" }, - { url = "https://files.pythonhosted.org/packages/e5/bd/062973b80c42945e6893e42bca19da4435530dc7dc4f92e89f0757b6c00f/pglast-7.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd96731e3f17289896e1a9977b611358a01410947ed85ebfd1a58a7dc18479af", size = 5432734, upload-time = "2024-12-21T09:17:49.825Z" }, - { url = "https://files.pythonhosted.org/packages/44/c9/c671743588e6f06253f7da1bb0a2f5c1dd470fccf19db170f6cfb3be1505/pglast-7.2-cp312-cp312-win32.whl", hash = "sha256:bf70d7d803641a645f9d9c504ac4a59aa9ffa1a282ef6214a8caed3251e994ef", size = 1010386, upload-time = "2024-12-21T09:17:52.552Z" }, - { url = "https://files.pythonhosted.org/packages/be/4b/a4d244211dd3dcfd99c704bb6195d9c5c564da175531b0fb8c844f311b8d/pglast-7.2-cp312-cp312-win_amd64.whl", hash = "sha256:06a5b2d3dd63c44ca71e29fcc0fe162f959708bd348754e420ac1b9332d1d340", size = 1054938, upload-time = "2024-12-21T09:17:54.383Z" }, - { url = "https://files.pythonhosted.org/packages/01/ff/cda3dc03f469c3fa56bc5d14b6420c3ac18bc0a935c9abcb162604fddd9a/pglast-7.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c8c6ce2d315a9d69d2a7cb0b11379ff450c7b29c44f5c488767f6de875dca907", size = 1140994, upload-time = "2024-12-21T09:17:57.617Z" }, - { url = "https://files.pythonhosted.org/packages/dc/f0/90ca159feaf5da2a74372b011084fb1cdb80ca1575f6c2ac36cec2408db8/pglast-7.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cc8fdb2c26b48b8ea9fe14a8e9195988e926f8cc5232833605eff91625e4db0e", size = 1079884, upload-time = "2024-12-21T09:18:00.667Z" }, - { url = "https://files.pythonhosted.org/packages/b9/79/a23b9cf526c82c88b1009e87363ddcb73ea1f9765526040747694850f5de/pglast-7.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f343d59ae674282a627140c3a9794dc86bc2dd43b76fd1202a8915f9cd38bdfd", size = 5552258, upload-time = "2024-12-21T09:18:04.107Z" }, - { url = "https://files.pythonhosted.org/packages/1e/d4/4e088c256f07231b38a9617acd7a29daeac08ec4534b803b801ff3a82f91/pglast-7.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18d228416c7a56ec0f73f35ee5a0a7ce7a58ec0bcaecbe0fe6f1bc388a1401af", size = 5644806, upload-time = "2024-12-21T09:18:07.636Z" }, - { url = "https://files.pythonhosted.org/packages/e9/77/b683afc004a5666c8e31d2a8dd27e57ebc227ccefeb61204fc6599d74f67/pglast-7.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fad462b577021aa91bdfcf16ca32922fed0347ac05ea0de4235b9554a2c7d846", size = 5417808, upload-time = "2024-12-21T09:18:10.57Z" }, - { url = "https://files.pythonhosted.org/packages/55/f8/7bd061ec3eb5d43c752daa60fe90b3c6b3ce1698701529756ba4b89f23bb/pglast-7.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e15f46038e9cd579ffb1ac993cfd961aabcb7d9c61e860c75f6dee4fbabf97fb", size = 5300625, upload-time = "2024-12-21T09:18:14.307Z" }, - { url = "https://files.pythonhosted.org/packages/5d/e1/9a7bfe9f9b6953324dd587135ec2c63150c71f4b38fca220a8c4d7d65951/pglast-7.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b536d7af6a8f820806b6799e0034844d3760c02d9a8764f8453906618ce151bf", size = 5257865, upload-time = "2024-12-21T09:18:16.67Z" }, - { url = "https://files.pythonhosted.org/packages/39/77/70ebfe9cbfc92b609f0b301d5cc3432897acf8f932d6f453f339e00018b0/pglast-7.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ae0e93d74e9779d2a02efa2eee84440736cc367b9d695c1e5735df92540ca4fe", size = 5440924, upload-time = "2024-12-21T09:18:19.754Z" }, - { url = "https://files.pythonhosted.org/packages/ae/1e/6ffb94b259af4cd60fee589c4b68cea2e6401df15f1ff3cd1950e339d71e/pglast-7.2-cp313-cp313-win32.whl", hash = "sha256:b1b940a09b884f8af95e29779d8fd812df0a5e5d5d885f9a4a91105e2395c2e0", size = 1009452, upload-time = "2024-12-21T09:18:21.898Z" }, - { url = "https://files.pythonhosted.org/packages/c5/d5/7c04fb7a2ebbb03b90391c58f876587cbe7073dfb769d0612fb348e37518/pglast-7.2-cp313-cp313-win_amd64.whl", hash = "sha256:56443a3416f83c6eb587d3bc2715e1c2d35e2aa751957a07aa54c0600280ac07", size = 1050476, upload-time = "2024-12-21T09:18:24.397Z" }, -] - -[[package]] -name = "pluggy" -version = "1.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, -] - -[[package]] -name = "postgres-mcp" +name = "pg-mcp" version = "0.3.0" source = { editable = "." } dependencies = [ @@ -651,6 +611,46 @@ dev = [ { name = "ruff", specifier = "==0.11.2" }, ] +[[package]] +name = "pglast" +version = "7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/4e/f0aac6a336fb52ab37485c5ce530880a78179e55948bb684945de6a22bd8/pglast-7.2.tar.gz", hash = "sha256:c0e9619a58af9323bbf51af8b5472638f1aba3916665f0b6540e4638783172be", size = 3366690, upload-time = "2024-12-21T08:10:54.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/fe/75fb9f8e80556effe55ddfc4d225d3bdc2ebe047d84f6321548a259743d2/pglast-7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a046fefb17286e591ed6eaf04500d4ea1f0afa5049f6bacb4f3b66e73eae44c5", size = 1146473, upload-time = "2024-12-21T09:17:26.747Z" }, + { url = "https://files.pythonhosted.org/packages/ee/cf/e93ca2eb500ece9eb09ab8d95c7e6c404ee9a94f3967cef8944d2302474f/pglast-7.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82e56c0007e8de85e5d306557b38cab7e1f7dda3a108f7ccd34419dc9589ba85", size = 1083071, upload-time = "2024-12-21T09:17:30.191Z" }, + { url = "https://files.pythonhosted.org/packages/1d/59/38ea481972978d7ac7bf7c49bb714a46ff2322dbd4f5a5159eb835136b27/pglast-7.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7eb5728b1a2418a5ed3f8549ba8e24d089b99e582e2bf38b229b6d52ffa20ba", size = 5548938, upload-time = "2024-12-21T09:17:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/58/e6/bf739bd61518c4a90ff05dbd456c973b21ca2a7b664d1f9910c1fe4f3088/pglast-7.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2613ad4228165125047706c26925230202d63adc4d5a10cbe8a8896af024ced", size = 5643862, upload-time = "2024-12-21T09:17:38.737Z" }, + { url = "https://files.pythonhosted.org/packages/ba/9f/f6c71865c3c09417de1a1a94664ba83850e83b8bf0780e14b96ea1248154/pglast-7.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a339993019619e2939086a14d4c02cd5e4e3833c17d116ac711e84c40d5b979e", size = 5415528, upload-time = "2024-12-21T09:17:41.054Z" }, + { url = "https://files.pythonhosted.org/packages/35/a5/4f300161cb8105aca7a55bf22b13d0a916116c550e6720db0cebb353fdc5/pglast-7.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:acdb5404ba6bcf5b1cd43a8777a7beb2afd7cba66bbf512559ab8760bf1712c8", size = 5297725, upload-time = "2024-12-21T09:17:44.579Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b9/f1ed29d9d7e26fff80dbe95779c8c8803bc55ee9700c86a802e18a5b8862/pglast-7.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45d5fecada160562fe0c699f5cdd032a37f65a952be52d88dbd50df186886fbf", size = 5253535, upload-time = "2024-12-21T09:17:46.961Z" }, + { url = "https://files.pythonhosted.org/packages/e5/bd/062973b80c42945e6893e42bca19da4435530dc7dc4f92e89f0757b6c00f/pglast-7.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd96731e3f17289896e1a9977b611358a01410947ed85ebfd1a58a7dc18479af", size = 5432734, upload-time = "2024-12-21T09:17:49.825Z" }, + { url = "https://files.pythonhosted.org/packages/44/c9/c671743588e6f06253f7da1bb0a2f5c1dd470fccf19db170f6cfb3be1505/pglast-7.2-cp312-cp312-win32.whl", hash = "sha256:bf70d7d803641a645f9d9c504ac4a59aa9ffa1a282ef6214a8caed3251e994ef", size = 1010386, upload-time = "2024-12-21T09:17:52.552Z" }, + { url = "https://files.pythonhosted.org/packages/be/4b/a4d244211dd3dcfd99c704bb6195d9c5c564da175531b0fb8c844f311b8d/pglast-7.2-cp312-cp312-win_amd64.whl", hash = "sha256:06a5b2d3dd63c44ca71e29fcc0fe162f959708bd348754e420ac1b9332d1d340", size = 1054938, upload-time = "2024-12-21T09:17:54.383Z" }, + { url = "https://files.pythonhosted.org/packages/01/ff/cda3dc03f469c3fa56bc5d14b6420c3ac18bc0a935c9abcb162604fddd9a/pglast-7.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c8c6ce2d315a9d69d2a7cb0b11379ff450c7b29c44f5c488767f6de875dca907", size = 1140994, upload-time = "2024-12-21T09:17:57.617Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f0/90ca159feaf5da2a74372b011084fb1cdb80ca1575f6c2ac36cec2408db8/pglast-7.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cc8fdb2c26b48b8ea9fe14a8e9195988e926f8cc5232833605eff91625e4db0e", size = 1079884, upload-time = "2024-12-21T09:18:00.667Z" }, + { url = "https://files.pythonhosted.org/packages/b9/79/a23b9cf526c82c88b1009e87363ddcb73ea1f9765526040747694850f5de/pglast-7.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f343d59ae674282a627140c3a9794dc86bc2dd43b76fd1202a8915f9cd38bdfd", size = 5552258, upload-time = "2024-12-21T09:18:04.107Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d4/4e088c256f07231b38a9617acd7a29daeac08ec4534b803b801ff3a82f91/pglast-7.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18d228416c7a56ec0f73f35ee5a0a7ce7a58ec0bcaecbe0fe6f1bc388a1401af", size = 5644806, upload-time = "2024-12-21T09:18:07.636Z" }, + { url = "https://files.pythonhosted.org/packages/e9/77/b683afc004a5666c8e31d2a8dd27e57ebc227ccefeb61204fc6599d74f67/pglast-7.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fad462b577021aa91bdfcf16ca32922fed0347ac05ea0de4235b9554a2c7d846", size = 5417808, upload-time = "2024-12-21T09:18:10.57Z" }, + { url = "https://files.pythonhosted.org/packages/55/f8/7bd061ec3eb5d43c752daa60fe90b3c6b3ce1698701529756ba4b89f23bb/pglast-7.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e15f46038e9cd579ffb1ac993cfd961aabcb7d9c61e860c75f6dee4fbabf97fb", size = 5300625, upload-time = "2024-12-21T09:18:14.307Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e1/9a7bfe9f9b6953324dd587135ec2c63150c71f4b38fca220a8c4d7d65951/pglast-7.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b536d7af6a8f820806b6799e0034844d3760c02d9a8764f8453906618ce151bf", size = 5257865, upload-time = "2024-12-21T09:18:16.67Z" }, + { url = "https://files.pythonhosted.org/packages/39/77/70ebfe9cbfc92b609f0b301d5cc3432897acf8f932d6f453f339e00018b0/pglast-7.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ae0e93d74e9779d2a02efa2eee84440736cc367b9d695c1e5735df92540ca4fe", size = 5440924, upload-time = "2024-12-21T09:18:19.754Z" }, + { url = "https://files.pythonhosted.org/packages/ae/1e/6ffb94b259af4cd60fee589c4b68cea2e6401df15f1ff3cd1950e339d71e/pglast-7.2-cp313-cp313-win32.whl", hash = "sha256:b1b940a09b884f8af95e29779d8fd812df0a5e5d5d885f9a4a91105e2395c2e0", size = 1009452, upload-time = "2024-12-21T09:18:21.898Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d5/7c04fb7a2ebbb03b90391c58f876587cbe7073dfb769d0612fb348e37518/pglast-7.2-cp313-cp313-win_amd64.whl", hash = "sha256:56443a3416f83c6eb587d3bc2715e1c2d35e2aa751957a07aa54c0600280ac07", size = 1050476, upload-time = "2024-12-21T09:18:24.397Z" }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, +] + [[package]] name = "propcache" version = "0.3.1" From a66063b3e3b1d3238119a3dba10bd32916fb4e94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20C=2E=20Andersen?= Date: Sat, 18 Oct 2025 19:18:34 +0200 Subject: [PATCH 07/14] Update LICENSE and README. --- LICENSE | 1 + README.md | 362 +++++------------------------------------------------- 2 files changed, 31 insertions(+), 332 deletions(-) diff --git a/LICENSE b/LICENSE index 49eef586..0882c8ba 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License Copyright (c) 2025, Crystal Corp. +Copyright (c) 2025, André C. Andersen Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index cdaa7bc8..2f4a285d 100644 --- a/README.md +++ b/README.md @@ -8,55 +8,27 @@ [![Twitter Follow](https://img.shields.io/twitter/follow/auto_dba?style=flat)](https://x.com/auto_dba) [![Contributors](https://img.shields.io/github/contributors/crystaldba/pg-mcp)](https://github.com/crystaldba/pg-mcp/graphs/contributors) -

A Postgres MCP server with index tuning, explain plans, health checks, and safe sql execution.

+

A lightweight Postgres MCP server for schema exploration and SQL execution.

## Overview -**Postgres MCP Lite** is an open source Model Context Protocol (MCP) server built to support you and your AI agents throughout the **entire development process**—from initial coding, through testing and deployment, and to production tuning and maintenance. - -Postgres MCP Lite does much more than wrap a database connection. - -Features include: - -- **🔍 Database Health** - analyze index health, connection utilization, buffer cache, vacuum health, sequence limits, replication lag, and more. -- **⚡ Index Tuning** - explore thousands of possible indexes to find the best solution for your workload, using industrial-strength algorithms. -- **📈 Query Plans** - validate and optimize performance by reviewing EXPLAIN plans and simulating the impact of hypothetical indexes. -- **🧠 Schema Intelligence** - context-aware SQL generation based on detailed understanding of the database schema. -- **🛡️ Safe SQL Execution** - configurable access control, including support for read-only mode and safe SQL parsing, making it usable for both development and production. - -Postgres MCP Lite supports both the [Standard Input/Output (stdio)](https://modelcontextprotocol.io/docs/concepts/transports#standard-input%2Foutput-stdio) and [Server-Sent Events (SSE)](https://modelcontextprotocol.io/docs/concepts/transports#server-sent-events-sse) transports, for flexibility in different environments. - -For additional background on why we built Postgres MCP Lite, see [our launch blog post](https://www.crystaldba.ai/blog/post/announcing-pg-mcp-server-pro). - -## Demo - -*From Unusable to Lightning Fast* -- **Challenge:** We generated a movie app using an AI assistant, but the SQLAlchemy ORM code ran painfully slow. -- **Solution:** Using Postgres MCP Lite with Cursor, we fixed the performance issues in minutes. - -What we did: -- 🚀 Fixed performance - including ORM queries, indexing, and caching -- 🛠️ Fixed a broken page - by prompting the agent to explore the data, fix queries, and add related content. -- 🧠 Improved the top movies - by exploring the data and fixing the ORM query to surface more relevant results. - -See the video below or read the [play-by-play](examples/movie-app.md). - -https://github.com/user-attachments/assets/24e05745-65e9-4998-b877-a368f1eadc13 - +**Postgres MCP Lite** is a lightweight, open-source Model Context Protocol (MCP) server for PostgreSQL. It provides AI assistants with essential database access: schema exploration and SQL execution. +This is a stripped-down version focused on core functionality: +- **🗂️ Schema Exploration** - List schemas, tables, views, and get detailed object information including columns, constraints, and indexes. +- **⚡ SQL Execution** - Execute SQL queries with configurable access control. +- **🛡️ Safe SQL Execution** - Read-only mode with SQL parsing validation for production environments. +- **🔌 Multiple Transports** - Supports both [stdio](https://modelcontextprotocol.io/docs/concepts/transports#standard-input%2Foutput-stdio) and [SSE](https://modelcontextprotocol.io/docs/concepts/transports#server-sent-events-sse) transports. ## Quick Start @@ -245,56 +217,27 @@ For Windsurf, the format in `mcp_config.json` is slightly different: } ``` -## Postgres Extension Installation (Optional) - -To enable index tuning and comprehensive performance analysis you need to load the `pg_statements` and `hypopg` extensions on your database. - -- The `pg_statements` extension allows Postgres MCP Lite to analyze query execution statistics. -For example, this allows it to understand which queries are running slow or consuming significant resources. -- The `hypopg` extension allows Postgres MCP Lite to simulate the behavior of the Postgres query planner after adding indexes. - -### Installing extensions on AWS RDS, Azure SQL, or Google Cloud SQL - -If your Postgres database is running on a cloud provider managed service, the `pg_statements` and `hypopg` extensions should already be available on the system. -In this case, you can just run `CREATE EXTENSION` commands using a role with sufficient privileges: - -```sql -CREATE EXTENSION IF NOT EXISTS pg_statements; -CREATE EXTENSION IF NOT EXISTS hypopg; -``` - -### Installing extensions on self-managed Postgres - -If you are managing your own Postgres installation, you may need to do additional work. -Before loading the `pg_statements` extension you must ensure that it is listed in the `shared_preload_libraries` in the Postgres configuration file. -The `hypopg` extension may also require additional system-level installation (e.g., via your package manager) because it does not always ship with Postgres. - ## Usage Examples -### Get Database Health Overview +### Explore Database Schema Ask: -> Check the health of my database and identify any issues. +> Show me all the tables in the database and their structure. -### Analyze Slow Queries +### Generate SQL Queries Ask: -> What are the slowest queries in my database? And how can I speed them up? +> Write a query to find all orders from the past month with their customer details. -### Get Recommendations On How To Speed Things Up +### Analyze Table Structure Ask: -> My app is slow. How can I make it faster? +> What indexes exist on the orders table and what columns do they cover? -### Generate Index Recommendations +### Execute Data Queries Ask: -> Analyze my database workload and suggest indexes to improve performance. - -### Optimize a Specific Query - -Ask: -> Help me optimize this query: SELECT \* FROM orders JOIN customers ON orders.customer_id = customers.id WHERE orders.created_at > '2023-01-01'; +> Show me the top 10 customers by order count in 2024. ## MCP Server API @@ -305,287 +248,42 @@ We chose this approach because the [MCP client ecosystem](https://modelcontextpr This contrasts with the approach of other Postgres MCP servers, including the [Reference Postgres MCP Server](https://github.com/modelcontextprotocol/servers/tree/main/src/postgres), which use [MCP resources](https://modelcontextprotocol.io/docs/concepts/resources) to expose schema information. -Postgres MCP Lite Tools: +Postgres MCP Lite provides 4 essential tools: | Tool Name | Description | |-----------|-------------| | `list_schemas` | Lists all database schemas available in the PostgreSQL instance. | | `list_objects` | Lists database objects (tables, views, sequences, extensions) within a specified schema. | -| `get_object_details` | Provides information about a specific database object, for example, a table's columns, constraints, and indexes. | +| `get_object_details` | Provides detailed information about a specific database object, including columns, constraints, and indexes. | | `execute_sql` | Executes SQL statements on the database, with read-only limitations when connected in restricted mode. | -| `explain_query` | Gets the execution plan for a SQL query describing how PostgreSQL will process it and exposing the query planner's cost model. Can be invoked with hypothetical indexes to simulate the behavior after adding indexes. | -| `get_top_queries` | Reports the slowest SQL queries based on total execution time using `pg_stat_statements` data. | -| `analyze_workload_indexes` | Analyzes the database workload to identify resource-intensive queries, then recommends optimal indexes for them. | -| `analyze_query_indexes` | Analyzes a list of specific SQL queries (up to 10) and recommends optimal indexes for them. | -| `analyze_db_health` | Performs comprehensive health checks including: buffer cache hit rates, connection health, constraint validation, index health (duplicate/unused/invalid), sequence limits, and vacuum health. | ## Related Projects -**Postgres MCP Servers** -- [Query MCP](https://github.com/alexander-zuev/supabase-mcp-server). An MCP server for Supabase Postgres with a three-tier safety architecture and Supabase management API support. -- [PG-MCP](https://github.com/stuzero/pg-mcp-server). An MCP server for PostgreSQL with flexible connection options, explain plans, extension context, and more. -- [Reference PostgreSQL MCP Server](https://github.com/modelcontextprotocol/servers/tree/main/src/postgres). A simple MCP Server implementation exposing schema information as MCP resources and executing read-only queries. -- [Supabase Postgres MCP Server](https://github.com/supabase-community/supabase-mcp). This MCP Server provides Supabase management features and is actively maintained by the Supabase community. -- [Nile MCP Server](https://github.com/niledatabase/nile-mcp-server). An MCP server providing access to the management API for the Nile's multi-tenant Postgres service. -- [Neon MCP Server](https://github.com/neondatabase-labs/mcp-server-neon). An MCP server providing access to the management API for Neon's serverless Postgres service. -- [Wren MCP Server](https://github.com/Canner/wren-engine). Provides a semantic engine powering business intelligence for Postgres and other databases. - -**DBA Tools (including commercial offerings)** -- [Aiven Database Optimizer](https://aiven.io/solutions/aiven-ai-database-optimizer). A tool that provides holistic database workload analysis, query optimizations, and other performance improvements. -- [dba.ai](https://www.dba.ai/). An AI-powered database administration assistant that integrates with GitHub to resolve code issues. -- [pgAnalyze](https://pganalyze.com/). A comprehensive monitoring and analytics platform for identifying performance bottlenecks, optimizing queries, and real-time alerting. -- [Postgres.ai](https://postgres.ai/). An interactive chat experience combining an extensive Postgres knowledge base and GPT-4. -- [Xata Agent](https://github.com/xataio/agent). An open-source AI agent that automatically monitors database health, diagnoses issues, and provides recommendations using LLM-powered reasoning and playbooks. - -**Postgres Utilities** -- [Dexter](https://github.com/DexterDB/dexter). A tool for generating and testing hypothetical indexes on PostgreSQL. -- [PgHero](https://github.com/ankane/pghero). A performance dashboard for Postgres, with recommendations. -Postgres MCP Lite incorporates health checks from PgHero. -- [PgTune](https://github.com/le0pard/pgtune?tab=readme-ov-file). Heuristics for tuning Postgres configuration. - -## Frequently Asked Questions - -*How is Postgres MCP Lite different from other Postgres MCP servers?* -There are many MCP servers allow an AI agent to run queries against a Postgres database. -Postgres MCP Lite does that too, but also adds tools for understanding and improving the performance of your Postgres database. -For example, it implements a version of the [Anytime Algorithm of Database Tuning Advisor for Microsoft SQL Server](https://www.microsoft.com/en-us/research/wp-content/uploads/2020/06/Anytime-Algorithm-of-Database-Tuning-Advisor-for-Microsoft-SQL-Server.pdf), a modern industrial-strength algorithm for automatic index tuning. - -| Postgres MCP Lite | Other Postgres MCP Servers | -|--------------|----------------------------| -| ✅ Deterministic database health checks | ❌ Unrepeatable LLM-generated health queries | -| ✅ Principled indexing search strategies | ❌ Gen-AI guesses at indexing improvements | -| ✅ Workload analysis to find top problems | ❌ Inconsistent problem analysis | -| ✅ Simulates performance improvements | ❌ Try it yourself and see if it works | - -Postgres MCP Lite complements generative AI by adding deterministic tools and classical optimization algorithms -The combination is both reliable and flexible. - - -*Why are MCP tools needed when the LLM can reason, generate SQL, etc?* -LLMs are invaluable for tasks that involve ambiguity, reasoning, or natural language. -When compared to procedural code, however, they can be slow, expensive, non-deterministic, and sometimes produce unreliable results. -In the case of database tuning, we have well established algorithms, developed over decades, that are proven to work. -Postgres MCP Lite lets you combine the best of both worlds by pairing LLMs with classical optimization algorithms and other procedural tools. - -*How do you test Postgres MCP Lite?* -Testing is critical to ensuring that Postgres MCP Lite is reliable and accurate. -We are building out a suite of AI-generated adversarial workloads designed to challenge Postgres MCP Lite and ensure it performs under a broad variety of scenarios. - -*What Postgres versions are supported?* -Our testing presently focuses on Postgres 15, 16, and 17. -We plan to support Postgres versions 13 through 17. - -*Who created this project?* -This project is created and maintained by [Crystal DBA](https://www.crystaldba.ai). - -## Roadmap - -*TBD* - -You and your needs are a critical driver for what we build. -Tell us what you want to see by opening an [issue](https://github.com/crystaldba/pg-mcp/issues) or a [pull request](https://github.com/crystaldba/pg-mcp/pulls). -You can also contact us on [Discord](https://discord.gg/4BEHC7ZM). +**Other Postgres MCP Servers** +- [Reference PostgreSQL MCP Server](https://github.com/modelcontextprotocol/servers/tree/main/src/postgres) - Official reference implementation +- [PG-MCP](https://github.com/stuzero/pg-mcp-server) - Feature-rich PostgreSQL MCP server +- [Supabase Postgres MCP Server](https://github.com/supabase-community/supabase-mcp) - Supabase integration +- [Query MCP](https://github.com/alexander-zuev/supabase-mcp-server) - Three-tier safety architecture ## Technical Notes -This section includes a high-level overview technical considerations that influenced the design of Postgres MCP Lite. - -### Index Tuning - -Developers know that missing indexes are one of the most common causes of database performance issues. -Indexes provide access methods that allow Postgres to quickly locate data that is required to execute a query. -When tables are small, indexes make little difference, but as the size of the data grows, the difference in algorithmic complexity between a table scan and an index lookup becomes significant (typically *O*(*n*) vs *O*(*log* *n*), potentially more if joins on multiple tables are involved). - -Generating suggested indexes in Postgres MCP Lite proceeds in several stages: - -1. *Identify SQL queries in need of tuning*. - If you know you are having a problem with a specific SQL query you can provide it. - Postgres MCP Lite can also analyze the workload to identify index tuning targets. - To do this, it relies on the `pg_stat_statements` extension, which records the runtime and resource consumption of each query. - - A query is a candidate for index tuning if it is a top resource consumer, either on a per-execution basis or in aggregate. - At present, we use execution time as a proxy for cumulative resource consumption, but it may also make sense to look at specifics resources, e.g., the number of blocks accessed or the number of blocks read from disk. - The `analyze_query_workload` tool focuses on slow queries, using the mean time per execution with thresholds for execution count and mean execution time. - Agents may also call `get_top_queries`, which accepts a parameter for mean vs. total execution time, then pass these queries `analyze_query_indexes` to get index recommendations. - - Sophisticated index tuning systems use "workload compression" to produce a representative subset of queries that reflects the characteristics of the workload as a whole, reducing the problem for downstream algorithms. - Postgres MCP Lite performs a limited form of workload compression by normalizing queries so that those generated from the same template appear as one. - It weights each query equally, a simplification that works when the benefits to indexing are large. - -2. *Generate candidate indexes* - Once we have a list of SQL queries that we want to improve through indexing, we generate a list of indexes that we might want to add. - To do this, we parse the SQL and identify any columns used in filters, joins, grouping, or sorting. - - To generate all possible indexes we need to consider combinations of these columns, because Postgres supports [multicolumn indexes](https://www.postgresql.org/docs/current/indexes-multicolumn.html). - In the present implementation, we include only one permutation of each possible multicolumn index, which is selected at random. - We make this simplification to reduce the search space because permutations often have equivalent performance. - However, we hope to improve in this area. - -3. *Search for the optimal index configuration*. - Our objective is to find the combination of indexes that optimally balances the performance benefits against the costs of storing and maintaining those indexes. - We estimate the performance improvement by using the "what if?" capabilities provided by the `hypopg` extension. - This simulates how the Postgres query optimizer will execute a query after the addition of indexes, and reports changes based on the actual Postgres cost model. - - One challenge is that generating query plans generally requires knowledge of the specific parameter values used in the query. - Query normalization, which is necessary to reduce the queries under consideration, removes parameter constants. - Parameter values provided via bind variables are similarly not available to us. - - To address this problem, we produce realistic constants that we can provide as parameters by sampling from the table statistics. - In version 16, Postgres added [generic explain plan functionality](https://www.postgresql.org/docs/current/sql-explain.html), but it has limitations, for example around `LIKE` clauses, which our implementation does not have. - - Search strategy is critical because evaluating all possible index combinations feasible only in simple situations. - This is what most sets apart various indexing approaches. - Adapting the approach of Microsoft's Anytime algorithm, we employ a greedy search strategy, i.e., find the best one-index solution, then find the best index to add to that to produce a two-index solution. - Our search terminates when the time budget is exhausted or when a round of exploration fails to produce any gains above the minimum improvement threshold of 10%. - -4. *Cost-benefit analysis*. - When posed with two indexing alternatives, one which produces better performance and one which requires more space, how do we decide which to choose? - Traditionally, index advisors ask for a storage budget and optimize performance with respect to that storage budget. - We also take a storage budget, but perform a cost-benefit analysis throughout the optimization. - - We frame this as the problem of selecting a point along the [Pareto front](https://en.wikipedia.org/wiki/Pareto_front)—the set of choices for which improving one quality metric necessarily worsens another. - In an ideal world, we might want to assess the cost of the storage and the benefit of improved performance in monetary terms. - However, there is a simpler and more practical approach: to look at the changes in relative terms. - Most people would agree that a 100x performance improvement is worth it, even if the storage cost is 2x. - In our implementation, we use a configurable parameter to set this threshold. - By default, we require the change in the log (base 10) of the performance improvement to be 2x the difference in the log of the space cost. - This works out to allowing a maximum 10x increase in space for a 100x performance improvement. - -Our implementation is most closely related to the [Anytime Algorithm](https://www.microsoft.com/en-us/research/wp-content/uploads/2020/06/Anytime-Algorithm-of-Database-Tuning-Advisor-for-Microsoft-SQL-Server.pdf) found in Microsoft SQL Server. -Compared to [Dexter](https://github.com/ankane/dexter/), an automatic indexing tool for Postgres, we search a larger space and use different heuristics. -This allows us to generate better solutions at the cost of longer runtime. - -We also show the work done in each round of the search, including a comparison of the query plans before and after the addition of each index. -This give the LLM additional context that it can use when responding to the indexing recommendations. - -### Experimental: Index Tuning by LLM - -Postgres MCP Lite includes an experimental index tuning feature based on [Optimization by LLM](https://arxiv.org/abs/2309.03409). -Instead of using heuristics to explore possible index configurations, we provide the database schema and query plans to an LLM and ask it to propose index configurations. -We then use `hypopg` to predict performance with the proposed indexes, then feed those results back into the LLM to produce a new set of suggestions. -We repeat this process until multiple rounds of iteration produce no further improvements. - -Index optimization by LLM is has advantages when the index search space is large, or when indexes with many columns need to be considered. -Like traditional search-based approaches, it relies on the accuracy of the `hypopg` performance predictions. - -In order to perform index optimization by LLM, you must provide an OpenAI API key by setting the `OPENAI_API_KEY` environment variable. - - -### Database Health - -Database health checks identify tuning opportunities and maintenance needs before they lead to critical issues. -In the present release, Postgres MCP Lite adapts the database health checks directly from [PgHero](https://github.com/ankane/pghero). -We are working to fully validate these checks and may extend them in the future. - -- *Index Health*. Looks for unused indexes, duplicate indexes, and indexes that are bloated. Bloated indexes make inefficient use of database pages. - Postgres autovacuum cleans up index entries pointing to dead tuples, and marks the entries as reusable. However, it does not compact the index pages and, eventually, index pages may contain few live tuple references. -- *Buffer Cache Hit Rate*. Measures the proportion of database reads that are served from the buffer cache instead of disk. - A low buffer cache hit rate must be investigated as it is often not cost-optimal and leads to degraded application performance. -- *Connection Health*. Checks the number of connections to the database and reports on their utilization. - The biggest risk is running out of connections, but a high number of idle or blocked connections can also indicate issues. -- *Vacuum Health*. Vacuum is important for many reasons. - A critical one is preventing transaction id wraparound, which can cause the database to stop accepting writes. - The Postgres multi-version concurrency control (MVCC) mechanism requires a unique transaction id for each transaction. - However, because Postgres uses a 32-bit signed integer for transaction ids, it needs to reuse transaction ids after after a maximum of 2 billion transactions. - To do this it "freezes" the transaction ids of historical transactions, setting them all to a special value that indicates distant past. - When records first go to disk, they are written visibility for a range of transaction ids. - Before re-using these transaction ids, Postgres must update any on-disk records, "freezing" them to remove the references to the transaction ids to be reused. - This check looks for tables that require vacuuming to prevent transaction id wraparound. -- *Replication Health*. Checks replication health by monitoring lag between primary and replicas, verifying replication status, and tracking usage of replication slots. -- *Constraint Health*. During normal operation, Postgres rejects any transactions that would cause a constraint violation. - However, invalid constraints may occur after loading data or in recovery scenarios. This check looks for any invalid constraints. -- *Sequence Health*. Looks for sequences that are at risk of exceeding their maximum value. - - ### Postgres Client Library -Postgres MCP Lite uses [psycopg3](https://www.psycopg.org/) to connect to Postgres using asynchronous I/O. -Under the hood, psycopg3 uses the [libpq](https://www.postgresql.org/docs/current/libpq.html) library to connect to Postgres, providing access to the full Postgres feature set and an underlying implementation fully supported by the Postgres community. - -Some other Python-based MCP servers use [asyncpg](https://github.com/MagicStack/asyncpg), which may simplify installation by eliminating the `libpq` dependency. -Asyncpg is also probably [faster](https://fernandoarteaga.dev/blog/psycopg-vs-asyncpg/) than psycopg3, but we have not validated this ourselves. -[Older benchmarks](https://gistpreview.github.io/?0ed296e93523831ea0918d42dd1258c2) report a larger performance gap, suggesting that the newer psycopg3 has closed the gap as it matures. - -Balancing these considerations, we selected `psycopg3` over `asyncpg`. -We remain open to revising this decision in the future. - - -### Connection Configuration - -Like the [Reference PostgreSQL MCP Server](https://github.com/modelcontextprotocol/servers/tree/main/src/postgres), Postgres MCP Lite takes Postgres connection information at startup. -This is convenient for users who always connect to the same database but can be cumbersome when users switch databases. - -An alternative approach, taken by [PG-MCP](https://github.com/stuzero/pg-mcp-server), is provide connection details via MCP tool calls at the time of use. -This is more convenient for users who switch databases, and allows a single MCP server to simultaneously support multiple end-users. - -There must be a better approach than either of these. -Both have security weaknesses—few MCP clients store the MCP server configuration securely (an exception is Goose), and credentials provided via MCP tools are passed through the LLM and stored in the chat history. -Both also have usability issues in some scenarios. - - -### Schema Information - -The purpose of the schema information tool is to provide the calling AI agent with the information it needs to generate correct and performant SQL. -For example, suppose a user asks, "How many flights took off from San Francisco and landed in Paris during the past year?" -The AI agent needs to find the table that stores the flights, the columns that store the origin and destinations, and perhaps a table that maps between airport codes and airport locations. - - -*Why provide schema information tools when LLMs are generally capable of generating the SQL to retrieve this information from Postgres directly?* - -Our experience using Claude indicates that the calling LLM is very good at generating SQL to explore the Postgres schema by querying the [Postgres system catalog](https://www.postgresql.org/docs/current/catalogs.html) and the [information schema](https://www.postgresql.org/docs/current/information-schema.html) (an ANSI-standardized database metadata view). -However, we do not know whether other LLMs do so as reliably and capably. - -*Would it be better to provide schema information using [MCP resources](https://modelcontextprotocol.io/docs/concepts/resources) rather than [MCP tools](https://modelcontextprotocol.io/docs/concepts/tools)?* - -The [Reference PostgreSQL MCP Server](https://github.com/modelcontextprotocol/servers/tree/main/src/postgres) uses resources to expose schema information rather than tools. -Navigating resources is similar to navigating a file system, so this approach is natural in many ways. -However, resource support is less widespread than tool support in the MCP client ecosystem (see [example clients](https://modelcontextprotocol.io/clients)). -In addition, while the MCP standard says that resources can be accessed by either AI agents or end-user humans, some clients only support human navigation of the resource tree. - +Postgres MCP Lite uses [psycopg3](https://www.psycopg.org/) for asynchronous database connectivity. It leverages [libpq](https://www.postgresql.org/docs/current/libpq.html) for full Postgres feature support. ### Protected SQL Execution -AI amplifies longstanding challenges of protecting databases from a range of threats, ranging from simple mistakes to sophisticated attacks by malicious actors. -Whether the threat is accidental or malicious, a similar security framework applies, with aims that fall into three categories: confidentiality, integrity, and availability. -The familiar tension between convenience and safety is also evident and pronounced. - -Postgres MCP Lite's protected SQL execution mode focuses on integrity. -In the context of MCP, we are most concerned with LLM-generated SQL causing damage—for example, unintended data modification or deletion, or other changes that might circumvent an organization's change management process. - -The simplest way to provide integrity is to ensure that all SQL executed against the database is read-only. -One way to do this is by creating a database user with read-only access permissions. -While this is a good approach, many find this cumbersome in practice. -Postgres does not provide a way to place a connection or session into read-only mode, so Postgres MCP Lite uses a more complex approach to ensure read-only SQL execution on top of a read-write connection. +Postgres MCP Lite provides two access modes: -Postgres MCP Litevides a read-only transaction mode that prevents data and schema modifications. -Like the [Reference PostgreSQL MCP Server](https://github.com/modelcontextprotocol/servers/tree/main/src/postgres), we use read-only transactions to provide protected SQL execution. +- **Unrestricted Mode**: Full read/write access, suitable for development environments +- **Restricted Mode**: Read-only transactions with execution time limits, suitable for production -To make this mechanism robust, we need to ensure that the SQL does not somehow circumvent the read-only transaction mode, say by issuing a `COMMIT` or `ROLLBACK` statement and then beginning a new transaction. +In restricted mode, SQL is parsed using [pglast](https://pglast.readthedocs.io/) to prevent transaction control statements that could circumvent read-only protections. All queries execute within read-only transactions and are automatically rolled back. -For example, the LLM can circumvent the read-only transaction mode by issuing a `ROLLBACK` statement and then beginning a new transaction. -For example: -```sql -ROLLBACK; DROP TABLE users; -``` - -To prevent cases like this, we parse the SQL before execution using the [pglast](https://pglast.readthedocs.io/) library. -We reject any SQL that contains `commit` or `rollback` statements. -Helpfully, the popular Postgres stored procedure languages, including PL/pgSQL and PL/Python, do not allow for `COMMIT` or `ROLLBACK` statements. -If you have unsafe stored procedure languages enabled on your database, then our read-only protections could be circumvented. - -At present, Postgres MCP Lite provides two levels of protection for the database, one at either extreme of the convenience/safety spectrum. -- "Unrestricted" provides maximum flexibility. -It is suitable for development environments where speed and flexibility are paramount, and where there is no need to protect valuable or sensitive data. -- "Restricted" provides a balance between flexibility and safety. -It is suitable for production environments where the database is exposed to untrusted users, and where it is important to protect valuable or sensitive data. - -Unrestricted mode aligns with the approach of [Cursor's auto-run mode](https://docs.cursor.com/chat/tools#auto-run), where the AI agent operates with limited human oversight or approvals. -We expect auto-run to be deployed in development environments where the consequences of mistakes are low, where databases do not contain valuable or sensitive data, and where they can be recreated or restored from backups when needed. +### Schema Information -We designed restricted mode to be conservative, erring on the side of safety even though it may be inconvenient. -Restricted mode is limited to read-only operations, and we limit query execution time to prevent long-running queries from impacting system performance. -We may add measures in the future to make sure that restricted mode is safe to use with production databases. +Schema tools provide AI agents with the information needed to generate correct SQL. While LLMs can query Postgres system catalogs directly, dedicated tools ensure consistent, reliable schema exploration across different LLM capabilities. ## Postgres MCP Lite Development From 09f79e75cc484a2f1bcf9e9fb60a41e36f62ac24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20C=2E=20Andersen?= Date: Sat, 18 Oct 2025 19:18:39 +0200 Subject: [PATCH 08/14] Removes movie-app example documentation --- examples/movie-app.md | 130 ------------------------------------------ 1 file changed, 130 deletions(-) delete mode 100644 examples/movie-app.md diff --git a/examples/movie-app.md b/examples/movie-app.md deleted file mode 100644 index 5eee0137..00000000 --- a/examples/movie-app.md +++ /dev/null @@ -1,130 +0,0 @@ -## Movie Ratings Website - -Let's do a quick AI-coding session and take an idea from concept to launch! - -We'll use the [IMDB dataset](https://developer.imdb.com/non-commercial-datasets/) to build a movie ratings website. - -**Our AI tools:** -- **Replit** - for the initial prototype -- **Cursor** - as our AI coding agent -- **Postgres Pro** - to give Cursor a Postgres expert - -**What we did:** -1) Create the initial app on Replit - it's slow! -2) Fixed performance - including ORM queries, indexing, and caching -3) Fixed an empty movie details pages -4) Improved the sort for top-rated movies - -**Full Video** - -*(play-by-play walkthrough is below)* - -https://github.com/user-attachments/assets/24e05745-65e9-4998-b877-a368f1eadc13 - ---- - -**Let's get started...** - - - - - - - - - - - - - - - - - - - - -
-

1) Create the initial app on Replit

-

We prompt Replit with:

-
-

Create a web app based on flask, python and SQAlchemy ORM

-

It's website that uses the schema from the public IMDB dataset . Assume I've imported the IMDB dataset as-is and add to that. I want people to be able to browse a mobile-friendly page for each movie, with all the IMDB data related to that movie. Additionally, people can rate each movie 1-5 and view top rated movies. The community and these ratings are one of the primary uses cases for the website.

-
-

Boom! We have a fully functional website with ratings, search, browse, auth -- in under an hour. What!! So cool.

-

But it's slooooow...
- The AI agent created a bunch of ORM code and what looked like reasonable indexes, but clearly it got it wrong.

-
-

2) Fix query performance

-

Our website looks decent, but it's too slow to ship.
- Let's switch to Cursor w/ Postgres Pro to get the app ready for launch.

-

Our prompt:

-
-
My app is slow!
-
- Look for opportunities to speed up by improving queries, indexes or caching.
-
- For db changes use migration scripts I can apply later.
-
-

Let's see what all the AI agent did.

-
    -
  1. Explored the schema and code to identify potential problem queries
  2. -
  3. Used Postgres Pro to diagnose by calling get_top_queries, analyze_db_health, and analyze_query_indexes
  4. -
  5. Added multiple indexes to improve query performance
  6. -
  7. Remove unused and bloated indexes to reclaim space
  8. -
  9. Added caching for expensive queries and image loading
  10. -
  11. Created a migration script to apply the changes
  12. -
-

That was amazing! The agent was able to connect the dots between the database analysis and the code to create a comprehensive PR in 2.5 minutes.

-
It summarized the expected impact:
-
    -
  • Text searches will be 10-100x faster
  • -
  • Page loads will be 2-5x faster
  • -
  • Database load will be significantly reduced
  • -
  • External API calls will be reduced by ~90%
  • -
-
-

3) Fix empty movie details pages

-

The movie details looks empty. Let's investigate.

-
-
The movie details page looks awful.
-
- no cast/crew. Are we missing the data or is the query wrong?
-
- The ratings looks misplaced. move it closer to the title
-
- Do we have additional data we can include like a description? Check the schema.
-
-
The result?
-
    -
  1. It used Postgres Pro to inspect the schema and compare it against the code.
  2. -
  3. It fixed the query in the route to join with name_basics.
  4. -
  5. It identified additional data in title_basics - to create a new About section with genre, runtime, and release years.
  6. -
- Let's ask: -
Am I missing any data?
-

The AI Agent runs the sql queries and figures out we are indeed missing the cast/crew data. It writes a script to import it in a more reliable way.

-
(it turned out my original script aborted on errors)

-
- -
-

4) Improve the sort for top-rated movies

-

The top-rated page is showing the classics like "Sisters of the Shrink 4" and "Zhuchok", etc. Something is wrong!

-
-
How are the top-rated sorted? It seems random. - Do we have data in those tables? Is the query it uses working?
-
-
The Agent checks the data and code dentifies that the issue is there is no minimum on the num_votes
-
-
So I ask:
-
-
help me find a good minimum of reviews
-
-
The AI Agent gets the distribution of data and some sample results to determine that a 10K vote minimum would give the best results. It's great seeing the results are grounded in reality and not just some hallucination.
-
- -
- -## Want to learn more? - -- [Overview](../README.md#overview) -- [Features](../README.md#features) -- [Quick Start](../README.md#quick-start) -- [Technical Notes](../README.md#technical-notes) -- [Discord Server](https://discord.gg/4BEHC7ZM) From 77f163749cffa9a2b41e5916d56128a8e7e5edb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20C=2E=20Andersen?= Date: Sat, 18 Oct 2025 19:23:19 +0200 Subject: [PATCH 09/14] Remove unused files. --- devenv.lock | 155 -------------------------------------------------- devenv.nix | 84 --------------------------- devenv.yaml | 22 ------- justfile | 35 ------------ smithery.yaml | 21 ------- 5 files changed, 317 deletions(-) delete mode 100644 devenv.lock delete mode 100644 devenv.nix delete mode 100644 devenv.yaml delete mode 100644 justfile delete mode 100644 smithery.yaml diff --git a/devenv.lock b/devenv.lock deleted file mode 100644 index 3e89b46c..00000000 --- a/devenv.lock +++ /dev/null @@ -1,155 +0,0 @@ -{ - "nodes": { - "devenv": { - "locked": { - "dir": "src/modules", - "lastModified": 1742998885, - "owner": "cachix", - "repo": "devenv", - "rev": "4e56212b1781ab297b506bfca0085bb0e8ba1cfb", - "type": "github" - }, - "original": { - "dir": "src/modules", - "owner": "cachix", - "repo": "devenv", - "type": "github" - } - }, - "flake-compat": { - "flake": false, - "locked": { - "lastModified": 1733328505, - "owner": "edolstra", - "repo": "flake-compat", - "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", - "type": "github" - }, - "original": { - "owner": "edolstra", - "repo": "flake-compat", - "type": "github" - } - }, - "flake-compat_2": { - "flake": false, - "locked": { - "lastModified": 1733328505, - "owner": "edolstra", - "repo": "flake-compat", - "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", - "type": "github" - }, - "original": { - "owner": "edolstra", - "repo": "flake-compat", - "type": "github" - } - }, - "git-hooks": { - "inputs": { - "flake-compat": "flake-compat", - "gitignore": "gitignore", - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1742649964, - "owner": "cachix", - "repo": "git-hooks.nix", - "rev": "dcf5072734cb576d2b0c59b2ac44f5050b5eac82", - "type": "github" - }, - "original": { - "owner": "cachix", - "repo": "git-hooks.nix", - "type": "github" - } - }, - "gitignore": { - "inputs": { - "nixpkgs": [ - "git-hooks", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1709087332, - "owner": "hercules-ci", - "repo": "gitignore.nix", - "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", - "type": "github" - }, - "original": { - "owner": "hercules-ci", - "repo": "gitignore.nix", - "type": "github" - } - }, - "nixpkgs": { - "locked": { - "lastModified": 1733477122, - "owner": "cachix", - "repo": "devenv-nixpkgs", - "rev": "7bd9e84d0452f6d2e63b6e6da29fe73fac951857", - "type": "github" - }, - "original": { - "owner": "cachix", - "ref": "rolling", - "repo": "devenv-nixpkgs", - "type": "github" - } - }, - "nixpkgs-python": { - "inputs": { - "flake-compat": "flake-compat_2", - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1733319315, - "owner": "cachix", - "repo": "nixpkgs-python", - "rev": "01263eeb28c09f143d59cd6b0b7c4cc8478efd48", - "type": "github" - }, - "original": { - "owner": "cachix", - "repo": "nixpkgs-python", - "type": "github" - } - }, - "nixpkgs-unstable": { - "locked": { - "lastModified": 1742800061, - "owner": "nixos", - "repo": "nixpkgs", - "rev": "1750f3c1c89488e2ffdd47cab9d05454dddfb734", - "type": "github" - }, - "original": { - "owner": "nixos", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "root": { - "inputs": { - "devenv": "devenv", - "git-hooks": "git-hooks", - "nixpkgs": "nixpkgs", - "nixpkgs-python": "nixpkgs-python", - "nixpkgs-unstable": "nixpkgs-unstable", - "pre-commit-hooks": [ - "git-hooks" - ] - } - } - }, - "root": "root", - "version": 7 -} diff --git a/devenv.nix b/devenv.nix deleted file mode 100644 index 9a109454..00000000 --- a/devenv.nix +++ /dev/null @@ -1,84 +0,0 @@ -{ pkgs, lib, config, inputs, ... }: -let - pkgs-unstable = import inputs.nixpkgs-unstable { system = pkgs.stdenv.system; }; -in -{ - # https://devenv.sh/basics/ - env.GREET = "devenv"; - - # https://devenv.sh/packages/ - packages = with pkgs; [ - git - postgresql_16 - pkgs-unstable.libgcc - ]; - - # env = { - # LD_LIBRARY_PATH = "${pkgs-unstable.icu}/lib:${pkgs-unstable.gcc.cc.lib}/lib64:${pkgs-unstable.gcc.cc.lib}/lib"; - # NIX_GLIBC_PATH = "${pkgs-unstable.gcc.cc.lib}/lib64:${pkgs-unstable.gcc.cc.lib}/lib"; - # }; - - # https://devenv.sh/languages/ - languages.javascript = { - enable = true; - package = pkgs-unstable.nodejs; - corepack.enable = true; - }; - - languages.python = { - enable = true; - # version = "3.12"; - uv = { - enable = true; - sync = { - enable = true; - allExtras = true; - }; - }; - }; - - dotenv.enable = true; - - # https://devenv.sh/processes/ - # processes.cargo-watch.exec = "cargo-watch"; - - # https://devenv.sh/services/ - # services.postgres = { - # enable = true; # to delete: set to false, then rm .devenv/state/postgres - # port = 5444; - # listen_addresses = "127.0.0.1"; - # initialScript = " - # CREATE USER postgres SUPERUSER; - # ALTER USER postgres WITH PASSWORD 'mysecretpassword'; - # CREATE EXTENSION IF NOT EXISTS pg_stat_statements; - # "; # SELECT * FROM pg_stat_statements LIMIT 1; - # settings.shared_preload_libraries = "pg_stat_statements"; - # }; - - # https://devenv.sh/scripts/ - scripts.hello.exec = '' - echo hello from $GREET - ''; - - enterShell = '' - hello - echo "Crystal DBA Agent Development Environment" - ''; - - # https://devenv.sh/tasks/ - # tasks = { - # "myproj:setup".exec = "mytool build"; - # "devenv:enterShell".after = [ "myproj:setup" ]; - # }; - - # https://devenv.sh/tests/ - enterTest = '' - echo "Running tests" - git --version | grep --color=auto "${pkgs.git.version}" - ''; - - # https://devenv.sh/git-hooks/ - # git-hooks.hooks.shellcheck.enable = true; - - # See full reference at https://devenv.sh/reference/options/ -} diff --git a/devenv.yaml b/devenv.yaml deleted file mode 100644 index 6c435d36..00000000 --- a/devenv.yaml +++ /dev/null @@ -1,22 +0,0 @@ -# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json -inputs: - nixpkgs: - url: github:cachix/devenv-nixpkgs/rolling - nixpkgs-unstable: - url: github:nixos/nixpkgs/nixpkgs-unstable - nixpkgs-python: - url: github:cachix/nixpkgs-python - inputs: - nixpkgs: - follows: nixpkgs - -# If you're using non-OSS software, you can set allowUnfree to true. -# allowUnfree: true - -# If you're willing to use a package that's vulnerable -# permittedInsecurePackages: -# - "openssl-1.1.1w" - -# If you have more than one devenv you can merge them -#imports: -# - ./backend diff --git a/justfile b/justfile deleted file mode 100644 index d1f2b3cd..00000000 --- a/justfile +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env just --justfile -set shell := ["zsh", "-cu"] -set fallback - -default: - just -u --list - -test: - npx @wong2/mcp-cli uv run python -m crystaldba.postgres_mcp - -dev: - uv run mcp dev -e . crystaldba/postgres_mcp/server.py - -nix-claude-desktop: - NIXPKGS_ALLOW_UNFREE=1 nix run "github:k3d3/claude-desktop-linux-flake#claude-desktop-with-fhs" --impure - -release-help: - @echo "- update version in pyproject.toml" - @echo "- uv sync" - @echo "- git commit" - @echo "- git push && merge to main" - @echo '- just release 0.0.0 "note"' - @echo 'OR' - @echo '- just prerelease 0.0.0 1 "note"' - -release version note extra="": # ="Release v{{version}}" extra="": # NOTE version format should be 0.0.0 - #!/usr/bin/env bash - if [[ "{{version}}" == v* ]]; then - echo "Error: Do not include 'v' prefix in version. It will be added automatically." - exit 1 - fi - uv build && git tag -a "v{{version}}" -m "Release v{{version}}" || true && git push --tags && gh release create "v{{version}}" --title "PostgreSQL MCP v{{version}}" --notes "{{note}}" {{extra}} dist/*.whl dist/*.tar.gz - -prerelease version rc note: #="Release candidate {{rc}} for version {{version}}": - just release "{{version}}rc{{rc}}" "{{note}}" "--prerelease" diff --git a/smithery.yaml b/smithery.yaml deleted file mode 100644 index ba45c030..00000000 --- a/smithery.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml - -startCommand: - type: stdio - configSchema: - # JSON Schema defining the configuration options for the MCP. - type: object - required: - - databaseUri - - accessMode - properties: - databaseUri: - type: string - description: URI for accessing the database, e.g., postgres://user:password@host:port/database. - accessMode: - type: string - description: The access mode for the MCP, e.g., "restricted" or "unrestricted". - commandFunction: - # A function that produces the CLI command to start the MCP on stdio. - |- - (config) => ({command: 'pg-mcp', args: ['--access-mode', config.accessMode], env: {DATABASE_URI: config.databaseUri}}) \ No newline at end of file From f7728b8b333dbaa5bae2bdb30013a21ef674d822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20C=2E=20Andersen?= Date: Sat, 18 Oct 2025 19:27:52 +0200 Subject: [PATCH 10/14] Moves unit tests to the root tests directory --- tests/{unit => }/test_access_mode.py | 0 tests/{unit/sql => }/test_db_conn_pool.py | 0 tests/{unit/sql => }/test_obfuscate_password.py | 0 tests/{unit/sql => }/test_readonly_enforcement.py | 0 tests/{unit/sql => }/test_safe_sql.py | 0 tests/{unit/sql => }/test_sql_driver.py | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename tests/{unit => }/test_access_mode.py (100%) rename tests/{unit/sql => }/test_db_conn_pool.py (100%) rename tests/{unit/sql => }/test_obfuscate_password.py (100%) rename tests/{unit/sql => }/test_readonly_enforcement.py (100%) rename tests/{unit/sql => }/test_safe_sql.py (100%) rename tests/{unit/sql => }/test_sql_driver.py (100%) diff --git a/tests/unit/test_access_mode.py b/tests/test_access_mode.py similarity index 100% rename from tests/unit/test_access_mode.py rename to tests/test_access_mode.py diff --git a/tests/unit/sql/test_db_conn_pool.py b/tests/test_db_conn_pool.py similarity index 100% rename from tests/unit/sql/test_db_conn_pool.py rename to tests/test_db_conn_pool.py diff --git a/tests/unit/sql/test_obfuscate_password.py b/tests/test_obfuscate_password.py similarity index 100% rename from tests/unit/sql/test_obfuscate_password.py rename to tests/test_obfuscate_password.py diff --git a/tests/unit/sql/test_readonly_enforcement.py b/tests/test_readonly_enforcement.py similarity index 100% rename from tests/unit/sql/test_readonly_enforcement.py rename to tests/test_readonly_enforcement.py diff --git a/tests/unit/sql/test_safe_sql.py b/tests/test_safe_sql.py similarity index 100% rename from tests/unit/sql/test_safe_sql.py rename to tests/test_safe_sql.py diff --git a/tests/unit/sql/test_sql_driver.py b/tests/test_sql_driver.py similarity index 100% rename from tests/unit/sql/test_sql_driver.py rename to tests/test_sql_driver.py From 017895018b57236bbcbd485ac62d38f4f39380bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20C=2E=20Andersen?= Date: Sat, 18 Oct 2025 19:33:28 +0200 Subject: [PATCH 11/14] Not using a similar icon. --- assets/postgres-mcp-lite.png | Bin 52503 -> 32877 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/postgres-mcp-lite.png b/assets/postgres-mcp-lite.png index 24be83b9952b92ea763414e567066d022918e242..845139942b71d49360e1998244e5e102fb81e79a 100644 GIT binary patch literal 32877 zcmd41WmjBV(*+8_f?MMjym5C6?(Xgm2_9U6H%@SOcL)~T-CYC0-KDY1dER?+?oYTM zdXHW$dsWq%HEULl9j>GxiHv}c009AkEG;Fb0s#SC^Zx$gBkcPXjWhF~1*EfzqzFXS z1mWTP0@_koUKj$RHU{z880LKq?;xe^3;}`C^Un(s3n=3c0pb5aT1;5Y-QYA+z;ktA zu^(=Ka$#1yjh;zds!~ zVgLX6m)8aU^F?^rQ^3{279z%A>{M99rZRtf_T98ZJ!gVK)&$47>tbv3KEukT?9T*L zEkpQ^Rg5@E9oD7n5z35D+7snTFjA9h09|ZsU1BKSuP!3e21eS4_4#H$$BA}aBN|VU zyi}DN%$jOPGnjf6BeDvf-G^QXZ`wf12GB6Vb2~1FO5vt++dyE!Q&lDfZd%i0k zu3bR!36+ws`#F47ExbX(D`+VpRMV>0w9uha>Xi?)4-P)9ITd_vI%!s6gO{rKTvP?x z=~c%Tm}(Z$C1S5*TjFr#5xilE@NJora9g89P#PsP+FZK0x!O8JGY|Ygb+LMZJ5{rN z36CwI9}4c4*&oKthxjfq?|Op_^2l73%#sW=?eC!F#ds=NhC`KmaqP5<@HMI=`-w>F z#%P$E0DBT8DBf`rP&_@QU_VVVc3hRe6hb-bOh#DhbNyhYlq_==D*Z*eBgC%f+Nj9I z@LFdo#9U^YPA|^Ilr>km-CMG#E;DUB!UdU;SFma^XDp}r9Z};bS zm_cC|?-M1B8`_16y<^SdV!5^s>z-F*j^5j;DzhIE#!AC2EmMDSZ5x`sOM;FOM*;IW z^^+fHklJ1514i{#sQ1d@b*JFkk1z-LLx>hVVkbD6%TaLB_J{rmr5+_>*J)G?Ex?ZI zVqJ{D^rsV$gz(|_1=>J#dy{0+U1pMBLmVybGHL;=E=5vwt~$l77TL=RCbHH_@inU1 z4RE_Vj)K~w!(a(BindRa_WKSA&iB&*x&=0P0+6M0nSxTEca!t3YgHllc}@l&4oHx? zET6wh~7!!rG>|rN< zl6Ut?oAl!kT8Q%e4X z_CN~#l7$S5xt)WtlGyTh16x(Q$s;M|_mGkTV<&RN%8mWxREMmACN89cQe>k;KpiPH zBLVQAHR%(>uurKWzC-6D?vk7+Jk7AWBP080Xs&0lQ7yqqvIj_RoSBtY#T;OWK6UT| zbIC?X%RPg4Q!nSBjCtOJ79R-)J+6O7ulm(#Un;8x%AEX2KJ@{{k=}(|xdt0F2j!My z8{_TBCUYYiAjPKdX!!VGC914~?%<7FlMz+p5JsAR_VEduSD~ZoA<&7?yPzhSS$#F$ zEJRuLDtW~ue^KnABIF!5Rs4Xjh$8xkYJ*Bph+~p%=5pCw9majdzwT9E7{%^!nk1o zQwoI{_hs%zc334y#3dRZQzE2sYx%KL>!ADN23l=qB2OL1X!B{(jl&mOP`~gR#qdvx z_Xi#c#=J~R2nmbX3K&(9YC0|`ukAmv`UhPtsVtc`@hgZYivK`Mbobtp(#OcuG@Iuh zTUJdN4$-ABn~j_NQOX=jT8(*So()s9`}HOx`QWE5uI-A$s0|9k4pS(qQ~EKl8n*7~ zXHoR)FZI?N5Q%%P30G)P6m!ZamR0r&m|(kDj=#R+d#H3Fl!mj=s?mUB9svx^=F;Bo zF{qDPrW_5(TX{%`1zgZ6bl0$WNwo>SGK=; zq~k7jK~E%}{eKDjxZp`j5LGZk6{4&7f$`|05+?}t&ZB(Gv7t!bMcm8xcd`K`0|dMs zrJY%&zc_|r2?cU4gjhG6?h!nClv>aSV$6L&&94p)3jd1zost!-#64}*qCDAe?7ZHMTm zDYAiUg=u!qf|g8?)sLo=y69u0NpSn~2I@~5dKskwSIg(+`?n?P_N*63(7@Ge&` zujpU)HeEmwN)mz;toZ{akgxUtnU%donbOtjZKWxyK~`OMwPhf#m#BEZWO2@a+VMoy9}&p<&J>A}I|b?8Y8lg5wVr9vk| zucT!cN0w^=q(llJ52Z4T!f6MEV}X?fSdDY!70PH_6DC{_^q)rU*#WWJ@K^LId=+Ms z>a~fcvE3b%OSw|7I5w08r+uV7{CPjjifyl^*z8het+=JIlgqNDg3$)le7l2)yi3Ih z<D~=NLMv>_kGB+DSmx6WV%EUb@bnMh|w|_Brvw z4i@wd_Grr9q0r_huT}mR6}d-#AxW`phcI<)`I53BrzGy~Mw2ilVpdgb5*ft?NqHZU z%OJAPJg`t;0Da2qJb`^mpae+ByK`-y7yPdnY742STlOx%IaT~IN()hb1(K6BcOVUxge3@afY%H z<16oK_Q|m@i>q*Iy`7cd+CE|YFvZ%O+J;#IOnb>0IQ5)tB1HayVt~8;gzM+yAxgjM zPmCU!Eu&63CzNU(W5XdV^>Xrw+}Q^I=TGRdP;_wXiG5Spqmwan|8Y;zlOFLU%Mfi_ zm`s4v%!jZ%;|J-i?2}PFQPE3Kj+`i}GXXqGOQ3>PZj_9F5b`T?RAK3Khzi^m3W<`4 zlY2^oO7CD2fR|n zFm)967xXS}%Lqq{Vxkcihq6{shw#44qLaawp}vz_~pDKae91gUhf-~QagmJ8<;P7|!%Ne}3NG0&vF-u$6 z=<(E)5fLvzMtmo{d;vc_<;`|NIs52TXhfpSNAe)x&3J}=KgRuAuOgSR^`SN3oOe}U zFmL948US&hKeGgUhndkw#xFreYHf6eg*aD<(f{Ol1FjCq<8b6nxqh)&`q=ny{$FQ;jD)01|Tt6;uPJaRCJ1O z_8aRz&FEL8dU^X6o^|u^3vbE>;;dlqU!b)ES{yLRH8?r>_bAhIXCc^k0kPh(v%&H6mR@+!yZJuQ7Y(GGIa!$_ za(SP>;sga8!96MS(XYj$;m7ZFU{C_=x&nJo6k!uAWL$lA7_4OH(1A3k0eFn-XRuMQ z)V++OSFtk*t{5^vpz8}3utaS0D3tD$Z$hNQC`s9x7E)%1@JSUT3N*WuZ$(1cOtssC za!|F!{9R$)Z1u!;{H;)p(UOm<)`9Kj8&3seTslAope{L)G}FO(KZ1;tL9oj&qx4}t z^b^eaJvrLtguG3#KwU>)7M zEJ|+-cuppdKQD;=1`XEyw;^tSh_h9ARb*7$@a@Y6Bw~#irVK#Y=ll0qqL6Koz&}=$ zS3`P~5O|FdW(vwd!X)dV%khOByHRJ0x zS~_^@#lGZ}wXvt%rb&Ic9Khg~fRUc2EzCO(rX4X1I(+6yQSrKNU-e+j3U}jaj+g$I z*#yj`d;1m`yH$%}32TkZ3faLHWb(#XWbG*5T@C3>&7B$xOsXx2VIxZRn8J6SqfWMJ>jVhueca>$DeTR&$48@S+=+ghl}x42ymKva%wm^KLbzfwwJ) zJ=wD`F?@*glU9&2`YunMU|gygt67{;kOA|a9bBFua}-7RC34;Oc`4h{<6l*f*z zi%e-j z8ftclDMlBC45-PsLOQBGF}kU><+81%UTwJ7fKraRV-{uaE;dN`>l7vA1IC}&5;n&# zj1qZRiU%2~8v8HA?wr#EFFl?M$h-@~tVBb)M|4dwRZ7IvJ^T@K^!Ma^M1nFdKICPf zX$^i2A{@hPx&2pn$3vXQj2g@iUstEXO3&!K2b!lmWW#!1OMvr(S(`=P=Sm0uBie6Y z|K`|ftT}Rz;?bpY=N|R9LSYIxCA}%jX9!*2CDf@#GuYd>6Y@G>ju?$>UG7{2W2k-D z=d}>mIVCG43YCdusA-Ay;0N?Umg8v2CyOrB-Vlh@MV^5WdGn$&VZSvKnkQ34ta5F? z1)D_`-0*8-R~}6mk^XQi^+W#JomX;6<1m_>FUqOMsns?ak`r#ea3Jz{_dp%j;mQRm zOVKZ|_5k#dP2Qf4JR~7@c*igrMo881iu-R!gJz_EQm&Ny&m2Ej(73Hg4I%XKk-`RV z=5rv-2|1}H>}2C+>CiW8uxaf3s>e+1G}h@pNaHOjS8Z2E6LLQ8!nji{5!hL+6H(`n zH{=6<>zpZnr~EIquu-!_!V3EQ2?O%#;Bv^|nx%lB;^<)jPGx9}%{I;rsCA;Pd)iu7 zM`1`HP~H6mZM3B6LM#cf)=3^6ExIN@UE`MH_|zW7zIlmQm1NfXf_e7Rg?o!FJpRyq zoceNzOXvVAe!ru27~yj~eJsx~#8j6cUC{g@Azq zELK3FKW4o`!pnCFutpBw^|^q@Yg{?DiO9TOr#R~g9a`P;8{!*(ONvE=3&|}2!#IHmw5IhR<*4 zf$DvD4HmUCjtdn`+erETBG$^(#-E+=WdDrG?BIhndY7t$lIhdp$gr)8rCdyKjCjWL zh+n2jr(K(qQ@^~K2b=DyBD8cei}=}f`P5ow(A>Q#wQW1bp_N|=w)}EhPowUmqlzn1 z1pVeJIYOz7^@=x!*F)5GkBs`mybruBti5WWp6%ejV?|3 zgkF+S?{-!BldnigcDTzkpjv2@op(6&swyw|8pOo0%k%i}+1Df$EafX^L1y_#D|DhX zAXj-_JYvG%vHYi4PdQOG`O<#Adc7b1E5en)5dD3hdqQc+(@+>*wQyY! z)2M`1-zG0vn#?aFL7b<(#*@x5{P`{{`g|C;Yf^^J0X+o_C)Ngry11Y`=HqBxd!ugK zTp(Sk;Ug}E0jaz$rL)0uSc-~e#UPBW%`KYia@9>1gSm`M>v`O4-K=69xv1BQ5%rDD}wMWKmk_bf53+XD?a9;Bt-ol^ho?2*hUtc_o-L$KC^!< z)W=olY6WFg!Y|{KLGP}=T(gNNmT@q9y`w)mi+FoT2z%Taw#8vk&+zY|)I1|m``|{K zB-DV2yI@KS6P0jIPbU($Ik#>3)fc1tC;)jxZ|Yp@5SrfM{3EPhKUhtb&D<8GeeJM=2}E!J7K*teBY zp)P>0l%V3(4m;O!V%JtpfGGFp@=q3uUBH;Eu*{9=2<=?k*^2X5~6g*id+>C=DH9Zq2OpL$k*)YIp+ z3HBs1s0*mJJ^!OhI9wuowVd3vIuR#ycbr2NtVj6LpN#$0osN$3-QUS6{ zDu(gTftw0sAml4;if)-6X~xB^`tKez@|7XjdkPKV;koP_o`M2uMx!cjOIdU4N&+0> zA{dg(JSa+yc<>_2Sw0dv4AVp^C&X|?ob11;%8a{IJZq+(^+Amz?Ut%U&@i|NY8ew6$U8RQK5VzpzYV)s)d zT8Naxl?qDa9t5%&!0ch-WLwFXYd_HPAml!FhYa`TCE{IQOCBmR?yZj~{~Te`znB=r zM@Se`h!i!(1o`g_f50y8%-JuxDeeJ1`zWp&sIBB?RR__JR$wO?5U5)Pdjx53ooiGC zROyqI#(|_;QHneeN2r{8`OF%&-tU;(F`=wWNmd%hu3}V^&#RWE@R8O;+5>Dihlw`_)~sRZ$nhBxe?ka*W98C7^Fy$}g@1(jxX2Bs0zMhjCW zDM~0t?Nlnz-+hs9=OlzKoUjGo<&S!3x})2K`hl(Slu1@i24e0OuEE%AJ~kfYJl)Zf zI)K^p zyOrji72+@Nx`H;w8fSzoeJ*b9j=L6AWVru^V+-!gaM(s)E*?7o3EPtM{%JD~BcvdR z1KjeOf<`rHj61*#glVW!3SXo==MLUUPAoTv^I)atKEE9kHaJ@5l0%B2JXCXTd zpk*N8NPinIa5q>5#YHe~Ojm82`MEEtBn7fL^Foj#d=%+;zaU2(k;t8S(WuqEmB zDYg7LzmbOwe7SO}jg08~X*a)|BzDHm>ELf5<$jtI=gpYxTG=%zfFYSUX*j|4FMo`2 zx<8jbcT;=#UN8Y!X^Kms0K^l?<4Y-M@=Akf*c7R%@K2(DKp<%N`Z?sI9wusGCa>SG zrd1X6te>PI!wzGKd*Vkgm?>)J>;hFZB#!BG*je-n;Gtfrick+9$9Q&z@8{x`>Oynag_NoO-@b$bF;@$l6-mIzMeSVYzS0rw3 zp^D4UB2FQtO6$Si$I;9>4KFp$2u-n7yI!gm?d+~by7TCzzZF!sAfjtqCJ!S&-U%}B zORH2~+ac~wsu>di&j~`VYERM+1d+hcjYgOEL&iwX3Za2wI?P#Axxr|0I?Wb=6j)Ah z6LaU$N#;huV#0_(Px4W|Md@>y2+Z&{H1MuAU+(OJ6p#Z96KGI3Q%S}gN&2&G%v{Oo zJ+d*Z=MyI516Vc%5{p;1{+?=UjeaarA`Jb|$=wfX5Kh65!u4o-N zeD&A?3IXO{s7O)(`swU#!-4J50V1?8dLO|byUB7=^Q5o8Z(h(@t`{Uxjew`QfyL89uKQ4@T9T#M=cH7!N75T~XjON}Oo7|U#0d{<2cR1rVA43dwFEtsX@fpW^$ z4=sZ0h@+yilu$;)Vwf*a`=IB?0dleUD~_DykjQG9hfSoVZIB2tpp47Jq@>$y%KM6+ zT~D3Xm44tYq)pu%5Ij;gl%0^6aX&{VI`95V5Roi{$h#QbJXt2h z$dU@ngtySM=^e#~w-kkJs}0kMMBwek4qfW(!2{o2;LkN24aAog&9&kFh8-ZBPTd>x z;lF6)=Ol}4J38wJDfXu>v(b*y0)nwHYBfHkD>sZYP33 z?)`d1ib@%9uy#s$4Sz#o8XXj%fFWI3sC6qMEx#qE11Lsk+EJS>DfzWC1|LdPhF(_L zG#0l`Hs~_kHV+|;GCpTYv~^na-pHI&^soFSkUj2$TOd4zw!I0>V0Ab(proP@XoWb> zlKs?$0wbPfU11~;;!8K`4`ORNJ`6;UR{v&$Ltf`Da3cHHB|?%+f2^6IPp!q6S|R8{ z%Ne|~8+AI@ZTrI;>d`KdQg$^&ja=UubGc#O9P7MVKtnM<^A=2SEm9lX9~L^;|2?R& z%V~J6@M$5KG9?V{lvs+yY{3VAVeKrDawgSp9JjdFXtZi+e4WZUx|x{^UBe8|3Jgjr zSgiBEF|<2a|K5aUrj$JLmZp?+taK;Wvu7$dmpk!zABJve?3*QYhDc!<&BTZq%@~-92)&s3r6zT zm0|BRK*t*p3$sugovQ(Meid(HEj6%LCze?)%5Ouf;YzawZG@P@3ZCglLXYtDD9y&^ zMxxteV3XVa#f&riSn#zK>m`Pcc*su`6{YOkDqI#R^I!?6UKth%44u9lJ}A_gekTO) z0gsxxK#R4h_A3L*CYh>u_kLujxWD=$l81JWtj1G>H7<$F9#j?qz>2dCeplz#`o`l@ zq!z|weN#(jJ+)9xFbLfy-wk#mwT9S4B5L%;Pn7-x4Nj(ie@g{Wh-Q5OAEv~`0H~t; zLnRIClUYhm{pc#|qN?iP+|C|6?f|UNY|M>b<@4-DUet@#zs{tXnxO??)u~Dj%kXr| zJQ|45IJ07L!#zqr^mvIoH??xgu0}i+-6_gxB;N{NIC%84a8`|HSm^q5X9?AD^I3| z{!5;Z?i5VwgnUC`dBpGomdY14u&46aMF%7Z z!4A~B^oaqlChUjM!59@*YYQGT-aGUk@&66X-cZU9(UQ2JjGk@1LgPSXTIAp#VO;ox z9~DFfO@Qe1GL{?|?D@ZU@oM%;FJ>3yZD;^Jl4GGvG_cr8J(~{A?7g&zpr0_p|K4wa z5HGV;OkYez-Q$QzSX(w2q&Nswk-SFMOFIs#KH2H(z3 zL?;GxO0BY&&d8mri|ORbrIBhRw3s+~b3)Wyikp@-Rhlqb)5a_8mOuw&i zhQQ5;?Aj42(wOOohG3&yNsM_=7($K{gxHUH!64$B-@DymRGS(7iymBOcK+xt+T73n z%38r;;4QH~8RhUbSP0ieOb2Oflf#>%LFX1>A+UE7i07`1kz)I=4(2WNe~o966lV%o z`b0_z2$Q8b9@?HKWIiGbj!jW5H+m_FF%3l^5x|=gh}6qp9eI9Cc=i+uRm2+`NSglt z(60M|Ydr3+A~=+z+(7%;@umbSJKpAd4KIwS=sm*mxa+QCee8lsgl8`fT721&)sEF#+14Usv9 z#EA#W{Fp(?3Pe2=vhi9QsaCGg6E~-O(gw;4&6L_1{}&g27iS|Fdz;wGGPELKsuEx6 z480hZMp)gvoWdoAfJMDVKT1o9z!5j?H1rMPersMf2OqBQ?n=?PqOX~72SsKl)KBGU z@0R}=x6D>F%f5T}D!5*5gl>6wLa$O>TpY*z9>(ZMg|6+^14{ZFU}1ng$93-KLwI*W zd?~D834CH8RAG8ilGeQZZB=wn-wm(?`L(P>3+vvzlsSpYFeWtm54~x$HQPU@u}o5A z?dg@>;rEpE`gp(T^6gvlsNtX)t90kfoi6w`Mrh{T(h|Gz-91A7z>gL5`RwxXUwT@S z>on#up`g_%f(!dY5Rg`bDth3Dv3>>HSnJbrh}QX1?mIZg`)qPDJbx@E9UaZPiDlLm znze3iCwXIZaDC7ff6p$sf%@0v?YG~L*1TzN{R+p!E%?U#%EQu`-$LK( zM7<*avELzu{>GcsdCZRA`spuPhV!&hm$yGJzTQgN8%5>zVH!fFPH~KHui)0j#B=zj zo|_|i-lv)ab(gp=E7gbm`;jbrS_cWSS3ANe=BDvw2u%Qa3ygd%i9Gm4wuyj#_)HO< z=_z3W@7Tm6Xn2j(Xap2m1vt45@y6ZjUo3{Gum|p4&9)4R5*WLD?~N?;tcLeH7J~1ofp+Woj6=4SS!zHmnU%(ZzKEG90NY4 z-CJHU^;Udm`=Y5*QVx(CX4Uuz(3U0&||371+AfrYSdEb+dNb9oAj* z8McZ>lW-IXk>J^4D`s{l6ajZvzaQP{+Y4#C0AFClMgf;*lb4_9Kf2OI&rd;9@*>6_ z{F1$n?rs1d3U46Y434cr-7UC--o(T=9dl%jO$LE5;sntnIC^z3BKk0kELbB{Ky+gZ z$P7>tPDIsxbW=5Z! zoqC_L#jdgC45mR;y&+*5?uB0ycROpEY!Aq~hD{3FsieFUcb%qP>@+H2T8VCPr8_FM|RNtm;?NM*mirc5ky zKRqaBdp5g4j@}0y|89n%ZzKNlsH4@jysYz<70L2z6bh0~tb`VkE-kj*TD3B)JXQ@9 zl;_Pz@4A*m_@@rHaDJ3IMCqb@#l7q|xjg6(Q)0+@8UAptL%+G`#@f*M$fU=7ele3n z{@tyAXZt@HNYbX%C4<9NDj)`9>zTNb;p6`LcB_w8!@W3fI7!6F-OVsud~^BoxEJHY zo0*Pzbi+Y%-&Eod5QqCQrC#pC9=D#-VUZPs@^Guf zo;kujFrPUeyQVr_`#`%W&{;=%A^!(&H!WW{iW*Skp^ZR8D!X_2v5t_xxOo5YWs&4i zajJ;+}ORi-jW3)bQH9+m)N3@rCv_*8a~{>HE`|8&`sDEFOVG zEA=Fwf%~9$b`1fXN?)@oOO!UxV8W}fg=>%DT$X*7F0}u}$lh9G#mW%O6wPO5SR~pD z$q~Sp69X#>-(oJWt6sWb_pltDT>9-=xtk!lTFAFRgNvcXA@aDX)%c&fa2F$*G3KL4 z{k1cyq_$8JS%GnO{3Yh*&v8;vIn?0)w&v9?h`0J4BvY63ftE&xRIaq`K-q(le&L%Z z(yE2m-BgM4W^&x1iY^r(&~Jx9LSK1Mq)snZ0U~lO@Dx2SJ&AMO(14yx8TduZyVJyX z9nc8H^2=Yr1>?O=!#n!hwHxdd-ity-_ zQY32!y68byaePa%$ve^F@p zC~TtaLEW?K`)sg1g05W|=nUi;0j{_6Bl8270=e289YX9D_lH&B!dl zWFvMGTS1?a$tz0onfpl{*%yM&^|Y3(wFVg)`H|raW=^SB76?U3%401_du3*ivgcX> zK^{7ixOy$vl4FNYlwY-~USi&lV$8tlxp!?9?^M0{z8;d&S?Wm;O ze%njL^j+AOxKLfp`$2C$;eslTP>W4oYfu(vJr7p>P0!cP5`r;9~lg^>5gWUs5`iJGWq z|8$j!$^Gj0Ldv%LCL`DWJR?8RG;{|wKl8-wVp29a4!!S(Toy@Lrf{{4^ipLCEV!1f zb;!=h{A~;j*&!Nn&taYhM8uux^a3209xfbsmfZdn$Q!y)=$WrBsIx9vd9^?@>ro6H zh=TVM{+n@;8%Z~ z6ofU4(2VHl$6i!ql+a(xrTk`Ze{+eO71}?`KKBi(V;M-vG-bUDcmiJ=NJh$azSVPs zZJbhHU%`@K2Fs?A(_c4>DvB~~Qe4>8ZfO52Ni0f$cm-PonnG$>+L>bZI%GWgxru`c zLK?u0K8<=vx3zu9kdOls9I_z3`SRFJf`|gg!hv)Q9{xybdp0un8~1C}Y5-F2y8?6O zmSa4hjUl-wf9t*DF)>T)h>`Pky|mU0%1N>E)$2$r7=5Z94or3f{BPs#*;3M|kYeN2 z0l*(|&!ZbGY;0ulR+-AM+?q7YPrvvv`l5-zvV!I1YFw0_$Ni5!B72j3vQWWBv zmNk3xl%4;L?$Ga^=beBCPLTd6lYb-WC}<~?a(|*^K(T6-@;eE`=*tZpg*Mb*dzLyU z^8DHRkoEr1IZCDiS*dLqdA*u5uCA(Bi2*(QI)#NTtqJxaOj$*jQEwOhc(^&SaP`9qGi{OOq` z)>Y@@W1_!RCvX<_4w_K7c_fTb%)3&s$|uvK>kTET{oAh>?cWp3E$sDe#skdFdUgoD zp7$MJW#jdD5(hVs=gN%=P+pR)YqhVS{;#&f$uk)#6It_6tEQLnX0fH{C~QwqBiz+~ zMu5({_#RJIBu4E~cdu0c{yY?3BO4u~P=H_|s_jGA^w%54{XUh82)dfpuD!2B0hp(((5PJ|M{Ax(3Q6#R$CfT zGt%<|`bWS=$}O1NgvJ^AIoe9?BuQM17#FWJyqD1IG8@{t2enePlpJG}a6 z3CFEgFkTh)Ddr&=g@%J2U!LxTgk1mG`->A;kRc1Vud;EvD%3OyDYC!W<1;`AG1meU3_^@ ztS9nt>q17bKW5@M|rIhTit>==7IbyS|I&w|DKVk5HUlRL*5d z&NY^N_&V75RixM&gi3OMAF*e^Eo*7Hl#z9Ii+r6xop$C}73YVMVU=;TH|gSeOO?ss z-hFU+=k-!j>Rx&IfxZK}`GeQ1ju(rAQu6^d@%OJ2T$d|zj`Xj(%lpR-$LBD%<|lrK z{qi>Zd+PXahK;D_@=fPu4G#^gkHb^Nnup`d-=#`8UfzhGggz1L=acu{2k9C=-?rHL z3L&9@UoX1OxyI0iAr4mmBd@YZkqcq$n(hJx1a{kER;HUq#O zlhtwQh-GGoNfCEc>1VL_r@K3)e+cP+$ZFZAJU!j*UFBuUQ7MwlVvL$OO&b_&*u5@g z_xpJHIWHR+$gK=x?$@03IJbATY}oxaJ7>Z%#9YfU8x5%O>Ae5`ll89irgU*hE8YH?$qVH^IimVSb?_~ov)Us3 z36}oYlVFL?`&~Z`oT<7^o`-e|uagkpyoB&rw0@+oBC9HvwpW$pg;O2=l(GV~A)Gb6 zB1OMH?e3XO_2x^p_un(OmeIA$_&s-&p=VJp^A3XT#f+j(f1C&>Oh{b3>vZnBj}uU}j2e%q=^5X%`nFbvjX`;ynqSBsuu8DH+yR+?%bc_%+{`s9z| zcW$-sbJ-}KIwy{r%@|C)EUz`-C@x!Tzj*cJeHE7Mgxqb4uJ(zlIud|7SH!XPwO4Sc zxYzYiTEA4_%9L-@9e$&`r%Uth4 zeIa;=Bqpi|QgPd#U3>e=-fD71XQ`!)sp@NLP*~qYqCw(%3;4Ep*sGnm(e_I|L*F7_ zu6l+~0l25x#f)Pz3(Tf{+TGLF92yUMzXQ(>Xw-8?e?pkI#?idwVbPz z*X!5v;(=nAsrG48j-unY7tYL=TgF7$pXkp9HJl2Yelmz8QqAQ*S~h(N!aW~NCG*4w zvf`zv4kt5HtT*`6?cd99Jq!xiw>Je2iM@z~=zCu}%aF{( zziCSS{C4-qMP2P(x$fhhPAjPP{ewN@D(AO1FMeEq((A7qYKN!wZ@6$%n&!jeOr~j7 z+;O+g(J)Utw=ML3)XB{nKwW$g-`Db+FHj>Pn}`eJ9$?el@2<(Oj~%uM*ap{#B!^tm z$s~Sch76?K6$yv;>{W)RLq9Oa>z9uNa#;xWYtFeMJ>m(&=coHBwuuN?x&O$W;{W0t z&4+$Nb(0wr{*m&5n4XRJ4EJzNg+CGTV6wYr6c)wzfoXOAD)|_!JRq|}rd}|yU#>BQ}-kcE5g`-N$ zdf`GcsjKnE)p(JyKY;R^-+(z%UT_-;t|~dr<9*@#RG~Wd7@wz!vWXT2Jvri(%56(6_Gp&`{q_ z<1xhpD#w^b<6M=-?hw^bF8f0NPdaMoo*a^W#-$n_arO4AIZ@=b#F%kfTeY|u+TC#( zaGmxCYztlY7y9SzaDoOnPwd9~NmM482JN%VRpwox%{TXp*Zb$$z5TB`oZA-hj9+~V z?vI;+{3sHyJ13LwmmY3A@E4aWUM?f~i_BE)Z+D#;M1MAq`+YOtrxTk_;KJv3bsC0v zwX(Rb65}~}fbXs6Ycl(RTK1*x#`cs(<1MS^qHXjTa?mIEkHKApq=E=|Uxy@!SrGDD zSNN5Xz}s#2wZ2oG%bHr1Z{B&kJJS;Bz-w}J#wbIly5W-dH{yLkw+ckVnChEZXFNWQ zx}?r*pZ@3PJdGqh!#h8r@F`XS zbByQQuUK*YhV&Ju2ZYRFR;ER>EoG?cRt}#kPHNVl>&PIIQD?g(qs21)gR_NMpTU~@m-+( zt;HN>(Wd3N0el}Q>dmA@XX@8!y4lm&_%h(WwR0LAm9@Vmc3>A-d+dC2A7tBuuBDji z+^JV_xO4yK_2S|k$J%1lM##5CWL04m$w-s{*6i7Oc(%Ya5AthhLRw%0e5i64ZI!8G z3qIj+3;Nxzh4~G4OT~k{YijYTL$}{TVn_03jL+wx(K4YaddH6A)^IIqFUP>TIeDlg z_0EL{$n#~?E2Il6W`NUws&_!CJeoC^zj!m60zo`0av!Ohy$Rym$B| zHx&&}i}!2#-e%O_NY=j%R86vMVpgFe{?G7=H-r$4zT+;U1WV}DtV^Md*Sq<#_}d~~OEVe`{If+53{6q71wuntF zJxfC&4)1GsymV$x=S!N(FF`POkBbCS-)@ieeAzt3#EeAe5Cv;9aQ=c z@BOf|nI`6qdyju}w)Y-;(N%NY+`S&h1Q8TuY)Vo&jQfBuZ+&!Dt-48@2vzuf+QpH+#mvYs?#>C5qxWnN9iDY4fQ%?k_777LJJQMom`qB zc?TKvXA3Q|aM$M^dychq|$l3vzFzEU@e& ze4!m=oW9+sBiypR^y=1WelqlY<{$S^@O#7D(C@n4$7cV_J- zEaNmfDq4;0@q|1fn+_Znl!iT6QwWX^Q;aDk9MKb4>k(lcfsnbdjuO>F*B8cAcWcT( zuL;*%9IrFUH#8YzpExlD?X2-&BvDAVI_3%%%G(^X3<^63=F45`udGr+Gm5M8^Lq5b z7mEn5;gLSfm!<=Yy(-n`?v<74s%tgO(SBcZ{Gb0ETjW(CG#N^~yCORK0;XCKc;h9r zsovIA?!>W+nY}-p-A2rKF9Yo{NKJ;=l9YzewYuNCZ#t(R%5PSgd|A#hbz7*}EeHsE zUqV=9nU%yuK9%YMWVDo(wbbNyQcu!qC;-^uEMqF(#_iAy*lILcMV28-RCe_ww!DnL z!bFVnO~6>1P`gev$QJ$QgHc%{talHeYFAJnOt}t^UyE4=9~UpT^vfTpS+C@8cP2Ll z#!QgcdU9TXqq*9pN;K@_+jBiv2RnQI)KQ#GS1xKtM2YL)yq;(C?@jl z*dcYQUQs8PL#;&hjssmwMW7UZe*z!<3qG0Zr{)8S^BSqJ~DD1Lzl>5RWBj2gwV z`}Z=L=2*lQ(ur#&hOh40JSe-RQIw+)%SMdnDm_c;(?I7faW;;9G1XVYw9WY2rFDzd_qmN-)}NM5W(z!CNZGn!Bp7Y+Tm{Nn z=yI|&B{2{BTrp{ydDUCqAHD~<@CWN4S`F;-lqqrle3{z5GB8D6O$lwDo)Z~et6S&< z`}#K3?s__HrjORPwO#5k z#n83H2_;*+LtB`my%jYpuQFUiZ#ryn!d`j~dbYLvp^06;@Ax;h4I@ zlDV9uH)MN-1Tt|G)3dxIoTwD6=V3ImDzA}sUtb9+r!G@X<>+A9SvIv{VBx^C**sp2#N~_GF~T%ns9Waqb5JST)6x%`qxmZsl><>?_oA z`O3t0YRs(#!-lyq_UJdOISn4yk(c1lI>3I=bGmPn;7qdfU>r6kC4Z5WG_ZAB(V5!V zkUYElu`0si|nJMJ*=k!xr`l@r74$ zlgxrY;0pA`2!b=WQ2PvH&ibo5ODlvlsR0F@UUwoQ8r%&~HeaR6(sWw+bPzT;28u;YupdA-hE)*JE7ReaN zpZ^U@LzaH+9>c=7Z@yn~_7S02`&70*`uiBDSc(z8SoQ3 zHQ)K+byd|a3yCVLAphy@QQE~QskYw8uBG)9mQyNxxWK?6xwW;^#zIBdj)XhFn`NG4 zbgD0=Z%o&xy{p7YfcSp%=*yGz<9uDW)hR5Z8F37Mj%cQk)KhzOkZ?H)Kcfvahay3F zNE36h%2h^0OLjlJ(Fab?g!r?#On?8JT%0h#{)bWCYBe{M%m%wQIS4)G^;(4{nHs2A zgg~Z-+8;`QQwe9%l?Kb9oN{bu&fX5*ojWz3^2ML>gw+2j>jU@pLOSl#q9-K%rZR*Y z+fz@)TTVtTM{@VBF2=&PO&4B1KAb?iU}>OB(A4UawkOl^qoidD@Ef?Rw@w2tU zM_#~G0Z^zKQd%S-W6qxDkuR$B(ws)SD0M5ojr~_d1O%NtPP0>31D7qOS-@AE@Foyo z<78~6V*HNg0g7E;I=rSyqa$qp$USYoKK6EKlOFN~RZKNNX^9Imq2lzFhgK!Tsfxx&4b- zo3`0u#3kZm9?t0#j_Wr4FZgY^U-r5Qdojx0&n`#U9*v{ikQy8KE?|umml%;YnE&(- z#WDb3up)hb2aTo|Q&MC%zhGdZQUx0AnH$7p3D8PSReD~~W|HkNx zvo6DEkbpBZ0`XhJiP9gJiMdj|z5It}qI03Lusx8wO z^yi}FUU&8CD_J{=+dZ&*M|afARNXd7QxrKf2e&M5D+}_ZHrKM{{4X<0@Q$mckNAD% z6HwW-cAIWG>HD%}&s!=}&Jmxz^Cjo@M6?rFworQRvuCy%=j8ST4mejVI}y-7w0&@X z>ez1bQp*dl(P~;ndjUf@1Wa^b!t2)z3#jMMiiPap~1%dgnLs@TbG-?1twV^nyShAC5jBe}-Q@KStGESXZ~ ze!@A!f#TWFR`@qlVFbD$2<~0iWLG$_&XoBJj`C4wBfFt7T4|Q@AhqB3IcN;k09Duz zjCnarF3SPDxDTrrt0h7Mzx)oO?wrnl%9oRT*}Lryy0|<ocw@A={ zCz~D~3yCrGm9TCUF(W2wT28FV;&`;yWq=-|<7eGB!_`TMHb3bM0g?^9V&tl7pQ=r8 zeAT@y!JK2}yid0jbywj(T=#Zd z>!TTKZInDxHHm&C0An4Nn;U9+j<-IXsoi>dHT;GfowS?W>F6l;dbO;Rn(HW}Ynj>8 z_K~ydaC4p2>lUmPR~%`dI6C`>y+XhXRwYQg*5kc>7p&HPW&4W^E9bDj}BtcM$bAVtQz z$@m*z+#SdJ6J6QKCM-5&56r8s`JU>QtCdQ(%RQK-TW$L6xzjkV_S{YF3aZ|I2($OG zUVqxsUGwNF`*{1s{+IU^+s&5oBPur z@mt?HfS`ST+EAFO{DpR7i+9@SqkCMrD!?ZUz_58Zr-qiq_X-wfNXkv!$%pk}tiXEu zy4&sMx9xUk44(cgEdxvPQ`hY3Uk0AmBLGXiuv?EG)?Ta2j`Qb_#lbS+?ux!ePAK#J ziF3~WGGU^n$x8Yq0D2c=miP`Jr?D5)LMu_vn*E7**-P1*8b3 zsJQ_Y$(Lqlmt;F&tRP>|FbNEW1S@3|ZL9B5(!sx^8tt6Ur8YrpJR4dAVli)q0#IL?f0Uzziy@$d3)N@?G1(=yz~16y&)%ndSjW@r@C9;}An1H$`% zAlc(qH<**C*fp+2j$d=y70N754wj}FzprakmU)s6%QC*wx-}LTH2)2EI7fPV!764L?uTBU zwMudr6f!Zl*zqKA=U1RX+OAvDeF9&yad9Pt7smSKDfeW|%|NbVawp(+2G%64U^~ou z%zPwPtJKAmf+y@-jAV0Y!DCf?2(-&Q^;!BHw+Q8o3#fFKM;WC(HvP!F>F2{6ih3FEk1FtpxJj(o3 zMO|7@$JXiWQfTVLgV((hTx8(?WiQJ#qJ_OT8D||=gqajv^GA6QciE zjEW)xi|8Le-gtj?jEY$Pe%*a*W1rf)1N!sFovH6e_qY3hT0R0?oGEu?@+*&D74?wD znoMBqAkU{q_r^hD*kWc+7F(XSHonSVsYKYufGyHx>NjVi>C&qj%H@w7`gC1Ly?j-fKf4(fEJybMewZvQ+he2?4;mp{g88^#OqJ?X5p*f z4~H#Bg4^6|=S?HeU~FRHUN_aQtXJKctq)`ydMYt3=sqxv6?jMw&!EiXii=ZFI7wRB3v=RoTo`m}moI=|7#owCNH#*@z zS{h%R*3GTDfXzC0UmxGG>kUY-Px{KK^YGqOXSp{iAC7o*>pG^xO#j#YM!)E_keAm= zK1&!)$Ku_<5-ICG0|q2()h_~&_8-qpElm}`yIo1{R>Hx7RYb3%327Z3!}b+zN(U(} zpMUhZP?%+qD>{cC{~&yg9htbDw9~TA@%DyUWtDtR1w#mz&k8XD&a;pU$cw?TV+8weeujXKYIe}+I z5$9JD;U0^nek%_ukW|c_ykGyGGDG+tUw$|Y5wc53RLhhF#Q%n35>)nK+Qgb(`Yh zbr9PsDsxaE8ssCSYH|AP)p!y*WOBP@n+l4YQ0GfPoWUXuFCu)2qBFrKbx1G zt)cG&8)t3T!Zt{xrCiANi%oddnqMQ+D#HUZDWsP=S4gH;$j*Iof7x(nmFO}P3=3V} zj%5yeIWa@M?)1)xVc4vyKHX5h5cS6v&R#3b=Mt8DjGU4TMP@=F!Fih*W?Erb$kOKt z#mb{?H&ZbSytsHB-aV+(4+fQd+Oq+*bf$2mujFn`Hg#{+xG<55-r{0RSVd2aFI$WC zhWmg8o9=_P&fyHtEEkh*cC&`|PJAL!DeixfF}C!`b#M{Ce6BPQW^$$6*~_jxzTVNB zQX<5CxSVToIuNo359dFtv7^(ZvV<)0&)Gl)Vc7r(5@*avzKUve{P-)%x*xQ|jv^`K zBR8p`*%)hMj0&9sH5Rx5A3X5KqZqdnzu|Ef8kH0R(l+?Lh*L*{?LOT>M|A4`uT7G%h3A8Q#zsFEVm3WJ^Sj#L*z_EM6??7JV?a}_|;ITH2s z@+)XQ8NX<@iT%cQS*I-}s7KWMF#=ILUnS7kts>{d0MOe9R!|%EmY1pqDg^Vc?l=tw z`lI^?vN|e^&=RW(nyq%KdJ6U)3Sy$##8=YKOSlUQy zeUOA5=$&z<6e6P`fg_Al* zGMg@)ohK1_IGWYKBNJR|ZK(7`_{sha>o%nmU^|ooH717vlyApaqX@GqfR3*`?c(NU zdBcgApxxmf*EU#Q<^Cw?tyk#nRmYJiZ-$Qy7`>sr865={i3{1d#w7Y-u13(l)I`01e*s4v|c8C?vh82r@z>WJy!I~E_?rG;K<|bj$9u~DeNVi5kN4zWkRj+4#$%q(pDLi$;|!9@t3kEl3&Ouj=mLFKG_xS3mAeFzf&HcX6 zHmKQ7F05A-i;u{)!GuloWZ`xa+mR#KK=kr@l)= z7fH3;68F5;M@2o-q6Vj0^<0)6A}La>?v|$({#2_(qn^r5j6_~6+^)SlavJ?Hx3?D$ zQKrG__quD=ymtpW&CMC^nZn@96rFY<7{CSqnG$bCnh%TomP=n$IbAdDVu3r`{ozBN zfeQ3{!}Mq+Jysl^#xOQvU_mEc{M}-`Oolv{!w6D^zA{HqVBEQk1|;T-Ga#NzOFyLm zd)Z6E)V6$0xR%ce%7Z1PP{IDx%P3-&g7nr35@^+8?sK4lJHIOO;bt6QnzJkz5()agN zj0XZNzwW3;029g#gl}P3K*2;SM`mBD9fOJS1f11unTp?(vf{?g~s=M`5QF^;W8 zo?3ZdJT7N;vteuC9~#a$!xm?AyBp(y20=-O^ znC-cIF-OGISH;mG+Nf}~3f^TuwH4^JDz$657A|O;I;>88Hjp~_b*YmoE~Xy@nEN&d z+DVz?hVFiv;ry(DBr9Rrgfwbx@o2v6iR4=k0Z(#`RUCP-j=vDIxw==3h_5|eZ{5qD7a zG0U@4s2AsF@aoec_1#4E3X%UQktlPcH^W2iBYz@pRilKu>uA+F@A`??St`1T<}+p> z>lN;g)#FL71nuj`-}~tGts&Z!pW3%?iThx^+xeoWyfGYo&M#JXWcu*#9iIF6S#`gB z_*P0-Z0+=ZmtGwpd_Nj_`*@txq*nR0?hE4=pK6356g`h6oXW&MPcA3u=qE6jw*?Df zQ7OJPBa*#)CB0_<%;eAk+kqjEPQrN@FlOxLQ)}iEgM5wrE7jlCSDR3j$Hbcs zxF-?j#@{|~pGMESJm3;knYfkSj$n?*MxzXN%{BZYKks(;voTM}P6AQ940u1$pAvcHnD* zND*J{AZv9)286Wbbr_6%BL8m4l)uOf2L?xco}KvPDQ-I7*3!+AnC^wU+_ZJyyyg#s zkmY_s`5mI(m?DKo#6La&HP#Nh0!JlqM)An*=yCasxI}unKYRVGaw>keJBRelf~$KX z+mor0{F9fk>V&EMF}hcWL1Y9ZBG=VIpCQJ7QDsS05`9=CH8wOelk0neHoS*P^*B{` zsoIQE{rpPt(3HQ=E8e$iFKkK~N)_@nn2d+!JPr&jTn4*vmQb5Gs%>7R$~3TSq=;O+BPT<@XFB zEM$w*n(&<4#)m@>WD9q&JT#F~+z|SXbM)Oxj|pd30TOZq-D_j@v{*n?TzymSr?l81 zI22g~f%AEhbT%t$A#t~=(0~Igjnov9ZQTth4Fj=eVSzipfP~jWd{a{A_jc#9?_}@r-YG{Bb!yN0_K~E5 zjf90|U>(kT<=~LBtz;$tAl+ja*nHYx!}6g+=zdPGVdsI#ch&B#57hD)uXTI$ck0r7 z*m3X*O1K8neVXLn_9s*B z2eK4v7--mp3^<@r8J)fOEf&{>M%KdAnU^OfWpN@A6D7?-uAn*2K*r)%M)NhEd9f#f z4zdX-y{$iDlF7R($_oWHOZNJo2O5^Pe5+L?^~l0YA^pOk<(Tfss{QQPAUVjrKRK>< z@R_ z&$ML-2@wNXCCew=dvO_~5!vEZqfKz7mqh(ztgu6kk%)n=uS+2XcA?aAt-8oC;=d0v@(ymB&D$@`O%AAMoJkzb z$xh7Ew&}b#l;4PCLHyPzRX1rHRpm&LeJ1FKzWrz4{_{&5NK5fuf&yFedioPGqiD|0 zKyvKC6sAA&fQ{l>J^e4W>}#2J3?#&Ip=1G!d(2_9kOkRtHH>BVtW6H`(}ns|8EL`S!o)5^M1WFQ>|G2uA+J6 zV*JaTkMzs{4!#{z3!~Q_frcF6O#_;xv&<77;UqGKqBNsf!m;C6PIy z=PVMU)ov;ihK1n|^!9U@`~LRc8OPUG$AwuBZI1khdnTS2e?NIrW5erAXGBlEFUKFk zll0z930rNu9$>4=qP*45bTKhjXpB5v6v{o%!+cj?Y!PpIVloE|S4{?~pfG_6z?}UT zcS2z_c3JnS|0AUaH5o9{!oeJ)@LN*>k_({}#RQcSyqI}5G1PBVHvgQSE7g?|&Q$Sd zs6{zMZox`zk9@wQH(Ay`t=&_d|7iw>;d*!t8)17^_~Au@LNc;g+LfR+WjJe~WN9;` z9JJahJwW@0kaeLB@C{Q1<8Bdxygl;E(Em!mu$VW$Yp&volz!Zg4(07gi zbTL28sZJ*?Y2jT1-%dZW|L0te&kb+rWglf`|NGUn?-rjao3cM)YUR^beM}71+SdR< z@jndGLWyVJa)!TC5;$87M*ZjAEN{(9i|O2YhSYtB0wpN&a&_ z+W))rKXbBU|7E?JnzR%~zDF(B&;1BiTZ`aw?$l|7XszVj{ zKZghhU{_N4=9$xhp+7{FsHx{v)Un&Sd&ixy7mAafcKhVsPd68>jbU9g@o|((w^O?m z08DfgT6yoH#SAOBUB*Qa{w8Z7%EPLZ7`DUdUI_gF#P$J^*SZkh{(_Y%eL* z2pESrFt)ui`NZdUwMly$Be- zlbzCTkjrnFKVe-5HA`@*{`Yu^Og@Hq_%0Lx3$kVuoQl$#%~;9CCCupHO(J=o>`*G| z6Sf=aA4%M8lTAvsgaq0F+vBbdXBPvMFbV-WFHfKLGXszRT()yd?3`PmkCI7OLX#d* zB!6EMR>^V(smfIjfrbNc&eHNEjG`nPHT%0Ek)AR1}1 zitmh!O!Nd^d4`Evt*qPEjV;o1q$~L~#ZtB_tS;-Oil;?y54H; zoG{e26$$B zGOW-o12xD{>>C&uBsS_z?%o_gEX@KmN#OZBraoS8T{*cVPVVi=Pv_AEk*yTmovcp6 z=FDa1zDQrL8vOIGbzpbkI+BR51u`vJrJY=9F;LS&I+)RoA_nNv^a&G1By4_;kaSGv9uM1-99n1 zyfq4wnc~C*b5}0u)Ofp<3R=Gcbpnh*&#g3NqutY0XMehGT+7ClSDJGh+>dxQ+xp~I>?D@|?UPzM_0dZ4xg}!!UxiXz78HW))wOUNY6IQ; zn8HJoF`ZviF&&wj^#wCdew3wrKHluWpQ{Q4J&!7}J)GX%8IAl`4Z@sWB~HgtGf>Y9W?r-1L#88(p=vG10?jPz?ulgp!&XESw?A>2RhgVm7j~%vqOHWF- zSF1iC&xYJajNm7pvtlK5y&$Db`jF2xx(PBtzU`wC~0b#0%LG3>ZY#>O1^cM7Cpmy)-jScwCL zr7HM1;&WQenwjjZLhI!HHQ~}T`r)rHI`Us&sH-QDeVdf5j4g#!lc!!d#1r}klMjaJ zf98*=40k^$4ks=MX5daf58Hx_W?{7So~>D_x#6;6o6%+qz-So$`cg6lL@oDgAfgm! zc4I6RjedSj-IzvUHH!5t6d7YT{su7cG#ddfP+IcCNO7r8; zLCc7d@tJ>C3F!Nznnw$xT`AZ;hil2wCZkMOkR1T8E)wF>3@aTP8VCLv9>Ym}&mG}C zx@Kd*G~I?*9RVsfz>OmannQfh4G_6`qqAfoYcmlt(Xba z_~@zZVFUwg5wEI-qu)VpLykmvjmk&cC-c6`^I=rVa@@Z^YL@;VMmKC2qZ_M=B&9@M z(J?X{L5|E~C@P#73jFyP(eTudG@fEX38tOyxZf6;-*pH-5clnJZdKV$-Zz_{<^D?| z1nBFg@REELBDIr%DM|NMN^yAqO<)vZ_ovSy8K1gEgFjNk%&U;wR^5A6-^C^)mA8j+ zPrcH>C^5Y}JCLdTE&S)ZssAbipbtv%w%A7V?Oj}BY+Y<=L4R{F^GszPy<|qQf(dKs zF8y2Ija`gp7V1IUTZx9l2IeU1C4aA!gi^<{|J|Sg1t9*K$m^6G?JibkM@>+;!$JMa z(&?AT2lN~qS!GEnHEc(W{3Fg3rg(M8Mf8DqE+3au?5aI+FE0Q0$O>e)^gj$xU@Ilh zV#~mN4nQY6?D8A0CVDkTP}7opQ@k~N%y@jMFX4>&h#~}@8T^Af zAF-XzVsm51vxEzaqk)1$AP8GxXQj<|rMiULQmn+aBno6%`CWNCM<-u`(1-hSX=L?S{C*<>vkPaL53BZC-r}sk76< zokq=Ng4;#EWe~%}EeI%uA}!`{ORM$oO8O7ivRT!%{Z}aPFvi4zG|q;1>J6C%ZH`gb z*AgX0*jqi*Ad_qJ1L~ecgmo!Iy>xNu*l;?DC<`ocOsQ*dk7g5UVUFv(6qON9gA--q zzNp~LZ@GV^qkv#lCXfFFIZqXVjB5#jIARDmd%3{R^I5sgpXrwrA0Wgc+yi{db?`}U zZ;r=BuQBt;SGPqO{-C$V8$x4KylYbal}{1ILl>VW>`sWS2%uY6kr}0o? zHE+~NgRB2G2N!`_D?fqs2R}01E1=H(u9}V=h|nM)S7|Vg86aOMwab=Or+Us;fYg!$ zbV@_1%+`?WrNAfLG%f(y##}2@e zesb^W`~|-`t143_E66CmK?z7or^M8%#>ZNhVrL{#zCv;k$yXQ{ST2F|j^j&83ZbS6 z3l2%|#|dyk69Z@pXV55yM7iz($d%axilf8+U+n2q(R1s+LJLm+O1z z>^~l8TY`2`+Ja+2$&4OTI{AAr>pPiZE0Jsc1 z;U(oQUU4wxU2L~md_t2$`aXOD4xz;tdVQh(sh5={3Y`vhxlCGg)nV}c;%XB~Xs>aa zWUNKA{$W@Q=Xt~X(ozoJAopE$O=>JU7OiFD0EQ%d=toRFld1qZ4?2{!cfv=aM~ac2 z-3G2o4)e71gWBoYG<3nd%W!DwKpETM36K)%CIoy-_rvx4)X z5gwV3gWZ82XUu3S^SY)%J7PCI-QV;x4ov4AMj1zt5(UaS!!(LQdCJE*qsZb>1DnaM zpbFxtg(5m}4)6cRg$gHvFbf=z?fnTur3i{d2?7m~>(%A3ar|#b@rf2WD@z?OFcF&q zsKZ_9_vQ(Jgg_b&oO;Hh3F5tvVCP+5^EyD3=T4O}k=5_< zGtFdnktp*~02P2GIZ(Vhy(J>|SAFi^IS5Sb$bjM*{FYbn@!oPYd-O^&g~ zmZ?w2GlZj|v}lc37h={81Ge6!cS=t0I)$U^i3npD{=2gXVih$CWZB%YK-}^)djB}` z5s65pYV{Y$G*(!tQTsW{qq+gC*h=FQQg3dGyxhjH6Ss|$%DsNPC?Hs@3h#!al2~hS z;;J>~JzInu7XGkK4rbOaV!P~)ObvDtzLB}K|5*ib&b*=zQ}Y@t6afayuN538S0eNM zcva*+-xa?|#6YK8Rd^_2nTW$@tr6P3rvJ97$2P=l1F)4lZ1hhMh41*)e?^3TpB}p` zei*1x*r~K#h{;3ZVojq<$Feah8n7t2fE7+hTgRr7EeF9qT0k4J_||U|1@89v09Bpb~1WFMXb!3#sKD; zj7#(OjWHk%XG$VFTpE~7Cj}qCxV^wE|7TQtr5XcDx;aBD&S_ums?tvg&-;32DzZzn zb|-a*f-iDPM3P~P@v>VG`?N=J;Z*WC3tlB7dHZHOJ&0Vdn$VbrmVQ6J7=*PG0+|>2 z?&x3kTF9l?5458R32XnKo16?Y^zHaNjt^wH40-ILFO8`ejA+aI#rAQaL^e67JgU1P ze*Vx2v@IMQ%IPXh9J1!|%#_MJ-RYzEbR3@%!bL-?{AFI$WtcP5nn)Wa{3( z3}&49u-!DJkt0zv+L%dKvC%W?q^%C23a>mzey|gypG=sFTqlYpvx5f!_)D>i)l8sW$4@n<@;a+7^2v!v zYJP`>O+iYR4F4Z0`~Inw9rICxjzSXx=;3*T{>(vgWF0L5QxxB- z@j7og5y#id%_Crv8J+T*EAAj2=6oY3q!JPAjK0gB2cF7xBhR2iSt%X7w9)F(wEh)W zL{teSCKwZGfXuj>PV8>C!I&5q7>lU`6_(_p^<}D2jM*nx+&Ol@s8%sF3l!)~Jc}gk zllmAL8kZ)XRr{$q%qsK@IbuPVivE{v4o@99NoBd2(jp5E#VlJCv$Jj!FRGnxznv{+ z3chndgead1wr`;!Eq+AwjG*hs;T-NuyuKw`BStHHP(DUddV>^fq~h!*KJb+2doX4~ zJwI-OaSU?DbmcVqSPG}i|3ogd!1ph`sxvt>lC#aa5OdXOD;408s5W5&2bf`=Vfb}uI`w1~B4PyH{fZW#CbXg>9H|T%_qUUo z^mPp%KL2Pi1P-U(52bAk7kOn&q9}a}M3}B|l{*x+whRBsg`OrYq8Q@+i3&(57!wmg zlnzF1n8x_7R;m#t#&HRJ8F^ReIFB8`c>5^_ol3J4ZRyhA>)kD;OQ_ux2K~rupwO*F z@9nN!Aopf z32?|js?ZRTQ*|s6q#Gg+NNi8O;t~q$MEwsJ?VJYf!g{xnw^~YLYj!Q%E1rj?JA&$g zz&riyGXt!M9GR`EF5dfWo?6!zhe4NlZ?-Ti%w*1}4>SzIP>osYg*s1bN@z##O!T;m zuvB_Z&wpBGh}#$4>NgwceF*#J@b632!!IXCd^!BdQQ+e=v?RY~|E9!{q?bz541|-^ zLBa?S#`MTWjN>5CluTZuZNOp-%`9G*460p^=DpOM(JX0EFdwJMNi@QJD6tLYPM_va z+rytAf`b|@vfhRS1^9J(q`4Dca)#Ld)tR?G{ht>E z!U58-hp>T?`7-ZEX=#u@hBG7od4ocgSu7lTE(BmjatlfFN$a-fV zUCd3z&n96R7r>aiP!TB;7oQV<7X!)N_wJO+Qy-ar*MXd5xY2)beHvyIU&F@;{F%xYGo6HAO!<{I6P%(< zBi$fHRNfe-qj{kbk^9gHKwLzVpSPN0N zpH*18=qEx0vPbBo61#tOGIdPBZz#B{Yh^xe&=GI{4Z)}nPB)f$){s3vyK%l~u%two z(TN{-xc9$|*5Jf#$(JP(N)ds->x#_Jq8kx?C&$~RAZP(;fyGgfEQ@L7@$7f;c`RR9 zalf|;2TgxxH{dOqu!YU7F7P08pWwW`$rVb3>NY)T9FkhIzl$-!Y^%frie{Mk>xMn5 zKykkJEx6>0~C(PlCUraR$C)(LrM z*ZvWyH`!FN4oLQIY!5I&aN~XTKX2G4lXSjH{P*A&%o+dN0E1;$$r-)>&;Ce4o%e*0 Yg!FYM*w4N61?)!>C?{GjtpDx*0OEqzA^-pY literal 52503 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�%4J7r&q{vBP87$Errn}5cY z^fCR8IUzxsnotZO1YorLLJf~1>up&azclK|5eOL;U1edf+M@A*m%1>{0_|x2b@b=% zjWSV&N`j1v|D3RrJf33w$Lf(KmZ>k1zazi9bXI%|g zYVs@ZIs)d&hZ5`g>#Z#nfK$H@hn)euu8aoCq`am*b0K?C z1FA{}Iy?78OD4sgv7}QvSVENKr$(93I)~Y9YkVX3c#2W{rI1{@dZHQWZtAd!)5QMI zaGvAG0P~lBK+rg|33bnDKdq-+C#G91QtRQ+2`4o*RdH@@#jTBeGfIj_>Q;QE3<#uY zLfFN`oVvztiMa!I^B@$4bZTuEf z2cO2u${t^jw&TCt7M9ohl1}KYR=4Hv1a#KOT?yc1Zk|o}iSHj%F)((fKcpPhJp(*#m-uS_E#;vc1|tFU=}O;xP}N2AA^6 zUS+I+#hU1t(hN_vvxPe^^*8D+sOsOj%{&@Irahq;>osLd>}prsYSAB!LS`Smex5C7 zU2sW7Y()KbDfJeUm7!*W)N#!Klfc$tUdqTqm@L z3S1I;QT$A@Z{fl0rRjn4#?xPS{*7_N#9o~Bhw?ja{=+G_)#V>C6@mq5e$DWPdwZg! z3D;;HZ$n9$R*N8JDwcK^LsCTL6UE}9?B`Pp$UJZwDU6l<`MYdqt^Ct2ruteWvdLA4 zttE;wr=|*Fm;JX;Ld3^(PCJuAK`D>M?SI_jY6S0I)3A~lp@DZ;NPfgt=d`)XE^OeOFb zlDX{08TatsqCY2<PR@o_sTQ`lHIo$|Yw~kX*c>;U^NaVyYT; zbBJ36+x<8_1$L->YGWjz*>s(NXHYT=-=3%LCBGH=;5)iEfoX`cGS03~RLvyrOz9%D zDAhe6?B%TTUVPX#VzLnzqU>iE6 zfvxsu?-ODk%8{Z?bWc07WV3w!WFjCAs0+SOptmFxNZktCfJov{wQBa@TnRfDnOA&$;AjGik*Ff`!$s=Dg zje?@mhU^-lf)^<)_s1m$K!vAXnNI}3&E#S^zc9T-`motfm=CUA7U0^PwEwt>aqy3B zTvJ8n=r}65#!)$NszMJFS%TzO!+nHo(Wg3%c)Rombidn6CmH$kZt-zC)U}vZ*B%Pe z@T>mqVt5tZr?4TJ`-S#nNhW2$6*iK?5=~wa>XMRQlqGAjXsIZT5(iOoD&@j(&@jFo zmZsGw4)BI*d%IWkxnguy0uPwf8|%qX^Ud)-y5G(~3h=~o5;}-`J`9hrdWZL179nIZ zZnMfvbrejTk3=grF9)RTTt%GwNlQ6q>Cfn-#{uZv(r2o4=@v{H>w9DdRN$lzQ}rzL z3WXXhj{ZSG5uiFc*r7bd&Hu$e&$vmSC*E>Z_8WQJdov6XNAiAIRs6M}6l{-{SDP5< zZ>HE%qd$#_S;L-n7pA|ZvL;9ZZx*d)gF44KYiJ3^vqR_VjdU?{-XihN5XzsGKinF| zRE0mMm-P~e&2@{n62s7_5_nk1B|r#vB&IyC$A~@*BqopQvG7&Tb{tGalnD7~cvNQM zNIP^Ta|?`{OXV}RYI60dB&(Vv0^n<4jv_∓u|Ia13oo7dGn6*Z(FY-qoHcOd@gJ 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 FO#lo>@U{Q| From c8d70c4966cf4a4dca4e7c65702574523db0ac7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20C=2E=20Andersen?= Date: Sat, 18 Oct 2025 19:33:57 +0200 Subject: [PATCH 12/14] Fixing references. --- README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2f4a285d..32370909 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,8 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) [![PyPI - Version](https://img.shields.io/pypi/v/pg-mcp)](https://pypi.org/project/pg-mcp/) -[![Discord](https://img.shields.io/discord/1336769798603931789?label=Discord)](https://discord.gg/4BEHC7ZM) -[![Twitter Follow](https://img.shields.io/twitter/follow/auto_dba?style=flat)](https://x.com/auto_dba) -[![Contributors](https://img.shields.io/github/contributors/crystaldba/pg-mcp)](https://github.com/crystaldba/pg-mcp/graphs/contributors) +[![Twitter Follow](https://img.shields.io/twitter/follow/AndreCAndersen?style=flat)](https://x.com/AndreCAndersen) +[![Contributors](https://img.shields.io/github/contributors/andre-c-andersen/pg-mcp)](https://github.com/andre-c-andersen/pg-mcp/graphs/contributors)

A lightweight Postgres MCP server for schema exploration and SQL execution.

@@ -23,7 +22,7 @@ **Postgres MCP Lite** is a lightweight, open-source Model Context Protocol (MCP) server for PostgreSQL. It provides AI assistants with essential database access: schema exploration and SQL execution. -This is a stripped-down version focused on core functionality: +This is a stripped-down fork of [postgres-mcp](https://github.com/crystaldba/postgres-mcp) by [Crystal DBA](https://www.linkedin.com/company/crystaldba/), focused on core functionality: - **🗂️ Schema Exploration** - List schemas, tables, views, and get detailed object information including columns, constraints, and indexes. - **⚡ SQL Execution** - Execute SQL queries with configurable access control. @@ -301,7 +300,7 @@ The instructions below are for developers who want to work on Postgres MCP Lite, 2. **Clone the repository**: ```bash - git clone https://github.com/crystaldba/pg-mcp.git + git clone https://github.com/andre-c-andersen/pg-mcp.git cd pg-mcp ``` From 808682a626f8fe640ffa4e37ff1ca0749e157934 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20C=2E=20Andersen?= Date: Sat, 18 Oct 2025 19:37:10 +0200 Subject: [PATCH 13/14] Reorders table of contents in README --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 32370909..8c98e91f 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ From 7c4937b5b7c527ef31ad8b15cec83a50b1177914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20C=2E=20Andersen?= Date: Sat, 18 Oct 2025 19:40:52 +0200 Subject: [PATCH 14/14] Getting ready to release v0.1.0 --- pyproject.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1eaced03..ee11d8af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pg-mcp" -version = "0.3.0" +version = "0.1.0" description = "Postgres MCP Lite - A lightweight MCP server for PostgreSQL" readme = "README.md" requires-python = ">=3.12" @@ -13,12 +13,12 @@ dependencies = [ "psycopg-pool>=3.2.6", "instructor>=1.7.9", ] -license = "mit" +license = "MIT" license-files = ["LICENSE"] [[project.authors]] -name = "Johann Schleier-Smith" -email = "jssmith@crystal.cloud" +name = "André C. Andersen" +email = "andre@gnist.ai" [build-system] requires = [ "hatchling",]