From 900096ca89eb959fa33d86b6e0b3b802c1b19aa9 Mon Sep 17 00:00:00 2001 From: Nico Date: Wed, 25 Feb 2026 13:16:48 +0000 Subject: [PATCH 01/14] Fix timeZone default not applied when parameter value is None When search_logs is called without a time_zone, the SearchRequest model sets time_zone=None. This None value gets passed into search_params as timeZone=None. The validator then tries to type-check None as a string, which raises an APIParameterError. The fix skips None values during validation so they are treated as "not provided", allowing the default value (UTC) to be applied in the subsequent defaults loop. Co-Authored-By: Claude Opus 4.6 --- sumologic_mcp/api_validator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sumologic_mcp/api_validator.py b/sumologic_mcp/api_validator.py index 85ae755..31c8f66 100644 --- a/sumologic_mcp/api_validator.py +++ b/sumologic_mcp/api_validator.py @@ -135,8 +135,10 @@ def validate_search_params(cls, params: Dict[str, Any]) -> Dict[str, Any]: api_endpoint="search API" ) - # Validate each parameter + # Validate each parameter (skip None so defaults get applied below) for param_name, param_value in params.items(): + if param_value is None: + continue if param_name not in schema: # Allow unknown parameters but log warning validated_params[param_name] = param_value From 5f66f3d501deca30e08c467c14dec4a6f4058b8a Mon Sep 17 00:00:00 2001 From: Nico Date: Wed, 25 Feb 2026 13:17:24 +0000 Subject: [PATCH 02/14] Fix time format: use epoch milliseconds for search jobs API The Sumo Logic search jobs API (/api/v1/search/jobs) requires time values as epoch milliseconds, not ISO 8601 strings. Sending ISO format results in: "The 'from' field contains an invalid time." This changes to_sumo_api_format and to_sumo_time_format to output epoch milliseconds, and adds epoch time pattern validation to the API validator so it accepts the format it now produces. Co-Authored-By: Claude Opus 4.6 --- sumologic_mcp/api_validator.py | 9 +++++++-- sumologic_mcp/time_utils.py | 25 ++++++++++++------------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/sumologic_mcp/api_validator.py b/sumologic_mcp/api_validator.py index 31c8f66..454f0c5 100644 --- a/sumologic_mcp/api_validator.py +++ b/sumologic_mcp/api_validator.py @@ -107,6 +107,7 @@ class SumoLogicAPIValidator: r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?([+-]\d{2}:\d{2}|Z)?$' ) RELATIVE_TIME_PATTERN = re.compile(r'^-?\d+[smhdw]$|^now$') + EPOCH_PATTERN = re.compile(r'^\d{10,13}$') @classmethod def validate_search_params(cls, params: Dict[str, Any]) -> Dict[str, Any]: @@ -336,8 +337,12 @@ def _validate_time_format(cls, param_name: str, time_value: str) -> None: # Check if it matches relative time format if cls.RELATIVE_TIME_PATTERN.match(time_value): return - - # Neither format matched + + # Check if it matches epoch time format (seconds or milliseconds) + if cls.EPOCH_PATTERN.match(time_value): + return + + # No format matched raise TimeValidationError( f"Invalid time format for parameter '{param_name}'", time_value, diff --git a/sumologic_mcp/time_utils.py b/sumologic_mcp/time_utils.py index 19cb59e..31de280 100644 --- a/sumologic_mcp/time_utils.py +++ b/sumologic_mcp/time_utils.py @@ -201,30 +201,29 @@ def _parse_epoch_time(cls, time_str: str) -> datetime: @classmethod def to_sumo_api_format(cls, dt: datetime) -> str: - """Convert datetime to exact Sumo Logic API format. - + """Convert datetime to Sumo Logic search jobs API format. + Args: dt: Datetime object to convert - + Returns: - Time string in exact Sumo Logic API format (ISO 8601 with milliseconds and Z suffix) + Time string as epoch milliseconds (required by Sumo Logic search jobs API) """ - # Sumo Logic API expects ISO 8601 format with milliseconds and Z suffix - # Format: YYYY-MM-DDTHH:MM:SS.sssZ - return dt.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z' - + import calendar + epoch_seconds = calendar.timegm(dt.timetuple()) + return str(epoch_seconds * 1000 + dt.microsecond // 1000) + @classmethod def to_sumo_time_format(cls, dt: datetime) -> str: """Convert datetime to Sumo Logic API time format. - + Args: dt: Datetime object to convert - + Returns: - Time string in Sumo Logic API format (ISO 8601 with milliseconds) + Time string as epoch milliseconds (required by Sumo Logic search jobs API) """ - # Sumo Logic expects ISO format with milliseconds - return dt.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z' + return cls.to_sumo_api_format(dt) @classmethod def validate_time_range(cls, from_time: str, to_time: str) -> tuple[datetime, datetime]: From fab2a72756dde87680e64dd1ceb5d5a7aba2df68 Mon Sep 17 00:00:00 2001 From: Nico Date: Wed, 25 Feb 2026 13:17:48 +0000 Subject: [PATCH 03/14] Fix result retrieval for aggregate queries and empty results The search results endpoint always fetched /messages, which fails for aggregate queries (count by, sum by, etc.) with "requireRawMessages is false". Aggregate query results are served from /records instead. This changes get_search_results to try /records first, then fall back to /messages. Both endpoints are handled gracefully when empty, so queries that match nothing return an empty result set instead of raising an error. Co-Authored-By: Claude Opus 4.6 --- sumologic_mcp/api_client.py | 47 ++++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/sumologic_mcp/api_client.py b/sumologic_mcp/api_client.py index ed10d6d..e43aa80 100644 --- a/sumologic_mcp/api_client.py +++ b/sumologic_mcp/api_client.py @@ -819,37 +819,56 @@ async def get_search_results( search_state=job_state ) - # Get messages (log records) + # Try /records first (for aggregate queries), fall back to /messages. + # If both are empty, return empty results gracefully. params = { "offset": offset, "limit": limit } - - response = await self._make_request( - method="GET", - endpoint=f"/api/v1/search/jobs/{job_id}/messages", - params=params, - operation_type="search_results" - ) - - results = await self._parse_json_response(response) - + + result_type = "records" + try: + response = await self._make_request( + method="GET", + endpoint=f"/api/v1/search/jobs/{job_id}/records", + params=params, + operation_type="search_results" + ) + results = await self._parse_json_response(response) + except Exception: + results = {"records": [], "fields": []} + + if not results.get("records"): + # No records — try messages (may also be empty for no-match queries) + result_type = "messages" + try: + response = await self._make_request( + method="GET", + endpoint=f"/api/v1/search/jobs/{job_id}/messages", + params=params, + operation_type="search_results" + ) + results = await self._parse_json_response(response) + except Exception: + results = {"messages": [], "fields": []} + logger.info( f"Retrieved search results", extra={ "job_id": job_id, - "returned_count": len(results.get("messages", [])), + "result_type": result_type, + "returned_count": len(results.get(result_type, [])), "offset": offset, "limit": limit } ) - + # Combine with job status for complete result return { "job_id": job_id, "status": status, "results": results, - "messages": results.get("messages", []), + "messages": results.get(result_type, []), "fields": results.get("fields", []) } From 9a0890d89e4c4af8afe4ca254bf13f7942ea7984 Mon Sep 17 00:00:00 2001 From: Nico Date: Wed, 4 Mar 2026 10:04:59 +0000 Subject: [PATCH 04/14] Fix list_collectors missing filter_type parameter The tool layer passes filter_type to the API client, but the client method didn't accept it. Add the parameter and filter client-side since the Sumo Logic API doesn't support it as a query param. Co-Authored-By: Claude Opus 4.6 --- sumologic_mcp/api_client.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/sumologic_mcp/api_client.py b/sumologic_mcp/api_client.py index e43aa80..d1f17ca 100644 --- a/sumologic_mcp/api_client.py +++ b/sumologic_mcp/api_client.py @@ -1559,16 +1559,17 @@ async def get_metric_metadata( # Collector and Source API Methods - async def list_collectors(self, limit: int = 100, offset: int = 0) -> Dict[str, Any]: + async def list_collectors(self, limit: int = 100, offset: int = 0, filter_type: Optional[str] = None) -> Dict[str, Any]: """List collectors with pagination. - + Args: limit: Maximum collectors to return (1-1000) offset: Result offset for pagination - + filter_type: Optional filter — "Installable", "Hosted", or "All" + Returns: Dictionary containing collectors list and metadata - + Raises: ValidationError: If parameters are invalid APIError: If API request fails @@ -1605,17 +1606,23 @@ async def list_collectors(self, limit: int = 100, offset: int = 0) -> Dict[str, operation_type="collector" ) - collectors = await self._parse_json_response(response) - + result = await self._parse_json_response(response) + + if filter_type and filter_type != "All": + result["collectors"] = [ + c for c in result.get("collectors", []) + if c.get("collectorType") == filter_type + ] + logger.info( - f"Retrieved {len(collectors.get('collectors', []))} collectors", + f"Retrieved {len(result.get('collectors', []))} collectors", extra={ "limit": limit, "offset": offset } ) - - return collectors + + return result except APIError as e: raise APIError( From afe78fd331d5e3c98a2cd1113e38c39d2e64a437 Mon Sep 17 00:00:00 2001 From: Nico Date: Wed, 4 Mar 2026 10:22:51 +0000 Subject: [PATCH 05/14] Remove unsupported filter_type from list_collectors The Sumo Logic Collector API does not support filter_type as a query parameter. Remove it from the tool schema, tool method, and API client rather than faking it with client-side filtering. Co-Authored-By: Claude Opus 4.6 --- sumologic_mcp/api_client.py | 9 +-------- sumologic_mcp/tools/collector_tools.py | 26 +++++++------------------- 2 files changed, 8 insertions(+), 27 deletions(-) diff --git a/sumologic_mcp/api_client.py b/sumologic_mcp/api_client.py index d1f17ca..7d5060a 100644 --- a/sumologic_mcp/api_client.py +++ b/sumologic_mcp/api_client.py @@ -1559,13 +1559,12 @@ async def get_metric_metadata( # Collector and Source API Methods - async def list_collectors(self, limit: int = 100, offset: int = 0, filter_type: Optional[str] = None) -> Dict[str, Any]: + async def list_collectors(self, limit: int = 100, offset: int = 0) -> Dict[str, Any]: """List collectors with pagination. Args: limit: Maximum collectors to return (1-1000) offset: Result offset for pagination - filter_type: Optional filter — "Installable", "Hosted", or "All" Returns: Dictionary containing collectors list and metadata @@ -1608,12 +1607,6 @@ async def list_collectors(self, limit: int = 100, offset: int = 0, filter_type: result = await self._parse_json_response(response) - if filter_type and filter_type != "All": - result["collectors"] = [ - c for c in result.get("collectors", []) - if c.get("collectorType") == filter_type - ] - logger.info( f"Retrieved {len(result.get('collectors', []))} collectors", extra={ diff --git a/sumologic_mcp/tools/collector_tools.py b/sumologic_mcp/tools/collector_tools.py index 7cc4566..b9ae872 100644 --- a/sumologic_mcp/tools/collector_tools.py +++ b/sumologic_mcp/tools/collector_tools.py @@ -29,17 +29,15 @@ def __init__(self, api_client: SumoLogicAPIClient): async def list_collectors( self, - filter_type: Optional[str] = None, limit: int = 100, offset: int = 0 ) -> Dict[str, Any]: - """List all collectors with optional filtering. - + """List all collectors with pagination. + This tool retrieves a list of collectors from Sumo Logic with support - for type-based filtering and pagination. - + for pagination. + Args: - filter_type: Optional collector type filter (Installable, Hosted, etc.) limit: Maximum number of collectors to return (1-1000) offset: Starting position for pagination (0-based) @@ -66,15 +64,10 @@ async def list_collectors( if offset < 0: raise ValidationError("Offset must be non-negative") - valid_types = ["Installable", "Hosted", "All"] - if filter_type and filter_type not in valid_types: - raise ValidationError(f"filter_type must be one of: {', '.join(valid_types)}") - - logger.info(f"Listing collectors with type='{filter_type}', limit={limit}, offset={offset}") - + logger.info(f"Listing collectors with limit={limit}, offset={offset}") + # Get collectors from API collectors_response = await self.api_client.list_collectors( - filter_type=filter_type, limit=limit, offset=offset ) @@ -119,7 +112,7 @@ async def list_collectors( "limit": limit, "returned_count": len(formatted_collectors), "has_more": has_more, - "filter_applied": filter_type is not None, + "filter_applied": False, "collector_types": collector_types, "summary": { "online_collectors": sum(1 for c in formatted_collectors if c["status"]), @@ -776,11 +769,6 @@ def get_tool_definitions(self) -> List[Dict[str, Any]]: "inputSchema": { "type": "object", "properties": { - "filter_type": { - "type": "string", - "description": "Optional collector type filter", - "enum": ["Installable", "Hosted", "All"] - }, "limit": { "type": "integer", "description": "Maximum number of collectors to return (1-1000)", From b663d48c88a22f247464be03611d3a2e802621ed Mon Sep 17 00:00:00 2001 From: Nico Date: Wed, 4 Mar 2026 14:51:50 +0000 Subject: [PATCH 06/14] fix --- sumologic_mcp/tools/collector_tools.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/sumologic_mcp/tools/collector_tools.py b/sumologic_mcp/tools/collector_tools.py index b9ae872..eba0e8d 100644 --- a/sumologic_mcp/tools/collector_tools.py +++ b/sumologic_mcp/tools/collector_tools.py @@ -168,8 +168,13 @@ async def get_collector(self, collector_id: str) -> Dict[str, Any]: # Format collector data collector = collector_response.get("collector", collector_response) - # Get sources for this collector - sources = collector.get("sources", []) + # Get sources for this collector (requires separate API call) + try: + sources_response = await self.api_client.list_sources(collector_id) + sources = sources_response.get("sources", []) + except Exception as e: + logger.warning(f"Failed to fetch sources for collector {collector_id}: {e}") + sources = [] formatted_sources = [] for source in sources: From 8945b35dc39ea4c063643fb262712e7857ba7d7b Mon Sep 17 00:00:00 2001 From: Nico Date: Wed, 13 May 2026 11:37:20 +0100 Subject: [PATCH 07/14] Stop circuit breaker tripping on benign batches of work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to the sumologic-api circuit breaker: 1. resilience.py: reset failure_count to 0 on a success in CLOSED state. Previously failure_count was monotonic in CLOSED state, so any process accumulating ~5 transient errors over its lifetime was guaranteed to trip the breaker eventually — "5 ever" rather than "5 consecutive." That's the wrong semantics for an MCP server doing dozens of small queries per session. 2. api_client.py: narrow expected_exception from bare Exception to the same transient set the retry layer already uses (APIError, RateLimitError, TimeoutError, ConnectionError, OSError, httpx.RequestError). User errors like 4xx-from-bad-query no longer count toward opening the breaker. Updated CircuitBreakerConfig.expected_exception's type annotation to reflect that a tuple is supported (Python's except clause accepts either form at runtime). Validated with three async test cases: - failure_count resets on success in CLOSED state - non-transient exceptions are propagated but don't count - 3 consecutive transient errors still open the breaker (intended behavior preserved) Co-Authored-By: Claude Opus 4.7 (1M context) --- sumologic_mcp/api_client.py | 9 ++++++++- sumologic_mcp/resilience.py | 17 ++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/sumologic_mcp/api_client.py b/sumologic_mcp/api_client.py index 7d5060a..0ce36e3 100644 --- a/sumologic_mcp/api_client.py +++ b/sumologic_mcp/api_client.py @@ -75,10 +75,17 @@ def __init__(self, config: SumoLogicConfig, auth: SumoLogicAuth): ) ) + # Only transient/infra-class failures should count toward opening the + # circuit. Bare `Exception` would mean user errors (e.g. invalid query + # syntax → 4xx, validation errors) also count, which can trip the + # breaker on benign batches of work and degrade tools unnecessarily. circuit_breaker_config = CircuitBreakerConfig( failure_threshold=max(5, self.config.max_retries * 2), recovery_timeout=60.0, - expected_exception=Exception, + expected_exception=( + APIError, RateLimitError, TimeoutError, + ConnectionError, OSError, httpx.RequestError, + ), success_threshold=3 ) diff --git a/sumologic_mcp/resilience.py b/sumologic_mcp/resilience.py index 83d6b7d..98fc8a5 100644 --- a/sumologic_mcp/resilience.py +++ b/sumologic_mcp/resilience.py @@ -9,7 +9,7 @@ import time from dataclasses import dataclass, field from enum import Enum -from typing import Any, Callable, Dict, Optional, Type, Union, List +from typing import Any, Callable, Dict, Optional, Tuple, Type, Union, List from datetime import datetime, timedelta from .exceptions import APIError, RateLimitError, TimeoutError, SumoLogicError @@ -54,7 +54,10 @@ class CircuitBreakerConfig: """Configuration for circuit breaker.""" failure_threshold: int = 5 recovery_timeout: float = 60.0 - expected_exception: Type[Exception] = Exception + # Pass a tuple of exception classes to count only transient/infra-class + # failures toward opening the circuit (the default Exception catches user + # errors too, which can trip the breaker on benign batches). + expected_exception: Union[Type[Exception], Tuple[Type[Exception], ...]] = Exception success_threshold: int = 3 # Successes needed in half-open to close def __post_init__(self): @@ -221,7 +224,15 @@ async def _check_state_transition(self): async def _record_success(self): """Record successful operation.""" self.stats.record_success() - + + # In CLOSED state, a success means the recent failure streak is broken. + # Without this reset, failure_count is monotonic and the breaker is + # guaranteed to trip eventually after enough cumulative failures over + # the lifetime of the process — "5 consecutive failures" is the + # intended semantics, not "5 failures ever". + if self.stats.state == CircuitState.CLOSED: + self.stats.failure_count = 0 + logger.debug( f"Circuit breaker '{self.name}' recorded success", extra={ From cee3dbbcf276c5422397c0fc3c2b1c6250bf2d95 Mon Sep 17 00:00:00 2001 From: "a.key" Date: Tue, 16 Jun 2026 12:06:12 +0100 Subject: [PATCH 08/14] Add uv.lock --- uv.lock | 1714 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1714 insertions(+) create mode 100644 uv.lock diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..1290336 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1714 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version < '3.11'", +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586, upload-time = "2026-06-15T22:00:49.021Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506, upload-time = "2026-06-15T22:00:47.595Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" }, + { url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" }, + { url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" }, + { url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, + { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, + { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, + { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "black" +version = "26.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/84/b3f55026206a9e8820a91503308075ca48eadc515e436731ca01dbe043b3/black-26.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9942db8888e06943c5dde66ca0037dcff82a2a4ec1ad0ada9e0d2ee9d9823893", size = 1987719, upload-time = "2026-05-18T17:05:02.757Z" }, + { url = "https://files.pythonhosted.org/packages/c6/34/7db312c5e5783d6e76cffd9d5ac8972a32badae4c6e3288dac0eed8d3bed/black-26.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:89c93167a74d3a75dfaa38a5c7cca015537d5820dd7f17d63267d674a61cae90", size = 1810083, upload-time = "2026-05-18T17:05:04.302Z" }, + { url = "https://files.pythonhosted.org/packages/33/e2/e0101e73c2c8727634e2efcb35e2b34bd23ad70dfa673789f5773a591b21/black-26.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f2cd76d069cc54c71f10360744ba8983fbb616903b4304a85b734915c8e1b4", size = 1860633, upload-time = "2026-05-18T17:05:06.391Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4c/e15c0c5b23cf3651035fe5addcce90e283af3548a3f91bb03d81b83106ab/black-26.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:87ed5c6f450580a2f6790bc7cbfb016dfc73bc750249762268a3695361315eef", size = 1477886, upload-time = "2026-05-18T17:05:07.96Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3f/59d43ade98d2ce5c8dc34a4e46cbecd177e6d55d7d4092969c6003ccc655/black-26.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:58b4bd92cf88aacf83d88479c8f9caee044b1ec55f2451a337354a7ea2590a22", size = 1277111, upload-time = "2026-05-18T17:05:09.473Z" }, + { url = "https://files.pythonhosted.org/packages/4b/96/3c3e09f09f44a37aac36b178a279cd19aa7001bd796187a7b162a294c81f/black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c", size = 1970639, upload-time = "2026-05-18T17:05:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/83/ea/5ad117b9ee3ecd933c712bcbae610006e5b7cc9f41c526cd7ed3b6c4124c/black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7", size = 1792130, upload-time = "2026-05-18T17:05:12.983Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/7c448bc623fcdfa96672531beb5a616ea5e64f6975955254d7731ffb0ad9/black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59", size = 1846134, upload-time = "2026-05-18T17:05:14.506Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5b/0b39b3a5917f0657ac014ad2edb58c139553a478adfe7f817abf1622ff6e/black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3", size = 1478883, upload-time = "2026-05-18T17:05:16.542Z" }, + { url = "https://files.pythonhosted.org/packages/4c/48/dc222692e0f95030db1bbfb6c857e76858bad09058221ea7aae815255327/black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe", size = 1277776, upload-time = "2026-05-18T17:05:18.029Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/7744b906703228264ef73bdd534df88ec1ef3de45c4e78f6d31b9e32d0c9/black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8", size = 2012518, upload-time = "2026-05-18T17:05:20.108Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c0/c5a3b1636dfd09c42534f2b3cf33506814f6d3e066fb0879ffa16c1ae860/black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217", size = 1816016, upload-time = "2026-05-18T17:05:21.84Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0e/36044316b65ca471d3bb6d3703fd06fb50c6b727c3562f6a5a3153634f88/black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d", size = 1884150, upload-time = "2026-05-18T17:05:23.546Z" }, + { url = "https://files.pythonhosted.org/packages/b3/33/dafc5808c2af43672912111d7c3354af1615f7e2be3bed7a878461abbe4d/black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264", size = 1486825, upload-time = "2026-05-18T17:05:25.004Z" }, + { url = "https://files.pythonhosted.org/packages/82/14/b965ee6ad2a311f28bdbf692def3ee9848d2ae289dab28b27657fcee3e78/black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418", size = 1288646, upload-time = "2026-05-18T17:05:26.477Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" }, + { url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" }, + { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/69/0d2ef01ff4b8fcecd4cba920d11e92fa4f96ae412441d3b56a90a258e69b/coverage-7.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3e3680291c4a1d0dadfa84a2c459576a4af5133abb617905714339a0c73138cf", size = 219722, upload-time = "2026-05-26T20:38:14.002Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ae/9afdeaa31b9d9ce98124b6abf8bb49119bf71aecae04f8567c189d91299f/coverage-7.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a5274669f37f2343635a347b91a60777621341ab3378e9c6ac9335eee704bddf", size = 220240, upload-time = "2026-05-26T20:38:17.424Z" }, + { url = "https://files.pythonhosted.org/packages/51/69/c998589871df7ea7dba865cc5ee32b5a3e1d47ba6c68ef91104c7c46fa5e/coverage-7.14.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe5a5fec635799ef33428f1e5e61bafa45a92a96190ba731561ba558ccc214d", size = 246981, upload-time = "2026-05-26T20:38:19.266Z" }, + { url = "https://files.pythonhosted.org/packages/fc/10/1c7d04c13040dac531d21b712bbe08f902e6dd9b58f5d77875c4d030f8f2/coverage-7.14.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:62a9f70b52e0b5a95cfef4a5c5641b06983cadc5e538a3feeb5c00211f523ac2", size = 248812, upload-time = "2026-05-26T20:38:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/c1/65/2a38a4607ef27cadcfbcee034dba5830ae2569f90144a0f4c7dbf47d30b0/coverage-7.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c18ebc343e15be53049b3a2dce38fe82d58f37e20ab9094b3a39c0aa4f6bb47", size = 250675, upload-time = "2026-05-26T20:38:22.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a2/a446ed9752a4a59b79e0fb6cbb319f6facb2183045c0725462625e66f87e/coverage-7.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b84ffdf877644e7096aa936991efeed873f7f3df57b9cd001312b7668ab08550", size = 252590, upload-time = "2026-05-26T20:38:23.63Z" }, + { url = "https://files.pythonhosted.org/packages/9e/fd/e81fbd7ba752365546e9842b1cbdaad3d6919d2a522c590aef16a281ec5e/coverage-7.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e854312c4103f2ad4c0dc023b69b77ebfd2c89db5f86c4c94dc2353f9a92167e", size = 247691, upload-time = "2026-05-26T20:38:25.057Z" }, + { url = "https://files.pythonhosted.org/packages/53/35/f3c26fdaae9ea937d154ca4d372e5ea0a4167ff70d36c6074ac2eacb2f83/coverage-7.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c643734307300234fafa36bf2a040a7235f8f177ea1fd6ec1423aea6fb7b929f", size = 248716, upload-time = "2026-05-26T20:38:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/2e/14/940b6c49551fd343e8507ee2b0ba7af5d0aa04ed5bf768285cb7c72a9884/coverage-7.14.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:84ac9499e48700399a5dd0ea7085b5091961fec52c68d66b4ec0d3cf7f4441b1", size = 246721, upload-time = "2026-05-26T20:38:28.282Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2c/40fc0634186c28292a662dff578866b3913983d6c375a3c2a74020938719/coverage-7.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7f02d09f70776579b926d889a4c9c235070a1f47c40458aeaca563fae5acfdb5", size = 250533, upload-time = "2026-05-26T20:38:29.753Z" }, + { url = "https://files.pythonhosted.org/packages/de/e3/2c26bf1e811f9df991ff2a9bdddebdd13ee0665d564df7d05979f9146297/coverage-7.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ce66d8e46da2bb5ee313a745cbd2e391d319176c1f7a9451bfcd3a2fb920859b", size = 246990, upload-time = "2026-05-26T20:38:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b0/060260ef56bd92363ebdce0c7095ce422b06e69aae71828efeca473ab1ca/coverage-7.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c912c259304cfb5ee584481cfb7ce1ff932b4d61e6c9140b8f19cb7b5ed82332", size = 247593, upload-time = "2026-05-26T20:38:33.065Z" }, + { url = "https://files.pythonhosted.org/packages/63/f3/501502046efeb0d6d94b5ca54941d95f1184183dd6bdb7f283985783bb4a/coverage-7.14.1-cp310-cp310-win32.whl", hash = "sha256:1238cb94638e610e972c60dac68e813f868dc7d6e982535270558443058d9d59", size = 222330, upload-time = "2026-05-26T20:38:35.36Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5d/1bf99f2c558f128faf7906817ccbdb576ba815d3b41ce2ac1719b70a3663/coverage-7.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253", size = 223261, upload-time = "2026-05-26T20:38:37.196Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d7/477ad149490e6cb849f28abea1dabb9c823cea72e7500c81b4240ce619c0/coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f", size = 219848, upload-time = "2026-05-26T20:38:38.715Z" }, + { url = "https://files.pythonhosted.org/packages/91/82/a5eb47257c50601bb7b9a9d2857c67b7a3a85ad74180eb2c98bb1fbe0ce5/coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4", size = 220354, upload-time = "2026-05-26T20:38:40.232Z" }, + { url = "https://files.pythonhosted.org/packages/43/8b/78419b5391a5cb706b6544390507e469d83ffc9a8248b02c4011aceb9365/coverage-7.14.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1", size = 250771, upload-time = "2026-05-26T20:38:41.782Z" }, + { url = "https://files.pythonhosted.org/packages/77/63/e77aaacd491182210d639636b7a8bba23ffffa9b82aa3762da9431855fa9/coverage-7.14.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f", size = 252683, upload-time = "2026-05-26T20:38:43.305Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/a022e3cfbec2ac241640003cb3a817e161d9c7f5aa9b49173756cdc03204/coverage-7.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129", size = 254791, upload-time = "2026-05-26T20:38:45.361Z" }, + { url = "https://files.pythonhosted.org/packages/61/d6/967e408aca4c1ceb88cb0cc677169110ae7f5995fb5eaf5fb1f5a1bb8f5d/coverage-7.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860", size = 256748, upload-time = "2026-05-26T20:38:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/b8/be/869188f7fe28638078ec479331ace6dc5f7b40b7153eb616f47ab79404d8/coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c", size = 250907, upload-time = "2026-05-26T20:38:48.493Z" }, + { url = "https://files.pythonhosted.org/packages/07/aa/adb7d3b4278d690e68703abcd76ab1b948242e3668d921711551b78f9ddb/coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7", size = 252483, upload-time = "2026-05-26T20:38:50.074Z" }, + { url = "https://files.pythonhosted.org/packages/43/61/331c74103c62dcb0c4b9b3a0de9a61aca016208b0a90f109592a9f9ecc28/coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec", size = 250545, upload-time = "2026-05-26T20:38:51.613Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/c5dae3c104d89be04828f61810e6b3473825482e4c288cc4ed04553e08ae/coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef", size = 254310, upload-time = "2026-05-26T20:38:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a1/2b9d5863e3b83c01ad8199e3c597802fbb3a9dc90b058885804c20296d31/coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df", size = 250266, upload-time = "2026-05-26T20:38:55.414Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5e/0e511fbdb269359be26fe678a1c3fa1f2aa2a01573cc3f54268c8d6d4797/coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9", size = 251174, upload-time = "2026-05-26T20:38:57.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/10/e55307b622b3dd9671cb321824502dc10f93e72f2802b9946159a8edadeb/coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548", size = 222354, upload-time = "2026-05-26T20:38:58.727Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/107421693cfb71e4f1ca5bf70443f64d4161878068d07a3e51c7ad21d17b/coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e", size = 223290, upload-time = "2026-05-26T20:39:00.413Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1d/3e3644585eb29e9dafefb19555078529a4d7cce12bd21929664eea989277/coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3", size = 221953, upload-time = "2026-05-26T20:39:02.159Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b7/bdbb725ba02c5b42825b200c940f38b7a54fcad24627b7192f78f8110d76/coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c", size = 220022, upload-time = "2026-05-26T20:39:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/72/81/fdc0898a55c6219223291ec1a1fe89966ef212ce82276aa0899df84b5de0/coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c", size = 220379, upload-time = "2026-05-26T20:39:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/de/72/de048c4a25e13bce59ac6a339351c10bdf2515e07459afcdaf04dc3143a2/coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b", size = 251888, upload-time = "2026-05-26T20:39:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/300c343f68beb9d4cbb64ec81e58c5b6b80b56927f72d2b38654ac26e013/coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6", size = 254624, upload-time = "2026-05-26T20:39:09.037Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ed/7b25642496e8170b6bac14adce00537c6e5fa2d586159401a4de3e8b49e6/coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37", size = 255739, upload-time = "2026-05-26T20:39:10.889Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a2/abd210b8c4e29c24e4624916db97bb519097a91034aaeb767f937e7da794/coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad", size = 257998, upload-time = "2026-05-26T20:39:12.722Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/7c50beed3792fe62f6ce0545c6686ce83379719e2c0276179333d97eae92/coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84", size = 252296, upload-time = "2026-05-26T20:39:14.259Z" }, + { url = "https://files.pythonhosted.org/packages/15/05/0f874628ebcbfc77ead559ff210281ef06a97db08481832e7dd39274a135/coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54", size = 253658, upload-time = "2026-05-26T20:39:15.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/6f/ca6ad067364b337ef997802115e7ecad2abd2248b05471464b0dea02b4d4/coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7", size = 251803, upload-time = "2026-05-26T20:39:17.537Z" }, + { url = "https://files.pythonhosted.org/packages/c0/30/b9b4d377cd9f40baf228068f5a81faf8450c6228503011bd499708483a50/coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9", size = 255873, upload-time = "2026-05-26T20:39:19.414Z" }, + { url = "https://files.pythonhosted.org/packages/3c/21/7c721a9e5e6bb88547d30a787aefb97512d3f54c1324c7488d9b3743f7f9/coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02", size = 251372, upload-time = "2026-05-26T20:39:21.169Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f8ae5a2200130e1503cd7661a6cd3b2b7bacef98277fbf3571fb13f8b766/coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a", size = 253245, upload-time = "2026-05-26T20:39:23.097Z" }, + { url = "https://files.pythonhosted.org/packages/34/62/70a9024672a5f6910517d9628c52c9afbdd3cf8f46426af52bb148a56fff/coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1", size = 222567, upload-time = "2026-05-26T20:39:24.868Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/8b7cd386839b039ebe1855733b9f9449a8dec5d79564018234f185a7fa70/coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e", size = 223372, upload-time = "2026-05-26T20:39:26.603Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ba/b44d472022f620d289d95fa830143235c0c36461c6f2437ea8d51e5481ed/coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a", size = 221989, upload-time = "2026-05-26T20:39:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/5f6d56327c62b185225d145191c607e07515294a0aa6338e58805cd4a5ac/coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793", size = 220044, upload-time = "2026-05-26T20:39:29.902Z" }, + { url = "https://files.pythonhosted.org/packages/75/92/e82aca356744cbbc0f77a0b623e38918c1872361963413a3bab5d0340393/coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d", size = 220412, upload-time = "2026-05-26T20:39:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/27/c9/385bde0bf7ed0f4bf3a7ee5367060a86b5d218718cfd6fb943c0f836b34f/coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247", size = 251412, upload-time = "2026-05-26T20:39:33.337Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/23faf6a2343a0d17f960a4bd56c43bc7eb4cf312f774dd6ceebd82c7d8fc/coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d", size = 254008, upload-time = "2026-05-26T20:39:35.009Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/36f4aa9ca8a815e6036156e80706a67828bb97bd826948244f6996dda957/coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b", size = 255241, upload-time = "2026-05-26T20:39:36.71Z" }, + { url = "https://files.pythonhosted.org/packages/ca/79/95266316352f90f6b1c6736bb413302edfde2453fb32422d3911642691b3/coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be", size = 257373, upload-time = "2026-05-26T20:39:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9c/58316d1f66c488b5fca8a0eb3e98348807813efa8a0d0833b9021be27488/coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43", size = 251635, upload-time = "2026-05-26T20:39:40.268Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5a/ca2398a568e16fed7bb713e84ba3603a7164fb65779abe645c565ec890d5/coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901", size = 253373, upload-time = "2026-05-26T20:39:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2c/0396562c32deaebe7be51d865b3a41e9a87d7561acafe1a28f53b07e019a/coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff", size = 251341, upload-time = "2026-05-26T20:39:43.907Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8f/a94f9221184c9cae1ee115820e3798e48b6b17777a9f19e46fb9a0c8dc74/coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4", size = 255497, upload-time = "2026-05-26T20:39:46.166Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/505d70e47db1eaebcd002c39759707621ef184cd6b1ae084d9f41293f323/coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d", size = 251159, upload-time = "2026-05-26T20:39:48.03Z" }, + { url = "https://files.pythonhosted.org/packages/e0/aa/58681c383aa33a9d2ed40a02d7a22fbf780d1fa4d575396365777828198c/coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33", size = 252934, upload-time = "2026-05-26T20:39:49.872Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/11c928cd6bdffc7074bb5965c173d9ebf517fb00205e1da524b98d29ef92/coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c", size = 222584, upload-time = "2026-05-26T20:39:51.68Z" }, + { url = "https://files.pythonhosted.org/packages/6f/92/fb416fc26d340dcba19518c418d6048e913186e17243982c5e435e41fa7a/coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416", size = 223394, upload-time = "2026-05-26T20:39:53.472Z" }, + { url = "https://files.pythonhosted.org/packages/73/c6/02d56e3867972f77d5036de924643f26c056e848f00452cafb4dbc3c29b4/coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42", size = 222015, upload-time = "2026-05-26T20:39:55.374Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9e/fcc77914050df73f7662fa1f00902774c79c075a8388ab334074574bf77e/coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d", size = 220733, upload-time = "2026-05-26T20:39:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/f7/67/2963cbdaf5cbadec44efa3a1e39eaa1f02df4079585f05387607a221e126/coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5", size = 221086, upload-time = "2026-05-26T20:39:59.019Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/8701645574e11881f2f47d8930f98bc48b5d43b25eb5b4430dfc4a2f9f48/coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52", size = 262381, upload-time = "2026-05-26T20:40:00.822Z" }, + { url = "https://files.pythonhosted.org/packages/7c/28/7a64d73598263e0c5abd5084211a8474488d31b3c552ff531c719dfcff62/coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a", size = 264458, upload-time = "2026-05-26T20:40:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d8/4969179db9f7eb4df218e69540adf829d1c835f59452513d065d15446802/coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a", size = 266884, upload-time = "2026-05-26T20:40:04.421Z" }, + { url = "https://files.pythonhosted.org/packages/a6/78/a45d5794dbc9bafd97afc96a4377c86c7820d78b6cf51b89bc1d4e919275/coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2", size = 268022, upload-time = "2026-05-26T20:40:06.298Z" }, + { url = "https://files.pythonhosted.org/packages/21/cb/4f5e354e9e3e67af96bd4e57113e6db6b22298c7168b13eec408a549903d/coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e", size = 261631, upload-time = "2026-05-26T20:40:08.226Z" }, + { url = "https://files.pythonhosted.org/packages/ec/49/eced49af4cb996d5d8b7e94e736175c513e4facd3398507b89892b4326d8/coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d", size = 264443, upload-time = "2026-05-26T20:40:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/5603a88a7c5913a6b54f6cb1a8c46f7b39cbb30f27cd3f492908da09b2d7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb", size = 262069, upload-time = "2026-05-26T20:40:11.999Z" }, + { url = "https://files.pythonhosted.org/packages/f0/59/2ae3cb79da554a06c8619d6c88ea19dd1e4aed4b834b6a83bb1fa243bdc5/coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d", size = 265780, upload-time = "2026-05-26T20:40:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/af/5f/b130c1dc999031f2648bd25317fbce505ad8d5562079b4ed81e736a84967/coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69", size = 260970, upload-time = "2026-05-26T20:40:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/87/d1/ec13ccddeb48ec963bdfa72a11224bac2584bd045ba13beca82f8113e9c7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54", size = 263157, upload-time = "2026-05-26T20:40:18.382Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259, upload-time = "2026-05-26T20:40:20.381Z" }, + { url = "https://files.pythonhosted.org/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320, upload-time = "2026-05-26T20:40:22.316Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577, upload-time = "2026-05-26T20:40:24.894Z" }, + { url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" }, + { url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" }, + { url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" }, + { url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" }, + { url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" }, + { url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" }, + { url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" }, + { url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" }, + { url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" }, + { url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" }, + { url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/dc/be6cbe99670cd6e4ad387123647cb08e0c32975e223f82551e914c5568a6/filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a", size = 63028, upload-time = "2026-06-13T16:12:00.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" }, +] + +[[package]] +name = "flake8" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mccabe" }, + { name = "pycodestyle" }, + { name = "pyflakes" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "isort" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "librt" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/10/37fd9e9ba96cb0bd742dfb20fc3d082e54bdbec759d7300df927f360ef07/librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f", size = 141706, upload-time = "2026-05-10T18:15:16.129Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/1b1466f358e4a0b728051f69bc27e67b432c6eaa2e05b88db49d3785ae0d/librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45", size = 142605, upload-time = "2026-05-10T18:15:18.148Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/ed26dd2f6bc9a0baf48306433e579e8d354d70b2bcb78134ed950a5d0e1e/librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c", size = 476555, upload-time = "2026-05-10T18:15:19.569Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/11891191c0e0a3fd617724e891f6e67a71a7658974a892b9a9a97fdb2977/librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33", size = 468434, upload-time = "2026-05-10T18:15:20.87Z" }, + { url = "https://files.pythonhosted.org/packages/6f/50/5ec949d7f9ce1a07af903aa3e13abb98b717923bdead6e719b2f824ccc07/librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884", size = 496918, upload-time = "2026-05-10T18:15:22.616Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c4/177336c7524e34875a38bf668e88b193a6723a4eb4045d07f74df6e1506c/librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280", size = 490334, upload-time = "2026-05-10T18:15:24.2Z" }, + { url = "https://files.pythonhosted.org/packages/13/1f/da3112f7569eda3b49f9a2629bae1fe059812b6085df16c885f6454dff49/librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c", size = 511287, upload-time = "2026-05-10T18:15:26.226Z" }, + { url = "https://files.pythonhosted.org/packages/fa/94/03fec301522e172d105581431223be56b27594ff46440ebfbb658a3735d5/librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb", size = 517202, upload-time = "2026-05-10T18:15:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/6e/339f6e5a7b413ce014f1917a756dae630fe59cc99f34153205b1cb540901/librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783", size = 497517, upload-time = "2026-05-10T18:15:29.614Z" }, + { url = "https://files.pythonhosted.org/packages/cd/43/acdd5ce317cb46e8253ca9bfbdb8b12e68a24d745949336a7f3d5fb79ba0/librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0", size = 538878, upload-time = "2026-05-10T18:15:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/29/b5/7a25bb12e3172839f647f196b3e988318b7bb1ca7501732a225c4dce2ec0/librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89", size = 100070, upload-time = "2026-05-10T18:15:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0d/ebbcf4d77999c02c937b05d2b90ff4cd4dcc7e9a365ba132329ac1fe7a0f/librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4", size = 117918, upload-time = "2026-05-10T18:15:33.678Z" }, + { url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" }, + { url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" }, + { url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" }, + { url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" }, + { url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" }, + { url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, + { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, + { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, + { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, + { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, + { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + +[[package]] +name = "mcp" +version = "1.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/3c/347cf965d313f5d41764e7d46bea6ffe7d9ef13b983cc429b0340962a082/mcp-1.27.2.tar.gz", hash = "sha256:8e02db104096d1c25b28e64bde29a5c32b31bc241710213e12fd4d84985bdfef", size = 621116, upload-time = "2026-05-29T17:16:04.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/11/252c6f971dc4f16af1d98a1c469d8ba523aab00d1bb76b4d3bc1ff32eacc/mcp-1.27.2-py3-none-any.whl", hash = "sha256:d6ff5160c6ca65d93013626efb3fc249de683c30b2d8570755ceddd490344de5", size = 220498, upload-time = "2026-05-29T17:16:02.442Z" }, +] + +[[package]] +name = "mypy" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/71/d351dca3e9b30da2328ee9d445c88b8388072808ebfbc49eb69d30b67749/mypy-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:11a6beb180257a805961aea9ec591bbd0bd17f1e18d35b8456d57aee5bedfedc", size = 14778792, upload-time = "2026-05-11T18:36:23.605Z" }, + { url = "https://files.pythonhosted.org/packages/2f/45/7d51594b644c17c0bcf74ed8cd5fc33b324276d708e8506f220b70dab9d9/mypy-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ef78c1d306bbf9a8a12f526c44902c9c28dffd6c52c52bf6a72641ce18d3849", size = 13645739, upload-time = "2026-05-11T18:37:22.752Z" }, + { url = "https://files.pythonhosted.org/packages/65/01/455c31b170e9468265074840bf18863a8482a24103fdaabe4e199392aa5f/mypy-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c209a90853081ff01d01ee895cafe10f7db1474e0d95beaeef0f6c1db9119bbd", size = 14074199, upload-time = "2026-05-11T18:35:09.292Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/93093f0b29a9e982deafde698f740a2eb2e05886e79ccf0594c7fd5413a3/mypy-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47cebf61abde7c088a4e27718a8b13a81655686b2e9c251f5c0915a802248166", size = 14953128, upload-time = "2026-05-11T18:31:57.678Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2f/a196f5331d96170ad3d28f144d2aba690d4b2911381f68d51e489c7ab82a/mypy-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d57a90ae5e872138a425ec328edbc9b235d1934c4377881a33ec05b341acc9a8", size = 15249378, upload-time = "2026-05-11T18:33:00.101Z" }, + { url = "https://files.pythonhosted.org/packages/54/de/94d321cc12da9f71341ac0c270efbed5c725750c7b4c334d957de9a087d9/mypy-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:aea7f7a8a55b459c34275fc468ada6ca7c173a5e43a68f5dbe588a563d8a06b8", size = 11060994, upload-time = "2026-05-11T18:33:18.848Z" }, + { url = "https://files.pythonhosted.org/packages/e1/62/0c27ca55219a7c764a7fb88c7bb2b7b2f9780ade8bbf16bc8ed8400eef6b/mypy-2.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c989640253f0d76843e9c6c1bbf4bd48c5e85ada61bde4beb37cb3eca035685e", size = 9976743, upload-time = "2026-05-11T18:31:25.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" }, + { url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618, upload-time = "2026-05-11T18:34:49.765Z" }, + { url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063, upload-time = "2026-05-11T18:34:05.855Z" }, + { url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564, upload-time = "2026-05-11T18:35:36.494Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983, upload-time = "2026-05-11T18:37:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, + { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, + { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, + { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + +[[package]] +name = "pycodestyle" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, +] + +[[package]] +name = "pyflakes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pytest" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/1a/cbbaf13b730abb0a16b964d984e19f2fe520c21a4dc664051359a3f5a9e7/python_discovery-1.4.2.tar.gz", hash = "sha256:8f3746c4b4968d22afbb97d36e1a0e5b66e6c0f297290f2e95f05b9b8bf18690", size = 70277, upload-time = "2026-06-11T16:10:42.383Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl", hash = "sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500", size = 33886, upload-time = "2026-06-11T16:10:41.192Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/24/f206113e05cb8ef51b3850e7ef88f20da6f4bf932190ceb48bd3da103e10/pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5", size = 161522, upload-time = "2026-01-30T01:02:50.393Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e9/06a6bf1b90c2ed81a9c7d2544232fe5d2891d1cd480e8a1809ca354a8eb2/pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe", size = 246945, upload-time = "2026-01-30T01:02:52.399Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/f6fb1007a4c3d8b682d5d65b7c1fb33257587a5f782647091e3408abe0b8/pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c", size = 259525, upload-time = "2026-01-30T01:02:53.737Z" }, + { url = "https://files.pythonhosted.org/packages/04/92/086f89b4d622a18418bac74ab5db7f68cf0c21cf7cc92de6c7b919d76c88/pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7", size = 262693, upload-time = "2026-01-30T01:02:54.871Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7b/8b31c347cf94a3f900bdde750b2e9131575a61fdb620d3d3c75832262137/pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2", size = 103567, upload-time = "2026-01-30T01:02:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/1b/9cfdeac80ee45bebbbcb31f1b7b99a0d81a1c72de48d837be984e0e88b1d/pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e", size = 6361387, upload-time = "2026-06-04T07:49:14.329Z" }, + { url = "https://files.pythonhosted.org/packages/33/b1/7afc96d041d982c27bc2df6f853d43f01fd273e3d39d04be3647ddeb533d/pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db", size = 6926780, upload-time = "2026-06-04T07:49:16.881Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/4140da9ad54108e517f4a16b2d83da3033e08662144623e1239587cb7db6/pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd", size = 4307203, upload-time = "2026-06-04T07:49:18.993Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/a0/acf8b6fc20bfdcd3a45bd3f57680fb198e157b7e997b9123b10763798bd2/rpds_py-2026.5.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3397a5ed7174dc2786bb214030232fc36fe8e5584fec43a9952cc542b1a12036", size = 355609, upload-time = "2026-05-28T11:58:50.78Z" }, + { url = "https://files.pythonhosted.org/packages/b6/95/f8203fd997484b1690a6869cd0e503b6c3c6be55b0ecc36d1a491fe742f0/rpds_py-2026.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:99ab6ba7bfa2cb0f96a04e3652355bf04e3f51aceb1e943b8541dab7ba4828cc", size = 348460, upload-time = "2026-05-28T11:58:52.374Z" }, + { url = "https://files.pythonhosted.org/packages/33/8c/b47326ad2f0be545a5e5c1a55937a12afaea7d392ba2837bb9680f57e6c9/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0efbe45632665e53e3db8fe1e5692db58fc5cb9bab4459d570b83efefe11164", size = 381031, upload-time = "2026-05-28T11:58:53.775Z" }, + { url = "https://files.pythonhosted.org/packages/22/0b/e83bbd97ffac6f6389b605cd4e1c8ac5761dc7e977769c9255d8c5adb7bd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:01d17b29c0c23d82b1f4751147ec49cf451f1fc2554eb9ef5f957e55d2656ead", size = 387121, upload-time = "2026-05-28T11:58:55.243Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0e/d285d1bc8864245919c61e1ca82263e4a66d337759c3a4cef72766ff9afc/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7559f72b94ae52659086c595dfa017cde03155f7832071d30959049052cb3ece", size = 501026, upload-time = "2026-05-28T11:58:56.788Z" }, + { url = "https://files.pythonhosted.org/packages/86/06/ccb2109a1e543437b5e43816f2b43b9554cc6783145528a4e3711e05c011/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e25b7088f9ccbfc0dfcaa52bf969300ca229e10ecf758974ebcbb080a4b37bb", size = 391865, upload-time = "2026-05-28T11:58:58.298Z" }, + { url = "https://files.pythonhosted.org/packages/3d/33/237173db1cfef10105b3839a24de00eb8d2a523711add4632447cdf0aedd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613fc4ee9eaef26dc5840666214dd6fbcebcf32f46e76f4abc473059f4e13dda", size = 378012, upload-time = "2026-05-28T11:58:59.589Z" }, + { url = "https://files.pythonhosted.org/packages/97/64/1eae54e34d5161f9969295e80bd6b62a55f2b6ac5f2a5b60d02c2140e758/rpds_py-2026.5.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:85264a90ff4c05c1568dd65f5921c837614b67c60358fb4c17df3b7f2e90690a", size = 391111, upload-time = "2026-05-28T11:59:01.104Z" }, + { url = "https://files.pythonhosted.org/packages/d8/34/5bb334a5a0f65d77869217c4654f34c78a7d11b93938a3c076a2edeafc52/rpds_py-2026.5.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe71bca7d547acb17027c7fd1624ff8aae623499c498d3e7011182c4de5c25e0", size = 409225, upload-time = "2026-05-28T11:59:02.433Z" }, + { url = "https://files.pythonhosted.org/packages/16/0f/007ec21283b5b040b4ec3bd95e0402591e22bfa7d5c93dfe01c465c2d2d7/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05fa4f41f37ec97c9c260441a940450a192f78d774d2b097eee1379f1e1246a", size = 556487, upload-time = "2026-05-28T11:59:04.012Z" }, + { url = "https://files.pythonhosted.org/packages/ff/10/5437c94508169b6b22d8418fef7a66e9ffb5f3b9e9c94460f2eedafe06ff/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df1d2a1996755b24b9ecee92cb4d36c28f86f464a6a173349c26bab41e94b8c2", size = 620798, upload-time = "2026-05-28T11:59:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d5/9937dce4d6bda74157b954e7d1460db05a22f5929dccfeeba1ed27a93df0/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8895840ac4809e5f60c88fd07617cd71326e73d6e5a8aa783c5c0f7c24985de2", size = 584053, upload-time = "2026-05-28T11:59:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/6c/31/750617dd0ae1752471bf43f9e41d263398fae7cde7849d23b8574a70e617/rpds_py-2026.5.1-cp311-cp311-win32.whl", hash = "sha256:3684a59b158a7683aaeb8e25352e9a9dd2122cec78f2d8530266e4f91b4c7b3f", size = 214390, upload-time = "2026-05-28T11:59:08.402Z" }, + { url = "https://files.pythonhosted.org/packages/3c/bb/3dcab0e1d9516303f2eb672a5d6f62eca5a69e2886301e9c8c54b520c39b/rpds_py-2026.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:7bd530e6a530bb3ea892f194fafa455f3516ac25ecf7143fd33c09be62b0470a", size = 231097, upload-time = "2026-05-28T11:59:09.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/d6/c6bbf5cb1cf12b9732df8074b57f6ef8341ba884c95d40632ae8bddb44e4/rpds_py-2026.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:0a5ae4dbe43c1076983b72616496919872ae7bbe7a1e21cc48336bc3154d130b", size = 226361, upload-time = "2026-05-28T11:59:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040, upload-time = "2026-05-28T11:59:12.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775, upload-time = "2026-05-28T11:59:13.827Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329, upload-time = "2026-05-28T11:59:15.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539, upload-time = "2026-05-28T11:59:16.665Z" }, + { url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674, upload-time = "2026-05-28T11:59:17.991Z" }, + { url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268, upload-time = "2026-05-28T11:59:19.434Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280, upload-time = "2026-05-28T11:59:21Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233, upload-time = "2026-05-28T11:59:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009, upload-time = "2026-05-28T11:59:23.845Z" }, + { url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113, upload-time = "2026-05-28T11:59:25.184Z" }, + { url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838, upload-time = "2026-05-28T11:59:26.749Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436, upload-time = "2026-05-28T11:59:28.11Z" }, + { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734, upload-time = "2026-05-28T11:59:29.689Z" }, + { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045, upload-time = "2026-05-28T11:59:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967, upload-time = "2026-05-28T11:59:32.318Z" }, + { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" }, + { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" }, + { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" }, + { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" }, + { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" }, + { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481, upload-time = "2026-05-28T11:59:53.148Z" }, + { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519, upload-time = "2026-05-28T11:59:54.505Z" }, + { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446, upload-time = "2026-05-28T11:59:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" }, + { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" }, + { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" }, + { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" }, + { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" }, + { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" }, + { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" }, + { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" }, + { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" }, + { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" }, + { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" }, + { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576, upload-time = "2026-05-28T12:01:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640, upload-time = "2026-05-28T12:01:08.441Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322, upload-time = "2026-05-28T12:01:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066, upload-time = "2026-05-28T12:01:11.48Z" }, + { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586, upload-time = "2026-05-28T12:01:13.051Z" }, + { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415, upload-time = "2026-05-28T12:01:14.631Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427, upload-time = "2026-05-28T12:01:16.153Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615, upload-time = "2026-05-28T12:01:17.809Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786, upload-time = "2026-05-28T12:01:19.419Z" }, + { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583, upload-time = "2026-05-28T12:01:21.013Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941, upload-time = "2026-05-28T12:01:22.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349, upload-time = "2026-05-28T12:01:24.118Z" }, + { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922, upload-time = "2026-05-28T12:01:25.522Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003, upload-time = "2026-05-28T12:01:27.062Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245, upload-time = "2026-05-28T12:01:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015, upload-time = "2026-05-28T12:01:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016, upload-time = "2026-05-28T12:01:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775, upload-time = "2026-05-28T12:01:33.136Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270, upload-time = "2026-05-28T12:01:34.574Z" }, + { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285, upload-time = "2026-05-28T12:01:36.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581, upload-time = "2026-05-28T12:01:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041, upload-time = "2026-05-28T12:01:39.307Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946, upload-time = "2026-05-28T12:01:41.043Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526, upload-time = "2026-05-28T12:01:43.032Z" }, + { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165, upload-time = "2026-05-28T12:01:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778, upload-time = "2026-05-28T12:01:46.337Z" }, + { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839, upload-time = "2026-05-28T12:01:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866, upload-time = "2026-05-28T12:01:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/42/56/3fe0fb34820ff667be791b3a3c22b85e8bcba54e9c832f47438c191fa7be/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:edf2765d84e42447f112ad877af8fe1db0089aaec5b28e88d6eab45e7fe99cea", size = 357151, upload-time = "2026-05-28T12:01:53.43Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/3eb9ccdb9f143b8c9b003978898cb497f942a324c077401e6b8834238e63/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ad3773236e95f7f33991eb125224b7da66f206504d032a253a02da7e134519fb", size = 350195, upload-time = "2026-05-28T12:01:54.901Z" }, + { url = "https://files.pythonhosted.org/packages/a7/24/dbda232bc4f3ed732120692ab0d2c8402cb020516556d8bee622dcef2413/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a04df86b3f0fade39ec8fd0e0aab089b1da9fbd2b48df778a57ef96f5e7d38df", size = 381850, upload-time = "2026-05-28T12:01:56.601Z" }, + { url = "https://files.pythonhosted.org/packages/40/30/32e769839a358f78810c234f160f2cc21d1e4e47e1c0e0e0d535be5a0219/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6142dbd80c4df62a5d899f0d616d417f84e0bc8d32526c8e5589019d75d028a7", size = 387899, upload-time = "2026-05-28T12:01:58.212Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/ec84d243aadb3b34b71dd26a010d0930b2d284ff5fc9a69fec53810ee6fd/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0b35217adefe87f2fe4db7e9766cabe84744bfe9616d9667be18988928c7f2dc", size = 501618, upload-time = "2026-05-28T12:01:59.888Z" }, + { url = "https://files.pythonhosted.org/packages/74/25/b60e52686bbff777a64f9e4f4d3dd57980dc846913777177a2c92e4937aa/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b95d5e11fc712b752081183a55a244c03cd00570489edd7014d8899f8ceb8162", size = 394003, upload-time = "2026-05-28T12:02:01.482Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c7/b3a6a588cc2219510ef3f42e207483a93950bedd1e3a0fd4015c95cff9e5/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:141c9498daf2ace9eda35d2b0e376f9ea8b058d84f2aef4f96fccfd449a2f251", size = 379778, upload-time = "2026-05-28T12:02:03.197Z" }, + { url = "https://files.pythonhosted.org/packages/31/00/c7dba3fc8a3da8cb3f6db1eb3386be4d79c2e97c6890d20eb9ac66ae8c43/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:6f249f8b860a200ad35193af961183ebe9132710484e6f6ce0cf89fd83c63a9a", size = 392359, upload-time = "2026-05-28T12:02:04.817Z" }, + { url = "https://files.pythonhosted.org/packages/93/dd/472ba494c70753f93745992c99855bee0636daf74e6984e5e003f150316f/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e4abbf391a70be864920858bf360f4fb380577c9a0f732438a1996726e2c195b", size = 412820, upload-time = "2026-05-28T12:02:06.401Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6f/93831a3bfe789542ed0c1d0d74b78b440f055d6dc3ea4640eba2d95e6e23/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:c74005a7bb87752acf351c93897ec63ad77a07a0da7ecad9c050e32e7286ba34", size = 557243, upload-time = "2026-05-28T12:02:08.013Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ff/0b3d604614ffc77522c6b288fdbce68957eb583da1002aa65ba38ac0ee40/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:8213afbe8a3a906fb9acb2014423fe3359ee783d0bf90995f70623a3217bfa6c", size = 623541, upload-time = "2026-05-28T12:02:09.661Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ea/e7b0251441da9adfeaebcf29601d10f2a1455fcf0772fae9e7e19032bd96/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8c43a8a973270fd173bf48cdf80bbe66312421cba68d40845034f174f2389049", size = 586326, upload-time = "2026-05-28T12:02:11.47Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0", size = 31819, upload-time = "2026-05-12T17:37:17.019Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973", size = 16514, upload-time = "2026-05-12T17:37:15.601Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "structlog" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/89/b4a0bcfdf4f71a3dea31379f095929613d7e4528a0996bca6aa964cd0dca/structlog-26.1.0.tar.gz", hash = "sha256:f63a716cbd1b1291cf7661de7794b455acfa4c43c5bcf1630e6ad5ddc1adb3b7", size = 1459881, upload-time = "2026-06-06T07:33:39.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/18/489c97b834dfff9cf2fc2507cede4bcd4b11e67f84bc462acd1992496f86/structlog-26.1.0-py3-none-any.whl", hash = "sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e", size = 73764, upload-time = "2026-06-06T07:33:38.046Z" }, +] + +[[package]] +name = "sumologic-mcp-python" +version = "0.1.1" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "mcp" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "structlog" }, +] + +[package.optional-dependencies] +dev = [ + { name = "black" }, + { name = "flake8" }, + { name = "isort" }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, +] + +[package.metadata] +requires-dist = [ + { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, + { name = "flake8", marker = "extra == 'dev'", specifier = ">=6.0.0" }, + { name = "httpx", specifier = ">=0.25.0" }, + { name = "isort", marker = "extra == 'dev'", specifier = ">=5.12.0" }, + { name = "mcp", specifier = ">=1.0.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.0.0" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, + { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.10.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "structlog", specifier = ">=23.0.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/0e/933bacb37b57ae7928b0030eef205a3dbb3e37afdbdde5be2e113318958f/virtualenv-21.5.0.tar.gz", hash = "sha256:98847aadf5e2037e0e4d2e19528eb3aca6f23906422e59a510bff231a6d32fce", size = 4577424, upload-time = "2026-06-13T20:36:45.066Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/87/b0667ede418386ab631e48924b845d326f366d61e6bd08fe68a748fae4d4/virtualenv-21.5.0-py3-none-any.whl", hash = "sha256:8f7c38605023688c89789f566959006af6d61c99eeeb9e58342eb780c5761e5e", size = 4557937, upload-time = "2026-06-13T20:36:42.967Z" }, +] From 36f8b791d90f60abefbd0192dd7cba6fbec1cc18 Mon Sep 17 00:00:00 2001 From: "a.key" Date: Tue, 16 Jun 2026 12:34:27 +0100 Subject: [PATCH 09/14] feat(monitors): implement get_monitor_status from Monitors Management API Previously get_monitor_status called non-existent status endpoints. Derive status from the Monitors Management API instead: - Single monitor: read /api/v1/monitors/{id} and normalize its status array. - All monitors: delegate to list_monitors (folder recursion) and build a status record per monitor, skipping folder entries. Add helpers _unwrap_monitor_item, _normalize_monitor_status (maps the raw status array + isDisabled to Normal/Triggered/Disabled/Unknown with a severity ranking), and _build_monitor_status_record. Timing fields (lastTriggered/triggerCount24h/last|nextEvaluation) are reported as unavailable since the API does not expose live evaluation data. Also: - Fix LogRecord 'name' attribute collision in get_monitor logging. - Remove unreachable dead code in the tool-layer get_monitor_status. - Add 15 unit tests for the status normalization helpers. - Track tests/test_*.py despite the broad test_*.py ignore rule. --- .gitignore | 2 + sumologic_mcp/api_client.py | 190 ++++++++++++++++++++------- sumologic_mcp/tools/monitor_tools.py | 139 -------------------- tests/test_monitor_status.py | 110 ++++++++++++++++ 4 files changed, 253 insertions(+), 188 deletions(-) create mode 100644 tests/test_monitor_status.py diff --git a/.gitignore b/.gitignore index b7a096b..31018da 100644 --- a/.gitignore +++ b/.gitignore @@ -169,6 +169,8 @@ cython_debug/ config.json test_*.py debug_*.py +# But always track the real test suite under tests/. +!tests/test_*.py # macOS .DS_Store diff --git a/sumologic_mcp/api_client.py b/sumologic_mcp/api_client.py index 0ce36e3..873b5ee 100644 --- a/sumologic_mcp/api_client.py +++ b/sumologic_mcp/api_client.py @@ -2400,8 +2400,8 @@ async def get_monitor(self, monitor_id: str) -> Dict[str, Any]: f"Retrieved monitor", extra={ "monitor_id": monitor_id, - "name": monitor.get("name"), - "type": monitor.get("monitorType"), + "monitor_name": monitor.get("name"), + "monitor_type": monitor.get("monitorType"), "is_disabled": monitor.get("isDisabled") } ) @@ -2687,6 +2687,84 @@ async def delete_monitor(self, monitor_id: str) -> Dict[str, Any]: context={"monitor_id": monitor_id} ) from e + # Severity ranking used to pick the most significant active trigger when a + # monitor reports multiple non-normal states (highest priority first). + _TRIGGER_SEVERITY_ORDER = ["Critical", "Warning", "MissingData"] + + @staticmethod + def _unwrap_monitor_item(element: Dict[str, Any]) -> Dict[str, Any]: + """Normalize a monitor object to a flat monitor dict. + + Most paths already yield flat monitor objects (list_monitors flattens + search results, and the get-by-id endpoint returns the monitor + directly). This helper is defensive: if an element is wrapped as + {"item": {...monitor...}, ...}, it unwraps it; otherwise it returns the + element unchanged. + """ + if isinstance(element, dict) and isinstance(element.get("item"), dict): + return element["item"] + return element + + def _normalize_monitor_status( + self, is_disabled: bool, status_array: Optional[List[str]] + ) -> tuple[str, Optional[str]]: + """Map a raw Sumo Logic monitor status into the tool-facing enum value. + + Sumo Logic exposes monitor state as an array (e.g. ["Normal"], + ["Disabled"], ["Critical"], ["Warning", "MissingData"]). The MCP tool + layer expects one of: "Normal", "Triggered", "Disabled", "Unknown". + + Returns: + (status, current_trigger_severity) where current_trigger_severity is + the most significant active severity when the monitor is triggered, + otherwise None. + """ + statuses = [s for s in (status_array or []) if isinstance(s, str)] + + if is_disabled or "Disabled" in statuses: + return "Disabled", None + + # Anything that is neither Normal nor Disabled is an active trigger + # (e.g. Critical, Warning, MissingData). + triggered = [s for s in statuses if s not in ("Normal", "Disabled")] + if triggered: + for severity in self._TRIGGER_SEVERITY_ORDER: + if severity in triggered: + return "Triggered", severity + return "Triggered", triggered[0] + + if "Normal" in statuses: + return "Normal", None + + return "Unknown", None + + def _build_monitor_status_record(self, monitor: Dict[str, Any]) -> Dict[str, Any]: + """Build a status record for the tool layer from a flat monitor object. + + Note: the Monitors Management API does not expose live evaluation timing + (lastTriggered / triggerCount24h / lastEvaluation / nextEvaluation), so + those fields are reported as None/0 and surfaced as unavailable. + """ + status_array = monitor.get("status") + is_disabled = bool(monitor.get("isDisabled", False)) + normalized_status, severity = self._normalize_monitor_status( + is_disabled, status_array + ) + + return { + "monitorId": monitor.get("id"), + "monitorName": monitor.get("name", "Unknown"), + "status": normalized_status, + "currentTriggerSeverity": severity, + "lastTriggered": None, + "triggerCount24h": 0, + "lastEvaluation": None, + "nextEvaluation": None, + "isDisabled": is_disabled, + "rawStatus": status_array, + "monitorType": monitor.get("monitorType"), + } + async def get_monitor_status( self, monitor_id: Optional[str] = None, @@ -2695,16 +2773,23 @@ async def get_monitor_status( offset: int = 0 ) -> Dict[str, Any]: """Get monitor status information. - + + Derives monitor status from the Monitors Management API. For a single + monitor it reads /api/v1/monitors/{id}; for all monitors it delegates to + list_monitors (which searches the library and recurses into folders), + then normalizes each monitor's `status` array. + Args: monitor_id: Optional specific monitor ID to get status for - filter_status: Optional status filter (triggered, normal, disabled) - limit: Maximum results to return (1-1000) - offset: Result offset for pagination - + filter_status: Accepted for compatibility; status filtering is applied + by the tool layer against the normalized records returned here. + limit: Page size for the multi-monitor search (1-1000) + offset: Starting result offset for pagination + Returns: - Dictionary containing monitor status information - + For a single monitor: {"data": }. + For all monitors: {"data": [, ...], "total": }. + Raises: ValidationError: If parameters are invalid APIError: If API request fails @@ -2715,15 +2800,15 @@ async def get_monitor_status( field_name="limit", field_value=limit ) - + if offset < 0: raise ValidationError( "Offset must be non-negative", field_name="offset", field_value=offset ) - - # Build endpoint and parameters + + # Single monitor: read the monitor object directly and normalize it. if monitor_id: if not monitor_id.strip(): raise ValidationError( @@ -2732,47 +2817,39 @@ async def get_monitor_status( field_value=monitor_id ) monitor_id = monitor_id.strip() - endpoint = f"/api/v1/monitors/{monitor_id}/status" - params = {} - else: - endpoint = "/api/v1/monitors/status" - params = { - "limit": limit, - "offset": offset - } - - if filter_status: - params["status"] = filter_status.strip() - + + logger.debug( + "Getting monitor status", + extra={"monitor_id": monitor_id, "filter_status": filter_status} + ) + + monitor = await self.get_monitor(monitor_id) + record = self._build_monitor_status_record( + self._unwrap_monitor_item(monitor) + ) + + logger.info( + "Retrieved monitor status", + extra={"monitor_id": monitor_id, "status": record["status"]} + ) + + return {"data": record} + + # All monitors: reuse list_monitors, which already searches the + # Monitors Management library (including recursion into folders) and + # normalizes the response shape. We then derive a status record per + # monitor from each returned object's `status` array. logger.debug( - "Getting monitor status", - extra={ - "monitor_id": monitor_id, - "filter_status": filter_status, - "limit": limit if not monitor_id else None - } + "Getting status for all monitors", + extra={"filter_status": filter_status, "limit": limit, "offset": offset} ) - + try: - response = await self._make_request( - method="GET", - endpoint=endpoint, - params=params, - operation_type="monitor" - ) - - status_result = await self._parse_json_response(response) - - logger.info( - f"Retrieved monitor status", - extra={ - "monitor_id": monitor_id, - "status_count": len(status_result.get("data", [])) if not monitor_id else 1 - } + listing = await self.list_monitors( + limit=limit, + offset=offset, + include_folders=True, ) - - return status_result - except APIError as e: raise APIError( f"Failed to get monitor status: {e.message}", @@ -2781,6 +2858,21 @@ async def get_monitor_status( request_id=e.request_id, context={"monitor_id": monitor_id} ) from e + + records: List[Dict[str, Any]] = [] + for item in listing.get("data", []): + monitor = self._unwrap_monitor_item(item) + # Skip folder entries; only real monitors carry a status. + if monitor.get("contentType") == "MonitorsLibraryFolder": + continue + records.append(self._build_monitor_status_record(monitor)) + + logger.info( + "Retrieved monitor status", + extra={"status_count": len(records)} + ) + + return {"data": records, "total": len(records)} async def enable_monitor(self, monitor_id: str) -> Dict[str, Any]: """Enable a disabled monitor. diff --git a/sumologic_mcp/tools/monitor_tools.py b/sumologic_mcp/tools/monitor_tools.py index d8102ab..69b6b01 100644 --- a/sumologic_mcp/tools/monitor_tools.py +++ b/sumologic_mcp/tools/monitor_tools.py @@ -4485,145 +4485,6 @@ async def _get_monitor_status_impl(): monitor_id=monitor_id, timeout_override=25.0 ) - logger.info( - "Getting monitor status", - extra={ - "monitor_id": monitor_id, - "filter_status": filter_status - } - ) - - # Validate filter_status if provided - if filter_status: - valid_statuses = ["triggered", "normal", "disabled", "unknown"] - if filter_status not in valid_statuses: - raise ValidationError( - f"Invalid filter_status. Must be one of: {', '.join(valid_statuses)}", - field_name="filter_status", - field_value=filter_status - ) - - # Validate monitor_id if provided - if monitor_id and not monitor_id.strip(): - raise ValidationError( - "Monitor ID cannot be empty", - field_name="monitor_id", - field_value=monitor_id - ) - - try: - # Call API client to get monitor status - api_response = await self.api_client.get_monitor_status( - monitor_id=monitor_id, - filter_status=filter_status - ) - - # Handle single monitor vs multiple monitors response - if monitor_id: - # Single monitor status - status_data = api_response.get("data", {}) - formatted_status = self._format_single_monitor_status(status_data, monitor_id) - - response = { - "success": True, - "monitor_status": formatted_status, - "metadata": { - "monitor_id": monitor_id, - "retrieved_at": datetime.now().isoformat(), - "status_type": "single_monitor" - } - } - else: - # Multiple monitors status - status_list = api_response.get("data", []) - - # Apply client-side filtering if needed - if filter_status: - status_list = self._filter_status_by_condition(status_list, filter_status) - - # Format status information - formatted_statuses = [ - self._format_single_monitor_status(status, status.get("monitorId")) - for status in status_list - ] - - # Calculate status statistics - status_stats = self._calculate_status_statistics(formatted_statuses) - - response = { - "success": True, - "monitor_statuses": formatted_statuses, - "metadata": { - "total_monitors": len(formatted_statuses), - "retrieved_at": datetime.now().isoformat(), - "status_type": "multiple_monitors", - "filter_applied": filter_status - }, - "statistics": status_stats - } - - logger.info( - "Successfully retrieved monitor status", - extra={ - "monitor_id": monitor_id, - "status_count": 1 if monitor_id else len(formatted_statuses), - "filter_status": filter_status - } - ) - - return response - - except APIError as e: - logger.error( - "Failed to get monitor status", - extra={ - "error": str(e), - "monitor_id": monitor_id, - "filter_status": filter_status - } - ) - - # Handle specific error cases - if e.status_code == 404: - raise APIError( - f"Monitor not found: {monitor_id}" if monitor_id else "Monitor status endpoint not found", - status_code=e.status_code, - request_id=e.request_id, - context={ - "operation": "get_monitor_status", - "monitor_id": monitor_id - } - ) from e - elif e.status_code == 403: - raise APIError( - f"Insufficient permissions to access monitor status" + (f" for monitor {monitor_id}" if monitor_id else ""), - status_code=e.status_code, - request_id=e.request_id, - context={ - "operation": "get_monitor_status", - "monitor_id": monitor_id - } - ) from e - else: - raise APIError( - f"Failed to get monitor status: {e.message}", - status_code=e.status_code, - request_id=e.request_id, - context={ - "operation": "get_monitor_status", - "monitor_id": monitor_id - } - ) from e - - except Exception as e: - logger.error( - "Unexpected error getting monitor status", - extra={"error": str(e), "error_type": type(e).__name__} - ) - raise APIError( - f"Unexpected error getting monitor status: {str(e)}", - context={"operation": "get_monitor_status"} - ) from e def _format_single_monitor_status(self, status_data: Dict[str, Any], monitor_id: str) -> Dict[str, Any]: """Format single monitor status information with trigger timestamps and severity. diff --git a/tests/test_monitor_status.py b/tests/test_monitor_status.py new file mode 100644 index 0000000..10113cf --- /dev/null +++ b/tests/test_monitor_status.py @@ -0,0 +1,110 @@ +"""Unit tests for monitor status normalization in the API client. + +These tests exercise the pure helper methods added for get_monitor_status +without requiring live credentials or network access. The client instance is +created with object.__new__ to bypass __init__, since the helpers only rely on +the class-level _TRIGGER_SEVERITY_ORDER attribute. +""" + +import pytest + +from sumologic_mcp.api_client import SumoLogicAPIClient + + +@pytest.fixture +def client() -> SumoLogicAPIClient: + """Return an API client instance without running __init__.""" + return object.__new__(SumoLogicAPIClient) + + +class TestUnwrapMonitorItem: + def test_unwraps_search_result_element(self, client): + element = {"item": {"id": "1", "name": "cpu"}, "path": "/Monitors/cpu"} + assert client._unwrap_monitor_item(element) == {"id": "1", "name": "cpu"} + + def test_returns_flat_monitor_unchanged(self, client): + monitor = {"id": "1", "name": "cpu"} + assert client._unwrap_monitor_item(monitor) == monitor + + def test_handles_non_dict_item(self, client): + element = {"item": "not-a-dict"} + assert client._unwrap_monitor_item(element) == element + + +class TestNormalizeMonitorStatus: + def test_disabled_flag_wins(self, client): + assert client._normalize_monitor_status(True, ["Critical"]) == ("Disabled", None) + + def test_disabled_status_string(self, client): + assert client._normalize_monitor_status(False, ["Disabled"]) == ("Disabled", None) + + def test_normal(self, client): + assert client._normalize_monitor_status(False, ["Normal"]) == ("Normal", None) + + def test_triggered_critical(self, client): + assert client._normalize_monitor_status(False, ["Critical"]) == ( + "Triggered", + "Critical", + ) + + def test_severity_ordering_prefers_critical(self, client): + status, severity = client._normalize_monitor_status( + False, ["MissingData", "Warning", "Critical"] + ) + assert status == "Triggered" + assert severity == "Critical" + + def test_severity_ordering_prefers_warning_over_missing_data(self, client): + status, severity = client._normalize_monitor_status( + False, ["MissingData", "Warning"] + ) + assert status == "Triggered" + assert severity == "Warning" + + def test_unknown_severity_still_triggered(self, client): + status, severity = client._normalize_monitor_status(False, ["SomethingElse"]) + assert status == "Triggered" + assert severity == "SomethingElse" + + def test_empty_status_is_unknown(self, client): + assert client._normalize_monitor_status(False, []) == ("Unknown", None) + + def test_none_status_is_unknown(self, client): + assert client._normalize_monitor_status(False, None) == ("Unknown", None) + + +class TestBuildMonitorStatusRecord: + def test_maps_core_fields_and_nulls_timing(self, client): + monitor = { + "id": "0000000000ABCDEF", + "name": "High CPU", + "isDisabled": False, + "status": ["Critical"], + "monitorType": "Logs", + } + + record = client._build_monitor_status_record(monitor) + + assert record["monitorId"] == "0000000000ABCDEF" + assert record["monitorName"] == "High CPU" + assert record["status"] == "Triggered" + assert record["currentTriggerSeverity"] == "Critical" + assert record["isDisabled"] is False + assert record["rawStatus"] == ["Critical"] + assert record["monitorType"] == "Logs" + # Timing fields are unavailable from the Monitors Management API. + assert record["lastTriggered"] is None + assert record["triggerCount24h"] == 0 + assert record["lastEvaluation"] is None + assert record["nextEvaluation"] is None + + def test_disabled_monitor(self, client): + monitor = {"id": "1", "name": "x", "isDisabled": True, "status": ["Normal"]} + record = client._build_monitor_status_record(monitor) + assert record["status"] == "Disabled" + assert record["currentTriggerSeverity"] is None + + def test_missing_name_defaults_to_unknown(self, client): + record = client._build_monitor_status_record({"id": "1", "status": ["Normal"]}) + assert record["monitorName"] == "Unknown" + assert record["status"] == "Normal" From b00ac7efa5f9b560c73c76f4bf5f06c9074a89e9 Mon Sep 17 00:00:00 2001 From: "a.key" Date: Tue, 16 Jun 2026 12:49:15 +0100 Subject: [PATCH 10/14] fix: migrate Pydantic v1 syntax to v2 to silence deprecation warnings Replace deprecated @validator with @field_validator (and @model_validator(mode="after") for cross-field consistency checks), swap class-based Config for model_config = ConfigDict(...), drop non-functional env= kwargs from Field definitions, and use min_length in place of min_items. Resolves all PydanticDeprecatedSince20 warnings; full test suite passes with -W error::DeprecationWarning. --- sumologic_mcp/auth.py | 6 +-- sumologic_mcp/config.py | 89 +++++++++++++++---------------- sumologic_mcp/models/config.py | 32 +++++------ sumologic_mcp/models/monitor.py | 55 +++++++++---------- sumologic_mcp/models/responses.py | 14 ++--- 5 files changed, 95 insertions(+), 101 deletions(-) diff --git a/sumologic_mcp/auth.py b/sumologic_mcp/auth.py index f525670..c8d53fc 100644 --- a/sumologic_mcp/auth.py +++ b/sumologic_mcp/auth.py @@ -8,7 +8,7 @@ from urllib.parse import urlparse import httpx -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from .config import SumoLogicConfig from .exceptions import AuthenticationError, APIError, ConfigurationError, TimeoutError @@ -25,9 +25,7 @@ class AuthSession(BaseModel): expires_at: Optional[datetime] = None session_id: Optional[str] = None - class Config: - """Pydantic configuration.""" - arbitrary_types_allowed = True + model_config = ConfigDict(arbitrary_types_allowed=True) class SumoLogicAuth: diff --git a/sumologic_mcp/config.py b/sumologic_mcp/config.py index 100101a..49a58dc 100644 --- a/sumologic_mcp/config.py +++ b/sumologic_mcp/config.py @@ -4,7 +4,7 @@ import os from pathlib import Path from typing import Optional, Dict, Any -from pydantic import BaseModel, Field, validator, ValidationError +from pydantic import BaseModel, Field, field_validator, ValidationError from dotenv import load_dotenv # Load environment variables from .env file if it exists @@ -14,88 +14,71 @@ class SumoLogicConfig(BaseModel): """Configuration for Sumo Logic MCP server.""" - # Sumo Logic API credentials (supports both new and reference environment variables) + # Sumo Logic API credentials (supports both new and reference environment variables). + # Environment loading is handled explicitly in from_env/from_env_and_file. access_id: str = Field( ..., - description="Sumo Logic Access ID", - env="SUMOLOGIC_ACCESS_ID" + description="Sumo Logic Access ID" ) access_key: str = Field( ..., - description="Sumo Logic Access Key", - env="SUMOLOGIC_ACCESS_KEY" + description="Sumo Logic Access Key" ) endpoint: str = Field( ..., - description="Sumo Logic API endpoint", - env="SUMOLOGIC_ENDPOINT" + description="Sumo Logic API endpoint" ) # Server configuration timeout: int = Field( default=30, - description="Request timeout in seconds", - env="SUMOLOGIC_TIMEOUT" + description="Request timeout in seconds" ) max_retries: int = Field( default=3, - description="Maximum retry attempts", - env="SUMOLOGIC_MAX_RETRIES" + description="Maximum retry attempts" ) rate_limit_delay: float = Field( default=1.0, - description="Delay between rate-limited requests in seconds", - env="SUMOLOGIC_RATE_LIMIT_DELAY" + description="Delay between rate-limited requests in seconds" ) # Logging configuration log_level: str = Field( default="INFO", - description="Logging level", - env="SUMOLOGIC_LOG_LEVEL" + description="Logging level" ) log_format: str = Field( default="json", - description="Log format (json or text)", - env="SUMOLOGIC_LOG_FORMAT" + description="Log format (json or text)" ) # MCP server configuration server_name: str = Field( default="sumologic-mcp-server", - description="MCP server name", - env="SUMOLOGIC_SERVER_NAME" + description="MCP server name" ) server_version: str = Field( default="0.1.0", - description="MCP server version", - env="SUMOLOGIC_SERVER_VERSION" + description="MCP server version" ) # Reference implementation compatibility query_timeout: int = Field( default=300, - description="Query timeout for compatibility with reference implementation", - env="QUERY_TIMEOUT" + description="Query timeout for compatibility with reference implementation" ) max_results: int = Field( default=1000, - description="Maximum results for compatibility with reference implementation", - env="MAX_RESULTS" + description="Maximum results for compatibility with reference implementation" ) default_vmware_source: str = Field( default="otel/vmware", - description="Default VMware source category for metrics exploration", - env="DEFAULT_VMWARE_SOURCE" + description="Default VMware source category for metrics exploration" ) - class Config: - """Pydantic configuration.""" - env_file = ".env" - env_file_encoding = "utf-8" - case_sensitive = False - - @validator("endpoint") + @field_validator("endpoint") + @classmethod def validate_endpoint(cls, v: str) -> str: """Validate Sumo Logic endpoint URL.""" if not v.startswith(("http://", "https://")): @@ -104,7 +87,8 @@ def validate_endpoint(cls, v: str) -> str: raise ValueError("Endpoint must be a valid Sumo Logic domain") return v.rstrip("/") - @validator("log_level") + @field_validator("log_level") + @classmethod def validate_log_level(cls, v: str) -> str: """Validate log level.""" valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} @@ -113,7 +97,8 @@ def validate_log_level(cls, v: str) -> str: raise ValueError(f"Log level must be one of: {', '.join(valid_levels)}") return v_upper - @validator("log_format") + @field_validator("log_format") + @classmethod def validate_log_format(cls, v: str) -> str: """Validate log format.""" valid_formats = {"json", "text"} @@ -122,7 +107,8 @@ def validate_log_format(cls, v: str) -> str: raise ValueError(f"Log format must be one of: {', '.join(valid_formats)}") return v_lower - @validator("timeout") + @field_validator("timeout") + @classmethod def validate_timeout(cls, v: int) -> int: """Validate timeout value.""" if v <= 0: @@ -131,7 +117,8 @@ def validate_timeout(cls, v: int) -> int: raise ValueError("Timeout must not exceed 300 seconds") return v - @validator("max_retries") + @field_validator("max_retries") + @classmethod def validate_max_retries(cls, v: int) -> int: """Validate max retries value.""" if v < 0: @@ -140,7 +127,8 @@ def validate_max_retries(cls, v: int) -> int: raise ValueError("Max retries should not exceed 10") return v - @validator("rate_limit_delay") + @field_validator("rate_limit_delay") + @classmethod def validate_rate_limit_delay(cls, v: float) -> float: """Validate rate limit delay.""" if v < 0: @@ -149,7 +137,8 @@ def validate_rate_limit_delay(cls, v: float) -> float: raise ValueError("Rate limit delay should not exceed 60 seconds") return v - @validator("query_timeout") + @field_validator("query_timeout") + @classmethod def validate_query_timeout(cls, v: int) -> int: """Validate query timeout value.""" if v <= 0: @@ -158,7 +147,8 @@ def validate_query_timeout(cls, v: int) -> int: raise ValueError("Query timeout must not exceed 3600 seconds (1 hour)") return v - @validator("max_results") + @field_validator("max_results") + @classmethod def validate_max_results(cls, v: int) -> int: """Validate max results value.""" if v <= 0: @@ -167,7 +157,8 @@ def validate_max_results(cls, v: int) -> int: raise ValueError("Max results should not exceed 100,000") return v - @validator("default_vmware_source") + @field_validator("default_vmware_source") + @classmethod def validate_default_vmware_source(cls, v: str) -> str: """Validate default VMware source category.""" if not v.strip(): @@ -505,14 +496,16 @@ class SearchRequest(BaseModel): by_receipt_time: bool = Field(default=False, description="Search by receipt time") auto_parsing_mode: Optional[str] = Field(None, description="Auto parsing mode") - @validator("query") + @field_validator("query") + @classmethod def validate_query(cls, v: str) -> str: """Validate search query.""" if not v.strip(): raise ValueError("Query cannot be empty") return v.strip() - @validator("from_time", "to_time") + @field_validator("from_time", "to_time") + @classmethod def validate_time_format(cls, v: str) -> str: """Validate time format (ISO 8601, relative time, or 'now').""" if not v.strip(): @@ -569,7 +562,8 @@ class DashboardConfig(BaseModel): description="Auto-refresh interval in seconds (minimum 30)" ) - @validator("title") + @field_validator("title") + @classmethod def validate_title(cls, v: str) -> str: """Validate dashboard title.""" if not v.strip(): @@ -578,7 +572,8 @@ def validate_title(cls, v: str) -> str: raise ValueError("Title cannot exceed 255 characters") return v.strip() - @validator("panels") + @field_validator("panels") + @classmethod def validate_panels(cls, v: list) -> list: """Validate panels configuration.""" if not v: diff --git a/sumologic_mcp/models/config.py b/sumologic_mcp/models/config.py index 5742f4b..4023e53 100644 --- a/sumologic_mcp/models/config.py +++ b/sumologic_mcp/models/config.py @@ -1,6 +1,6 @@ """Configuration models for Sumo Logic MCP server.""" -from pydantic import BaseModel, Field, validator, HttpUrl +from pydantic import BaseModel, Field, field_validator, HttpUrl from typing import Optional, Dict, Any, List import re @@ -15,7 +15,7 @@ class SumoLogicConfig(BaseModel): max_retries: int = Field(default=3, description="Maximum retry attempts", ge=0, le=10) rate_limit_delay: float = Field(default=1.0, description="Delay between rate-limited requests", ge=0.1, le=60.0) - @validator('endpoint') + @field_validator('endpoint') def validate_endpoint(cls, v): """Validate that endpoint is a proper Sumo Logic API URL.""" if not v.startswith('https://'): @@ -24,14 +24,14 @@ def validate_endpoint(cls, v): raise ValueError('Endpoint must be a valid Sumo Logic API URL') return v - @validator('access_id') + @field_validator('access_id') def validate_access_id(cls, v): """Validate access ID format.""" if not re.match(r'^[A-Za-z0-9]{14}$', v): raise ValueError('Access ID must be 14 alphanumeric characters') return v - @validator('access_key') + @field_validator('access_key') def validate_access_key(cls, v): """Validate access key format.""" if len(v) < 20: @@ -51,14 +51,14 @@ class SearchRequest(BaseModel): by_receipt_time: bool = Field(default=False, description="Search by receipt time instead of message time") auto_parsing_mode: Optional[str] = Field(default=None, description="Auto parsing mode ('intelligent', 'performance')") - @validator('query') + @field_validator('query') def validate_query(cls, v): """Validate search query is not empty after stripping whitespace.""" if not v.strip(): raise ValueError('Search query cannot be empty') return v.strip() - @validator('from_time', 'to_time') + @field_validator('from_time', 'to_time') def validate_time_format(cls, v): """Validate time format (ISO 8601, relative time, or 'now').""" # Allow 'now' as a valid time format @@ -95,7 +95,7 @@ def validate_time_format(cls, v): ) return v - @validator('auto_parsing_mode') + @field_validator('auto_parsing_mode') def validate_auto_parsing_mode(cls, v): """Validate auto parsing mode.""" if v is not None and v not in ['intelligent', 'performance']: @@ -108,28 +108,28 @@ class DashboardConfig(BaseModel): title: str = Field(..., description="Dashboard title", min_length=1, max_length=255) description: Optional[str] = Field(None, description="Dashboard description", max_length=1000) - panels: List[Dict[str, Any]] = Field(..., description="Dashboard panels configuration", min_items=1) + panels: List[Dict[str, Any]] = Field(..., description="Dashboard panels configuration", min_length=1) refresh_interval: Optional[int] = Field(None, description="Auto-refresh interval in seconds", ge=30, le=86400) folder_id: Optional[str] = Field(None, description="Folder ID to place dashboard in") theme: Optional[str] = Field(default="Light", description="Dashboard theme") topology_label_map: Optional[Dict[str, Any]] = Field(None, description="Topology label mapping") domain: Optional[str] = Field(None, description="Dashboard domain") - @validator('title') + @field_validator('title') def validate_title(cls, v): """Validate dashboard title.""" if not v.strip(): raise ValueError('Dashboard title cannot be empty') return v.strip() - @validator('theme') + @field_validator('theme') def validate_theme(cls, v): """Validate dashboard theme.""" if v is not None and v not in ['Light', 'Dark']: raise ValueError('Theme must be "Light" or "Dark"') return v - @validator('panels') + @field_validator('panels') def validate_panels(cls, v): """Validate panels configuration.""" if not v: @@ -157,14 +157,14 @@ class MetricsRequest(BaseModel): requested_data_points: Optional[int] = Field(default=600, ge=1, le=1440, description="Number of data points to return") max_tab_results: Optional[int] = Field(default=100, ge=1, le=1000, description="Maximum tabular results") - @validator('query') + @field_validator('query') def validate_metrics_query(cls, v): """Validate metrics query is not empty.""" if not v.strip(): raise ValueError('Metrics query cannot be empty') return v.strip() - @validator('from_time', 'to_time') + @field_validator('from_time', 'to_time') def validate_time_format(cls, v): """Validate time format (ISO 8601, relative time, or 'now').""" # Allow 'now' as a valid time format @@ -213,7 +213,7 @@ class CollectorConfig(BaseModel): ephemeral: bool = Field(default=True, description="Whether collector is ephemeral") source_sync_mode: Optional[str] = Field(default="UI", description="Source synchronization mode") - @validator('name') + @field_validator('name') def validate_name(cls, v): """Validate collector name.""" if not v.strip(): @@ -223,7 +223,7 @@ def validate_name(cls, v): raise ValueError('Collector name contains invalid characters') return v.strip() - @validator('source_sync_mode') + @field_validator('source_sync_mode') def validate_source_sync_mode(cls, v): """Validate source sync mode.""" if v is not None and v not in ['UI', 'JSON', 'Both']: @@ -246,7 +246,7 @@ class SourceConfig(BaseModel): default_date_format: Optional[str] = Field(None, description="Default date format") filters: Optional[List[Dict[str, Any]]] = Field(None, description="Processing filters") - @validator('name') + @field_validator('name') def validate_name(cls, v): """Validate source name.""" if not v.strip(): diff --git a/sumologic_mcp/models/monitor.py b/sumologic_mcp/models/monitor.py index 9bcc0f5..7e3c469 100644 --- a/sumologic_mcp/models/monitor.py +++ b/sumologic_mcp/models/monitor.py @@ -1,6 +1,6 @@ """Monitor-specific data models for Sumo Logic MCP server.""" -from pydantic import BaseModel, Field, validator +from pydantic import BaseModel, Field, field_validator, model_validator, ValidationInfo from typing import List, Dict, Any, Optional, Union from enum import Enum import re @@ -67,7 +67,7 @@ class TriggerCondition(BaseModel): occurrence_type: OccurrenceType = Field(default=OccurrenceType.RESULT_COUNT, description="Type of occurrence evaluation") trigger_source: TriggerSource = Field(default=TriggerSource.ALL_RESULTS, description="Source for trigger evaluation") - @validator('time_range') + @field_validator('time_range') def validate_time_range(cls, v): """Validate time range format.""" # Allow relative time expressions like -5m, -1h, -1d @@ -75,7 +75,7 @@ def validate_time_range(cls, v): raise ValueError('Time range must be in relative format (e.g., -5m, -1h, -1d)') return v - @validator('threshold') + @field_validator('threshold') def validate_threshold(cls, v): """Validate threshold is a valid number.""" if not isinstance(v, (int, float)): @@ -92,10 +92,11 @@ class NotificationAction(BaseModel): webhook_url: Optional[str] = Field(None, description="Webhook URL for webhook notifications") message_body: Optional[str] = Field(None, description="Custom message body for notifications", max_length=2000) - @validator('recipients') - def validate_recipients(cls, v, values): + @field_validator('recipients') + @classmethod + def validate_recipients(cls, v, info: ValidationInfo): """Validate recipients based on action type.""" - action_type = values.get('action_type') + action_type = info.data.get('action_type') if action_type == NotificationType.EMAIL and v: # Validate email addresses @@ -106,10 +107,11 @@ def validate_recipients(cls, v, values): return v - @validator('webhook_url') - def validate_webhook_url(cls, v, values): + @field_validator('webhook_url') + @classmethod + def validate_webhook_url(cls, v, info: ValidationInfo): """Validate webhook URL format.""" - action_type = values.get('action_type') + action_type = info.data.get('action_type') if action_type == NotificationType.WEBHOOK: if not v: @@ -133,7 +135,7 @@ class MonitorConfig(BaseModel): group_notifications: bool = Field(default=True, description="Whether to group notifications") evaluation_delay: Optional[str] = Field(default="0m", description="Delay before evaluation (e.g., '5m')") - @validator('name') + @field_validator('name') def validate_name(cls, v): """Validate monitor name.""" if not v.strip(): @@ -143,14 +145,14 @@ def validate_name(cls, v): raise ValueError('Monitor name contains invalid characters') return v.strip() - @validator('query') + @field_validator('query') def validate_query(cls, v): """Validate monitor query is not empty.""" if not v.strip(): raise ValueError('Monitor query cannot be empty') return v.strip() - @validator('trigger_conditions') + @field_validator('trigger_conditions') def validate_trigger_conditions(cls, v): """Validate trigger conditions.""" if not v: @@ -163,7 +165,7 @@ def validate_trigger_conditions(cls, v): return v - @validator('evaluation_delay') + @field_validator('evaluation_delay') def validate_evaluation_delay(cls, v): """Validate evaluation delay format.""" if v and not re.match(r'^\d+[smh]$', v): @@ -191,14 +193,14 @@ class MonitorResponse(BaseModel): evaluation_delay: Optional[str] = Field(None, description="Evaluation delay setting") group_notifications: Optional[bool] = Field(None, description="Group notifications setting") - @validator('id') + @field_validator('id') def validate_id(cls, v): """Validate monitor ID.""" if not v or not isinstance(v, str): raise ValueError('Monitor ID must be a non-empty string') return v - @validator('version') + @field_validator('version') def validate_version(cls, v): """Validate version is positive.""" if v < 1: @@ -218,7 +220,7 @@ class MonitorStatusInfo(BaseModel): last_evaluation: Optional[str] = Field(None, description="Last evaluation timestamp") next_evaluation: Optional[str] = Field(None, description="Next scheduled evaluation timestamp") - @validator('monitor_id') + @field_validator('monitor_id') def validate_monitor_id(cls, v): """Validate monitor ID.""" if not v or not isinstance(v, str): @@ -239,14 +241,14 @@ class ActiveAlert(BaseModel): alert_id: str = Field(..., description="Unique alert identifier") status: str = Field(default="Active", description="Alert status") - @validator('monitor_id', 'alert_id') + @field_validator('monitor_id', 'alert_id') def validate_ids(cls, v): """Validate ID fields.""" if not v or not isinstance(v, str): raise ValueError('ID must be a non-empty string') return v - @validator('severity') + @field_validator('severity') def validate_severity(cls, v): """Validate severity level.""" valid_severities = ['Critical', 'Warning', 'MissingData'] @@ -266,7 +268,7 @@ class MonitorHistoryEntry(BaseModel): trigger_value: Optional[float] = Field(None, description="Value that caused trigger (if triggered)") error_message: Optional[str] = Field(None, description="Error message if execution failed") - @validator('execution_duration_ms') + @field_validator('execution_duration_ms') def validate_duration(cls, v): """Validate execution duration.""" if v is not None and v < 0: @@ -284,14 +286,14 @@ class MonitorHistoryResponse(BaseModel): trigger_patterns: Dict[str, Any] = Field(..., description="Trigger pattern analysis") metadata: Dict[str, Any] = Field(..., description="Request metadata and pagination info") - @validator('monitor_id') + @field_validator('monitor_id') def validate_monitor_id(cls, v): """Validate monitor ID.""" if not v or not isinstance(v, str): raise ValueError('Monitor ID must be a non-empty string') return v - @validator('execution_history') + @field_validator('execution_history') def validate_execution_history(cls, v): """Validate execution history is a list.""" if not isinstance(v, list): @@ -309,12 +311,11 @@ class MonitorValidationResult(BaseModel): trigger_conditions_valid: Optional[bool] = Field(None, description="Whether trigger conditions are valid") notifications_valid: Optional[bool] = Field(None, description="Whether notification configurations are valid") - @validator('valid') - def validate_consistency(cls, v, values): + @model_validator(mode="after") + def validate_consistency(self) -> "MonitorValidationResult": """Ensure validity is consistent with errors.""" - errors = values.get('errors', []) - if v and errors: + if self.valid and self.errors: raise ValueError('Configuration cannot be valid if there are errors') - if not v and not errors: + if not self.valid and not self.errors: raise ValueError('Configuration must have errors if marked as invalid') - return v \ No newline at end of file + return self \ No newline at end of file diff --git a/sumologic_mcp/models/responses.py b/sumologic_mcp/models/responses.py index 44f31d3..803c109 100644 --- a/sumologic_mcp/models/responses.py +++ b/sumologic_mcp/models/responses.py @@ -1,6 +1,6 @@ """Response models for Sumo Logic API data.""" -from pydantic import BaseModel, Field, validator +from pydantic import BaseModel, Field, field_validator from typing import Optional, List, Dict, Any, Union from datetime import datetime from enum import Enum @@ -28,7 +28,7 @@ class SearchResult(BaseModel): pending_errors: List[str] = Field(default_factory=list, description="Pending errors from search") histogram_buckets: Optional[List[Dict[str, Any]]] = Field(None, description="Histogram data buckets") - @validator('job_id') + @field_validator('job_id') def validate_job_id(cls, v): """Validate job ID format.""" if not v or not isinstance(v, str): @@ -85,7 +85,7 @@ class DashboardInfo(BaseModel): modified_by: Optional[str] = Field(None, description="Last modifier user ID") version: Optional[int] = Field(None, description="Dashboard version") - @validator('id') + @field_validator('id') def validate_id(cls, v): """Validate dashboard ID.""" if not v or not isinstance(v, str): @@ -160,7 +160,7 @@ class CollectorInfo(BaseModel): modified_at: Optional[str] = Field(None, description="Last modification timestamp") modified_by: Optional[str] = Field(None, description="Last modifier user ID") - @validator('id') + @field_validator('id') def validate_id(cls, v): """Validate collector ID.""" if v is None or v < 0: @@ -177,7 +177,7 @@ class MetricsQueryResult(BaseModel): query_info: Optional[Dict[str, Any]] = Field(None, description="Query execution information") unit: Optional[str] = Field(None, description="Unit of measurement") - @validator('query') + @field_validator('query') def validate_query(cls, v): """Validate metrics query.""" if not v or not isinstance(v, str): @@ -221,14 +221,14 @@ class PaginatedResponse(BaseModel): has_more: Optional[bool] = Field(None, description="Whether more items are available") next_token: Optional[str] = Field(None, description="Token for next page") - @validator('offset') + @field_validator('offset') def validate_offset(cls, v): """Validate offset is non-negative.""" if v < 0: raise ValueError('Offset must be non-negative') return v - @validator('limit') + @field_validator('limit') def validate_limit(cls, v): """Validate limit is positive.""" if v <= 0: From 97021595799dddc0fb272d2a6d7459908220fecf Mon Sep 17 00:00:00 2001 From: "a.key" Date: Tue, 16 Jun 2026 17:00:10 +0100 Subject: [PATCH 11/14] feat: add generic api CLI subcommand and api_call MCP tool - Implement execute_raw_request on SumoLogicAPIClient to support raw requests using configured authentication, resilience, and rate-limiting - Add api_call MCP tool to APITools and register it in server.py - Add api subcommand to main.py supporting optional -X/--method switch (defaulting to GET), params, headers, and request body - Write unit tests in test_api_tools.py - Document tool and CLI subcommand in README.md --- README.md | 33 + sumologic_mcp/api_client.py | 2477 +++++++++++++++--------------- sumologic_mcp/main.py | 305 +++- sumologic_mcp/server.py | 300 ++-- sumologic_mcp/tools/__init__.py | 12 +- sumologic_mcp/tools/api_tools.py | 121 ++ tests/test_api_tools.py | 74 + 7 files changed, 1855 insertions(+), 1467 deletions(-) create mode 100644 sumologic_mcp/tools/api_tools.py create mode 100644 tests/test_api_tools.py diff --git a/README.md b/README.md index 78899f3..9167235 100644 --- a/README.md +++ b/README.md @@ -377,6 +377,39 @@ Configure your MCP client to connect to this server. Example configuration for v - `validate_monitor_config`: Validate monitor configuration - `get_monitor_history`: Get monitor execution history and metrics +### Generic API Tools +- `api_call`: Execute raw, authenticated HTTP requests to any Sumo Logic API endpoint (useful for fallback or unsupported APIs) + +## CLI Subcommand + +This MCP server package also exposes an `api` CLI subcommand (similar to GitHub CLI's `gh api`) that lets you execute authenticated and rate-limited HTTP requests to Sumo Logic directly from your terminal. It reuses the credentials configured in your environment or configuration files. + +### Syntax +```bash +python -m sumologic_mcp api [OPTIONS] +``` + +### Examples +```bash +# List all users (defaults to GET) +python -m sumologic_mcp api "/v1/users" + +# Create a folder (using -X POST) +python -m sumologic_mcp api "/v1/folders" -X POST --body '{"name": "New Folder", "parentId": "0000000000000001"}' + +# Delete a collector (using -X DELETE) +python -m sumologic_mcp api "/v1/collectors/10002" -X DELETE + +# Get with query parameters +python -m sumologic_mcp api "/v1/collectors" -p "limit=5" -p "offset=0" +``` + +### Options +* `-X, --method`: HTTP Method (GET, POST, PUT, DELETE, PATCH). Defaults to `GET`. +* `-b, --body`: JSON string request body for write requests. +* `-p, --param`: Query parameters in `KEY=VALUE` format (can be specified multiple times). +* `-H, --header`: Custom headers in `KEY=VALUE` or `KEY:VALUE` format (can be specified multiple times). + ## Development ### Setup Development Environment diff --git a/sumologic_mcp/api_client.py b/sumologic_mcp/api_client.py index 873b5ee..55ef720 100644 --- a/sumologic_mcp/api_client.py +++ b/sumologic_mcp/api_client.py @@ -5,55 +5,54 @@ import logging import time from datetime import datetime, timedelta -from typing import Dict, Any, Optional, List, Union -from urllib.parse import urljoin, urlencode +from typing import Any, Dict, List, Optional, Union +from urllib.parse import urlencode, urljoin import httpx from pydantic import ValidationError as PydanticValidationError +from .api_validator import SumoLogicAPIValidator from .auth import SumoLogicAuth -from .config import SumoLogicConfig, SearchRequest, DashboardConfig -from .models.config import MetricsRequest, CollectorConfig, SourceConfig +from .config import DashboardConfig, SearchRequest, SumoLogicConfig from .exceptions import ( - APIError, - RateLimitError, - ValidationError, - TimeoutError, + APIError, + APIParameterError, + RateLimitError, SearchError, SumoLogicError, + TimeoutError, TimeValidationError, - APIParameterError + ValidationError, ) +from .models.config import CollectorConfig, MetricsRequest, SourceConfig +from .monitoring import MetricsCollector from .resilience import ( + CircuitBreakerConfig, ResilientAPIClient, RetryConfig, - CircuitBreakerConfig, - TimeoutManager + TimeoutManager, ) -from .api_validator import SumoLogicAPIValidator -from .monitoring import MetricsCollector from .time_utils import TimeParser - logger = logging.getLogger(__name__) class SumoLogicAPIClient: """Low-level client for Sumo Logic REST APIs. - + This class provides a comprehensive interface to Sumo Logic's REST APIs, including search, dashboard, metrics, and collector management operations. It handles authentication, rate limiting, retries, and error handling. - + Attributes: config: Sumo Logic configuration auth: Authentication manager http_client: HTTP client for API requests """ - + def __init__(self, config: SumoLogicConfig, auth: SumoLogicAuth): """Initialize API client with configuration and authentication. - + Args: config: Sumo Logic configuration containing API settings auth: Authentication manager for handling credentials @@ -63,18 +62,22 @@ def __init__(self, config: SumoLogicConfig, auth: SumoLogicAuth): self._http_client: Optional[httpx.AsyncClient] = None self._rate_limit_lock = asyncio.Lock() self._last_request_time = 0.0 - + # Initialize resilience components retry_config = RetryConfig( max_attempts=self.config.max_retries, base_delay=self.config.rate_limit_delay, max_delay=min(self.config.timeout / 2, 60.0), # Don't exceed half timeout retryable_exceptions=( - APIError, RateLimitError, TimeoutError, - ConnectionError, OSError, httpx.RequestError - ) + APIError, + RateLimitError, + TimeoutError, + ConnectionError, + OSError, + httpx.RequestError, + ), ) - + # Only transient/infra-class failures should count toward opening the # circuit. Bare `Exception` would mean user errors (e.g. invalid query # syntax → 4xx, validation errors) also count, which can trip the @@ -83,65 +86,70 @@ def __init__(self, config: SumoLogicConfig, auth: SumoLogicAuth): failure_threshold=max(5, self.config.max_retries * 2), recovery_timeout=60.0, expected_exception=( - APIError, RateLimitError, TimeoutError, - ConnectionError, OSError, httpx.RequestError, + APIError, + RateLimitError, + TimeoutError, + ConnectionError, + OSError, + httpx.RequestError, ), - success_threshold=3 + success_threshold=3, ) - + self.resilient_client = ResilientAPIClient( name="sumologic-api", retry_config=retry_config, - circuit_breaker_config=circuit_breaker_config + circuit_breaker_config=circuit_breaker_config, ) - + # Initialize timeout manager with operation-specific timeouts self.timeout_manager = TimeoutManager(default_timeout=self.config.timeout) self._setup_operation_timeouts() - + # Initialize metrics collector self.metrics_collector = MetricsCollector() - + logger.info( "Initialized Sumo Logic API client with resilience patterns and monitoring", extra={ "endpoint": self.config.endpoint, "timeout": self.config.timeout, "max_retries": self.config.max_retries, - "circuit_breaker_threshold": circuit_breaker_config.failure_threshold - } + "circuit_breaker_threshold": circuit_breaker_config.failure_threshold, + }, ) - + def _setup_operation_timeouts(self): """Setup operation-specific timeouts.""" # Search operations may take longer self.timeout_manager.set_timeout("search", self.config.timeout * 2) self.timeout_manager.set_timeout("search_results", self.config.timeout * 1.5) - + # Dashboard operations are usually quick self.timeout_manager.set_timeout("dashboard", self.config.timeout) - + # Metrics queries can be complex self.timeout_manager.set_timeout("metrics", self.config.timeout * 1.5) - + # Collector operations are typically fast self.timeout_manager.set_timeout("collector", self.config.timeout) - + # Monitor operations are usually quick self.timeout_manager.set_timeout("monitor", self.config.timeout) - + @property def http_client(self) -> httpx.AsyncClient: """Get or create HTTP client for API requests. - + Returns: Configured HTTP client instance """ if self._http_client is None: # Check if SSL verification should be disabled import os - verify_ssl = os.getenv('PYTHONHTTPSVERIFY', '1') != '0' - + + verify_ssl = os.getenv("PYTHONHTTPSVERIFY", "1") != "0" + self._http_client = httpx.AsyncClient( timeout=httpx.Timeout(self.config.timeout), verify=verify_ssl, @@ -149,11 +157,11 @@ def http_client(self) -> httpx.AsyncClient: headers={ "User-Agent": f"sumologic-mcp-server/{self.config.server_version}", "Accept": "application/json", - "Content-Type": "application/json" - } + "Content-Type": "application/json", + }, ) return self._http_client - + async def _make_request( self, method: str, @@ -161,10 +169,10 @@ async def _make_request( params: Optional[Dict[str, Any]] = None, json_data: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None, - operation_type: str = "api" + operation_type: str = "api", ) -> httpx.Response: """Make HTTP request with resilience patterns. - + Args: method: HTTP method (GET, POST, PUT, DELETE) endpoint: API endpoint path @@ -172,10 +180,10 @@ async def _make_request( json_data: JSON request body headers: Additional headers operation_type: Type of operation for timeout management - + Returns: HTTP response object - + Raises: APIError: If request fails after all retries RateLimitError: If rate limit is exceeded @@ -183,34 +191,33 @@ async def _make_request( """ # Record request metrics await self.metrics_collector.increment_counter( - "api_requests_total", - labels={"method": method, "operation": operation_type} + "api_requests_total", labels={"method": method, "operation": operation_type} ) - + request_start_time = time.time() - + # Use resilient client to execute the request with retry and circuit breaker async def make_single_request() -> httpx.Response: # Apply rate limiting await self._apply_rate_limiting() - + # Build full URL - url = urljoin(self.config.endpoint, endpoint.lstrip('/')) - + url = urljoin(self.config.endpoint, endpoint.lstrip("/")) + # Get authentication headers try: auth_headers = await self.auth.get_auth_headers() except Exception as e: raise APIError( f"Failed to get authentication headers: {e}", - context={"endpoint": endpoint, "method": method} + context={"endpoint": endpoint, "method": method}, ) from e - + # Merge headers request_headers = auth_headers.copy() if headers: request_headers.update(headers) - + # Log request details logger.debug( f"Making {method} request to {endpoint}", @@ -218,40 +225,44 @@ async def make_single_request() -> httpx.Response: "url": url, "params": params, "has_json_data": json_data is not None, - "operation_type": operation_type - } + "operation_type": operation_type, + }, ) - + # Execute request with timeout response = await self.timeout_manager.execute_with_timeout( self._execute_http_request, operation_type, - method, url, params, json_data, request_headers + method, + url, + params, + json_data, + request_headers, ) - + # Handle response and errors await self._handle_response_errors(response, endpoint, method) - + return response - + try: # Execute with resilience patterns response = await self.resilient_client.execute(make_single_request) - + # Record success metrics request_duration = (time.time() - request_start_time) * 1000 await self.metrics_collector.increment_counter( "api_requests_success_total", - labels={"method": method, "operation": operation_type} + labels={"method": method, "operation": operation_type}, ) await self.metrics_collector.record_timer( "api_request_duration_ms", request_duration, - labels={"method": method, "operation": operation_type} + labels={"method": method, "operation": operation_type}, ) - + return response - + except Exception as e: # Record failure metrics request_duration = (time.time() - request_start_time) * 1000 @@ -260,8 +271,8 @@ async def make_single_request() -> httpx.Response: labels={ "method": method, "operation": operation_type, - "error_type": type(e).__name__ - } + "error_type": type(e).__name__, + }, ) await self.metrics_collector.record_timer( "api_request_duration_ms", @@ -269,40 +280,36 @@ async def make_single_request() -> httpx.Response: labels={ "method": method, "operation": operation_type, - "status": "failure" - } + "status": "failure", + }, ) raise - + async def _execute_http_request( self, method: str, url: str, params: Optional[Dict[str, Any]], json_data: Optional[Dict[str, Any]], - headers: Dict[str, str] + headers: Dict[str, str], ) -> httpx.Response: """Execute the actual HTTP request. - + Args: method: HTTP method url: Full URL params: Query parameters json_data: JSON request body headers: Request headers - + Returns: HTTP response """ try: response = await self.http_client.request( - method=method, - url=url, - params=params, - json=json_data, - headers=headers + method=method, url=url, params=params, json=json_data, headers=headers ) - + # Log response details logger.debug( f"Received response", @@ -310,42 +317,35 @@ async def _execute_http_request( "status_code": response.status_code, "response_size": len(response.content) if response.content else 0, "request_id": response.headers.get("x-sumo-request-id"), - "url": url - } + "url": url, + }, ) - + return response - + except httpx.TimeoutException as e: raise TimeoutError( f"Request to {url} timed out", timeout_seconds=self.config.timeout, - operation=f"{method} {url}" + operation=f"{method} {url}", ) from e - + except httpx.RequestError as e: raise APIError( f"Request to {url} failed: {e}", - context={ - "method": method, - "url": url, - "error_type": type(e).__name__ - } + context={"method": method, "url": url, "error_type": type(e).__name__}, ) from e - + async def _handle_response_errors( - self, - response: httpx.Response, - endpoint: str, - method: str + self, response: httpx.Response, endpoint: str, method: str ) -> None: """Handle HTTP response errors. - + Args: response: HTTP response to check endpoint: API endpoint method: HTTP method - + Raises: RateLimitError: If rate limited APIError: For other HTTP errors @@ -357,12 +357,9 @@ async def _handle_response_errors( f"Rate limit exceeded for {endpoint}", retry_after=retry_after, limit_type="api_requests", - context={ - "endpoint": endpoint, - "method": method - } + context={"endpoint": endpoint, "method": method}, ) - + # Handle authentication errors if response.status_code == 401: raise APIError( @@ -370,13 +367,13 @@ async def _handle_response_errors( status_code=401, response_body=response.text, request_id=response.headers.get("x-sumo-request-id"), - context={"endpoint": endpoint, "method": method} + context={"endpoint": endpoint, "method": method}, ) - + # Handle client errors (4xx) if 400 <= response.status_code < 500: error_message = f"Client error {response.status_code} for {endpoint}" - + # Try to extract error details from response try: error_data = response.json() @@ -384,15 +381,15 @@ async def _handle_response_errors( error_message = error_data["message"] except (json.JSONDecodeError, ValueError): error_message = response.text or error_message - + raise APIError( error_message, status_code=response.status_code, response_body=response.text, request_id=response.headers.get("x-sumo-request-id"), - context={"endpoint": endpoint, "method": method} + context={"endpoint": endpoint, "method": method}, ) - + # Handle server errors (5xx) if response.status_code >= 500: raise APIError( @@ -400,33 +397,33 @@ async def _handle_response_errors( status_code=response.status_code, response_body=response.text, request_id=response.headers.get("x-sumo-request-id"), - context={"endpoint": endpoint, "method": method} + context={"endpoint": endpoint, "method": method}, ) - + async def _apply_rate_limiting(self) -> None: """Apply rate limiting to prevent exceeding API limits.""" async with self._rate_limit_lock: current_time = asyncio.get_event_loop().time() time_since_last = current_time - self._last_request_time - + if time_since_last < self.config.rate_limit_delay: sleep_time = self.config.rate_limit_delay - time_since_last logger.debug(f"Rate limiting: sleeping for {sleep_time:.2f} seconds") await asyncio.sleep(sleep_time) - + self._last_request_time = asyncio.get_event_loop().time() - + def _parse_retry_after(self, response: httpx.Response) -> float: """Parse Retry-After header from rate limit response. - + Args: response: HTTP response with rate limit error - + Returns: Number of seconds to wait before retrying """ retry_after_header = response.headers.get("Retry-After") - + if retry_after_header: try: # Try parsing as seconds @@ -435,23 +432,24 @@ def _parse_retry_after(self, response: httpx.Response) -> float: # Try parsing as HTTP date try: from email.utils import parsedate_to_datetime + retry_time = parsedate_to_datetime(retry_after_header) return max(0, (retry_time - datetime.utcnow()).total_seconds()) except (ValueError, TypeError): pass - + # Default backoff if no valid Retry-After header return self.config.rate_limit_delay * 2 - + async def _parse_json_response(self, response: httpx.Response) -> Dict[str, Any]: """Parse JSON response with error handling. - + Args: response: HTTP response to parse - + Returns: Parsed JSON data - + Raises: APIError: If response cannot be parsed as JSON """ @@ -463,79 +461,122 @@ async def _parse_json_response(self, response: httpx.Response) -> Dict[str, Any] status_code=response.status_code, response_body=response.text, request_id=response.headers.get("x-sumo-request-id"), - context={"content_type": response.headers.get("content-type")} + context={"content_type": response.headers.get("content-type")}, ) from e - + + async def execute_raw_request( + self, + method: str, + path: str, + params: Optional[Dict[str, Any]] = None, + body: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + ) -> Dict[str, Any]: + """Execute a raw HTTP request to the Sumo Logic API using server credentials. + + Args: + method: HTTP method (GET, POST, PUT, DELETE, PATCH) + path: API path suffix (e.g., '/v1/users') + params: Optional query parameters + body: Optional request JSON body + headers: Optional additional request headers + + Returns: + Dict containing response status_code, headers, and parsed body. + """ + response = await self._make_request( + method=method.upper(), + endpoint=path, + params=params, + json_data=body, + headers=headers, + operation_type="api", + ) + + try: + if response.status_code == 204 or not response.content: + response_data = None + else: + response_data = response.json() + except (ValueError, json.JSONDecodeError): + response_data = response.text + + return { + "status_code": response.status_code, + "headers": dict(response.headers), + "body": response_data, + } + async def close(self) -> None: """Close the HTTP client and clean up resources.""" if self._http_client: await self._http_client.aclose() self._http_client = None - + logger.info("Sumo Logic API client closed") - + async def get_health_status(self) -> Dict[str, Any]: """Get health status of the API client including resilience metrics. - + Returns: Dictionary containing health status and metrics """ resilience_status = self.resilient_client.get_health_status() metrics_summary = await self.metrics_collector.get_metrics_summary() - + return { "api_client": { "endpoint": self.config.endpoint, "timeout": self.config.timeout, "max_retries": self.config.max_retries, "rate_limit_delay": self.config.rate_limit_delay, - "http_client_active": self._http_client is not None + "http_client_active": self._http_client is not None, }, "resilience": resilience_status, "timeouts": { "default": self.timeout_manager.default_timeout, - "operations": dict(self.timeout_manager.operation_timeouts) + "operations": dict(self.timeout_manager.operation_timeouts), }, - "metrics": metrics_summary + "metrics": metrics_summary, } - + async def reset_circuit_breaker(self) -> Dict[str, Any]: """Reset the circuit breaker to allow new requests. - + This method can be used when the circuit breaker is open due to previous failures but you want to test connectivity again. - + Returns: Dictionary containing reset status and circuit breaker state """ try: # Reset the circuit breaker self.resilient_client.circuit_breaker.reset() - + logger.info("Circuit breaker has been reset") - + # Get updated status resilience_status = self.resilient_client.get_health_status() - + return { "reset_successful": True, "timestamp": datetime.utcnow().isoformat(), "circuit_breaker_state": resilience_status.get("circuit_breaker", {}), - "message": "Circuit breaker has been reset and is now closed" + "message": "Circuit breaker has been reset and is now closed", } - + except Exception as e: logger.error(f"Failed to reset circuit breaker: {e}") return { "reset_successful": False, "timestamp": datetime.utcnow().isoformat(), "error": str(e), - "message": "Failed to reset circuit breaker" + "message": "Failed to reset circuit breaker", } - + def is_circuit_breaker_open(self) -> bool: """Check if the circuit breaker is currently open. - + Returns: True if circuit breaker is open (blocking requests), False otherwise """ @@ -545,17 +586,17 @@ def is_circuit_breaker_open(self) -> bool: return circuit_breaker_status.get("state") == "open" except Exception: return False - + async def get_api_metrics(self) -> Dict[str, Any]: """Get API-specific metrics. - + Returns: Dictionary containing API metrics """ return await self.metrics_collector.get_all_metrics() - + # Search API Methods - + async def search_logs( self, query: str, @@ -565,10 +606,10 @@ async def search_logs( offset: int = 0, time_zone: Optional[str] = None, by_receipt_time: bool = False, - auto_parsing_mode: Optional[str] = None + auto_parsing_mode: Optional[str] = None, ) -> Dict[str, Any]: """Execute log search query. - + Args: query: Search query string from_time: Start time (ISO format, relative like '-1h', epoch, or 'now') @@ -578,10 +619,10 @@ async def search_logs( time_zone: Time zone for query (e.g., 'UTC', 'America/New_York') by_receipt_time: Search by receipt time instead of message time auto_parsing_mode: Auto parsing mode ('intelligent', 'performance') - + Returns: Dictionary containing search job information - + Raises: ValidationError: If search parameters are invalid SearchError: If search execution fails @@ -597,31 +638,33 @@ async def search_logs( offset=offset, time_zone=time_zone, by_receipt_time=by_receipt_time, - auto_parsing_mode=auto_parsing_mode + auto_parsing_mode=auto_parsing_mode, ) except PydanticValidationError as e: raise ValidationError( f"Search request validation failed: {e}", - validation_errors={err['loc'][0]: err['msg'] for err in e.errors()}, - context={"query": query, "from_time": from_time, "to_time": to_time} + validation_errors={err["loc"][0]: err["msg"] for err in e.errors()}, + context={"query": query, "from_time": from_time, "to_time": to_time}, ) from e - + # Convert time parameters to proper ISO 8601 format for API try: - from_time_formatted = TimeParser.convert_time_for_api(search_request.from_time) + from_time_formatted = TimeParser.convert_time_for_api( + search_request.from_time + ) to_time_formatted = TimeParser.convert_time_for_api(search_request.to_time) - + # Additional validation to ensure from_time is before to_time from_dt = TimeParser.parse_time(search_request.from_time) to_dt = TimeParser.parse_time(search_request.to_time) - + if from_dt >= to_dt: raise TimeValidationError( "Start time must be before end time", f"from_time='{search_request.from_time}', to_time='{search_request.to_time}'", - "from_time should represent an earlier time than to_time (e.g., from='-2h', to='-1h')" + "from_time should represent an earlier time than to_time (e.g., from='-2h', to='-1h')", ) - + except TimeValidationError: # Re-raise TimeValidationError as-is raise @@ -630,24 +673,29 @@ async def search_logs( f"Invalid time range: {str(e)}", f"from_time='{search_request.from_time}', to_time='{search_request.to_time}'", "Valid time formats: ISO 8601, relative time (-1h, -30m, now), or epoch time", - context={"from_time": search_request.from_time, "to_time": search_request.to_time} + context={ + "from_time": search_request.from_time, + "to_time": search_request.to_time, + }, ) from e - + # Prepare parameters for API schema validation search_params = { "query": search_request.query, "from": from_time_formatted, "to": to_time_formatted, "timeZone": search_request.time_zone, - "byReceiptTime": search_request.by_receipt_time + "byReceiptTime": search_request.by_receipt_time, } - + if search_request.auto_parsing_mode: search_params["autoParsingMode"] = search_request.auto_parsing_mode - + # Validate parameters against official API schema try: - validated_params = SumoLogicAPIValidator.validate_search_params(search_params) + validated_params = SumoLogicAPIValidator.validate_search_params( + search_params + ) except (APIParameterError, TimeValidationError): # Re-raise enhanced validation errors as-is raise @@ -657,67 +705,72 @@ async def search_logs( param_value=search_params, expected_type="valid search parameters according to Sumo Logic API", api_endpoint="search API", - context={"query": query, "from_time": from_time, "to_time": to_time, "original_error": str(e)} + context={ + "query": query, + "from_time": from_time, + "to_time": to_time, + "original_error": str(e), + }, ) from e - + # Build search request payload with validated parameters search_payload = { "query": validated_params["query"], "from": validated_params["from"], "to": validated_params["to"], "timeZone": validated_params["timeZone"], - "byReceiptTime": validated_params["byReceiptTime"] + "byReceiptTime": validated_params["byReceiptTime"], } - + if "autoParsingMode" in validated_params: search_payload["autoParsingMode"] = validated_params["autoParsingMode"] - + logger.info( "Starting log search", extra={ "query": query[:100] + "..." if len(query) > 100 else query, "from_time": from_time, "to_time": to_time, - "limit": limit - } + "limit": limit, + }, ) - + try: response = await self._make_request( method="POST", endpoint="/api/v1/search/jobs", json_data=search_payload, - operation_type="search" + operation_type="search", ) - + search_result = await self._parse_json_response(response) - + logger.info( "Search job created successfully", extra={ "job_id": search_result.get("id"), - "link": search_result.get("link") - } + "link": search_result.get("link"), + }, ) - + return search_result - + except APIError as e: raise SearchError( f"Failed to start search: {e.message}", query=query, - context={"from_time": from_time, "to_time": to_time} + context={"from_time": from_time, "to_time": to_time}, ) from e - + async def get_search_job_status(self, job_id: str) -> Dict[str, Any]: """Get status of running search job. - + Args: job_id: Search job ID - + Returns: Dictionary containing job status information - + Raises: APIParameterError: If job_id is invalid SearchError: If status check fails @@ -728,56 +781,52 @@ async def get_search_job_status(self, job_id: str) -> Dict[str, Any]: param_name="job_id", param_value=job_id, expected_type="non-empty string representing a valid search job ID", - api_endpoint="search job status API" + api_endpoint="search job status API", ) - + job_id = job_id.strip() - + logger.debug(f"Checking status for search job {job_id}") - + try: response = await self._make_request( method="GET", endpoint=f"/api/v1/search/jobs/{job_id}", - operation_type="search" + operation_type="search", ) - + status_result = await self._parse_json_response(response) - + logger.debug( f"Search job status retrieved", extra={ "job_id": job_id, "state": status_result.get("state"), "message_count": status_result.get("messageCount"), - "record_count": status_result.get("recordCount") - } + "record_count": status_result.get("recordCount"), + }, ) - + return status_result - + except APIError as e: raise SearchError( - f"Failed to get search job status: {e.message}", - job_id=job_id + f"Failed to get search job status: {e.message}", job_id=job_id ) from e - + async def get_search_results( - self, - job_id: str, - offset: int = 0, - limit: int = 100 + self, job_id: str, offset: int = 0, limit: int = 100 ) -> Dict[str, Any]: """Retrieve results from completed search job. - + Args: job_id: Search job ID offset: Result offset for pagination limit: Maximum results to return (1-10000) - + Returns: Dictionary containing search results - + Raises: APIParameterError: If parameters are invalid SearchError: If results retrieval fails @@ -788,50 +837,47 @@ async def get_search_results( param_name="job_id", param_value=job_id, expected_type="non-empty string representing a valid search job ID", - api_endpoint="search results API" + api_endpoint="search results API", ) - + if offset < 0: raise APIParameterError( param_name="offset", param_value=offset, expected_type="non-negative integer (>= 0)", - api_endpoint="search results API" + api_endpoint="search results API", ) - + if limit < 1 or limit > 10000: raise APIParameterError( param_name="limit", param_value=limit, expected_type="integer between 1 and 10000", - api_endpoint="search results API" + api_endpoint="search results API", ) - + job_id = job_id.strip() - + logger.debug( f"Retrieving results for search job {job_id}", - extra={"offset": offset, "limit": limit} + extra={"offset": offset, "limit": limit}, ) - + try: # First check if job is complete status = await self.get_search_job_status(job_id) job_state = status.get("state", "").upper() - + if job_state not in ["DONE GATHERING RESULTS", "CANCELLED"]: raise SearchError( f"Search job is not ready for results retrieval (state: {job_state})", job_id=job_id, - search_state=job_state + search_state=job_state, ) - + # Try /records first (for aggregate queries), fall back to /messages. # If both are empty, return empty results gracefully. - params = { - "offset": offset, - "limit": limit - } + params = {"offset": offset, "limit": limit} result_type = "records" try: @@ -839,7 +885,7 @@ async def get_search_results( method="GET", endpoint=f"/api/v1/search/jobs/{job_id}/records", params=params, - operation_type="search_results" + operation_type="search_results", ) results = await self._parse_json_response(response) except Exception: @@ -853,7 +899,7 @@ async def get_search_results( method="GET", endpoint=f"/api/v1/search/jobs/{job_id}/messages", params=params, - operation_type="search_results" + operation_type="search_results", ) results = await self._parse_json_response(response) except Exception: @@ -866,8 +912,8 @@ async def get_search_results( "result_type": result_type, "returned_count": len(results.get(result_type, [])), "offset": offset, - "limit": limit - } + "limit": limit, + }, ) # Combine with job status for complete result @@ -876,27 +922,26 @@ async def get_search_results( "status": status, "results": results, "messages": results.get(result_type, []), - "fields": results.get("fields", []) + "fields": results.get("fields", []), } - + except SearchError: # Re-raise search errors as-is raise except APIError as e: raise SearchError( - f"Failed to get search results: {e.message}", - job_id=job_id + f"Failed to get search results: {e.message}", job_id=job_id ) from e - + async def cancel_search_job(self, job_id: str) -> bool: """Cancel a running search job. - + Args: job_id: Search job ID to cancel - + Returns: True if cancellation was successful - + Raises: APIParameterError: If job_id is invalid SearchError: If cancellation fails @@ -907,54 +952,52 @@ async def cancel_search_job(self, job_id: str) -> bool: param_name="job_id", param_value=job_id, expected_type="non-empty string representing a valid search job ID", - api_endpoint="search job cancellation API" + api_endpoint="search job cancellation API", ) - + job_id = job_id.strip() - + logger.info(f"Cancelling search job {job_id}") - + try: response = await self._make_request( method="DELETE", endpoint=f"/api/v1/search/jobs/{job_id}", - operation_type="search" + operation_type="search", ) - + # Sumo Logic returns 200 for successful cancellation success = response.status_code == 200 - + if success: logger.info(f"Search job {job_id} cancelled successfully") else: - logger.warning(f"Search job {job_id} cancellation returned status {response.status_code}") - + logger.warning( + f"Search job {job_id} cancellation returned status {response.status_code}" + ) + return success - + except APIError as e: raise SearchError( - f"Failed to cancel search job: {e.message}", - job_id=job_id + f"Failed to cancel search job: {e.message}", job_id=job_id ) from e - + # Dashboard API Methods - + async def list_dashboards( - self, - limit: int = 100, - offset: int = 0, - filter_query: Optional[str] = None + self, limit: int = 100, offset: int = 0, filter_query: Optional[str] = None ) -> Dict[str, Any]: """List available dashboards with optional filtering. - + Args: limit: Maximum dashboards to return (1-1000) offset: Result offset for pagination filter_query: Optional filter query (e.g., 'title:MyDashboard') - + Returns: Dictionary containing dashboard list and metadata - + Raises: ValidationError: If parameters are invalid APIError: If API request fails @@ -963,67 +1006,62 @@ async def list_dashboards( raise ValidationError( "Limit must be between 1 and 1000", field_name="limit", - field_value=limit + field_value=limit, ) - + if offset < 0: raise ValidationError( - "Offset must be non-negative", - field_name="offset", - field_value=offset + "Offset must be non-negative", field_name="offset", field_value=offset ) - - params = { - "limit": limit, - "offset": offset - } - + + params = {"limit": limit, "offset": offset} + if filter_query: params["q"] = filter_query.strip() - + logger.debug( "Listing dashboards", - extra={"limit": limit, "offset": offset, "filter": filter_query} + extra={"limit": limit, "offset": offset, "filter": filter_query}, ) - + try: response = await self._make_request( method="GET", endpoint="/api/v2/dashboards", params=params, - operation_type="dashboard" + operation_type="dashboard", ) - + dashboards = await self._parse_json_response(response) - + logger.info( f"Retrieved {len(dashboards.get('data', []))} dashboards", extra={ "total_count": dashboards.get("totalCount"), "limit": limit, - "offset": offset - } + "offset": offset, + }, ) - + return dashboards - + except APIError as e: raise APIError( f"Failed to list dashboards: {e.message}", status_code=e.status_code, response_body=e.response_body, - request_id=e.request_id + request_id=e.request_id, ) from e - + async def get_dashboard(self, dashboard_id: str) -> Dict[str, Any]: """Get specific dashboard configuration and metadata. - + Args: dashboard_id: Dashboard ID to retrieve - + Returns: Dictionary containing dashboard configuration - + Raises: APIParameterError: If dashboard_id is invalid APIError: If API request fails @@ -1033,51 +1071,53 @@ async def get_dashboard(self, dashboard_id: str) -> Dict[str, Any]: param_name="dashboard_id", param_value=dashboard_id, expected_type="non-empty string representing a valid dashboard ID", - api_endpoint="dashboard retrieval API" + api_endpoint="dashboard retrieval API", ) - + dashboard_id = dashboard_id.strip() - + logger.debug(f"Retrieving dashboard {dashboard_id}") - + try: response = await self._make_request( method="GET", endpoint=f"/api/v2/dashboards/{dashboard_id}", - operation_type="dashboard" + operation_type="dashboard", ) - + dashboard = await self._parse_json_response(response) - + logger.info( f"Retrieved dashboard", extra={ "dashboard_id": dashboard_id, "title": dashboard.get("title"), - "panel_count": len(dashboard.get("panels", [])) - } + "panel_count": len(dashboard.get("panels", [])), + }, ) - + return dashboard - + except APIError as e: raise APIError( f"Failed to get dashboard {dashboard_id}: {e.message}", status_code=e.status_code, response_body=e.response_body, request_id=e.request_id, - context={"dashboard_id": dashboard_id} + context={"dashboard_id": dashboard_id}, ) from e - - async def create_dashboard(self, dashboard_config: Dict[str, Any]) -> Dict[str, Any]: + + async def create_dashboard( + self, dashboard_config: Dict[str, Any] + ) -> Dict[str, Any]: """Create new dashboard with specified configuration. - + Args: dashboard_config: Dashboard configuration dictionary - + Returns: Dictionary containing created dashboard information - + Raises: ValidationError: If dashboard configuration is invalid APIError: If API request fails @@ -1088,82 +1128,77 @@ async def create_dashboard(self, dashboard_config: Dict[str, Any]) -> Dict[str, except PydanticValidationError as e: raise ValidationError( f"Invalid dashboard configuration: {e}", - validation_errors={err['loc'][0]: err['msg'] for err in e.errors()}, - context={"config": dashboard_config} + validation_errors={err["loc"][0]: err["msg"] for err in e.errors()}, + context={"config": dashboard_config}, ) from e - + # Build dashboard payload dashboard_payload = { "title": config.title, "description": config.description or "", "panels": config.panels, - "theme": getattr(config, 'theme', 'Light'), + "theme": getattr(config, "theme", "Light"), "timeRange": { "type": "BeginBoundedTimeRange", - "from": { - "type": "RelativeTimeRangeBoundary", - "relativeTime": "-1h" - } - } + "from": {"type": "RelativeTimeRangeBoundary", "relativeTime": "-1h"}, + }, } - + if config.refresh_interval: dashboard_payload["refreshInterval"] = config.refresh_interval - - if hasattr(config, 'folder_id') and config.folder_id: + + if hasattr(config, "folder_id") and config.folder_id: dashboard_payload["folderId"] = config.folder_id - + logger.info( f"Creating dashboard '{config.title}'", extra={ "panel_count": len(config.panels), - "has_description": bool(config.description) - } + "has_description": bool(config.description), + }, ) - + try: response = await self._make_request( method="POST", endpoint="/api/v2/dashboards", json_data=dashboard_payload, - operation_type="dashboard" + operation_type="dashboard", ) - + created_dashboard = await self._parse_json_response(response) - + logger.info( f"Dashboard created successfully", extra={ "dashboard_id": created_dashboard.get("id"), - "title": created_dashboard.get("title") - } + "title": created_dashboard.get("title"), + }, ) - + return created_dashboard - + except APIError as e: raise APIError( f"Failed to create dashboard: {e.message}", status_code=e.status_code, response_body=e.response_body, request_id=e.request_id, - context={"title": config.title} + context={"title": config.title}, ) from e - + async def update_dashboard( - self, - dashboard_id: str, - dashboard_config: Dict[str, Any] + self, dashboard_id: str, dashboard_config: Dict[str, Any] ) -> Dict[str, Any]: """Update existing dashboard configuration. - + Args: dashboard_id: Dashboard ID to update dashboard_config: Updated dashboard configuration - + Returns: Dictionary containing updated dashboard information - + Raises: ValidationError: If parameters are invalid APIError: If API request fails @@ -1172,11 +1207,11 @@ async def update_dashboard( raise ValidationError( "Dashboard ID cannot be empty", field_name="dashboard_id", - field_value=dashboard_id + field_value=dashboard_id, ) - + dashboard_id = dashboard_id.strip() - + # Get current dashboard to merge with updates try: current_dashboard = await self.get_dashboard(dashboard_id) @@ -1184,74 +1219,71 @@ async def update_dashboard( raise APIError( f"Failed to get current dashboard for update: {e.message}", status_code=e.status_code, - context={"dashboard_id": dashboard_id} + context={"dashboard_id": dashboard_id}, ) from e - + # Merge current config with updates updated_config = current_dashboard.copy() updated_config.update(dashboard_config) - + # Validate merged configuration try: config = DashboardConfig( title=updated_config.get("title", ""), description=updated_config.get("description"), panels=updated_config.get("panels", []), - refresh_interval=updated_config.get("refreshInterval") + refresh_interval=updated_config.get("refreshInterval"), ) except PydanticValidationError as e: raise ValidationError( f"Invalid updated dashboard configuration: {e}", - validation_errors={err['loc'][0]: err['msg'] for err in e.errors()}, - context={"dashboard_id": dashboard_id, "updates": dashboard_config} + validation_errors={err["loc"][0]: err["msg"] for err in e.errors()}, + context={"dashboard_id": dashboard_id, "updates": dashboard_config}, ) from e - + logger.info( f"Updating dashboard {dashboard_id}", - extra={ - "title": config.title, - "panel_count": len(config.panels) - } + extra={"title": config.title, "panel_count": len(config.panels)}, ) - + try: response = await self._make_request( method="PUT", endpoint=f"/api/v2/dashboards/{dashboard_id}", json_data=updated_config, - operation_type="dashboard" + operation_type="dashboard", ) - + updated_dashboard = await self._parse_json_response(response) - + logger.info( f"Dashboard updated successfully", extra={ "dashboard_id": dashboard_id, - "title": updated_dashboard.get("title") - } + "title": updated_dashboard.get("title"), + }, ) - + return updated_dashboard - + except APIError as e: raise APIError( f"Failed to update dashboard {dashboard_id}: {e.message}", status_code=e.status_code, response_body=e.response_body, request_id=e.request_id, - context={"dashboard_id": dashboard_id} + context={"dashboard_id": dashboard_id}, ) from e - + async def delete_dashboard(self, dashboard_id: str) -> bool: """Delete specified dashboard. - + Args: dashboard_id: Dashboard ID to delete - + Returns: True if deletion was successful - + Raises: ValidationError: If dashboard_id is invalid APIError: If API request fails @@ -1260,61 +1292,63 @@ async def delete_dashboard(self, dashboard_id: str) -> bool: raise ValidationError( "Dashboard ID cannot be empty", field_name="dashboard_id", - field_value=dashboard_id + field_value=dashboard_id, ) - + dashboard_id = dashboard_id.strip() - + logger.info(f"Deleting dashboard {dashboard_id}") - + try: response = await self._make_request( method="DELETE", endpoint=f"/api/v2/dashboards/{dashboard_id}", - operation_type="dashboard" + operation_type="dashboard", ) - + # Sumo Logic returns 204 for successful deletion success = response.status_code == 204 - + if success: logger.info(f"Dashboard {dashboard_id} deleted successfully") else: - logger.warning(f"Dashboard {dashboard_id} deletion returned status {response.status_code}") - + logger.warning( + f"Dashboard {dashboard_id} deletion returned status {response.status_code}" + ) + return success - + except APIError as e: raise APIError( f"Failed to delete dashboard {dashboard_id}: {e.message}", status_code=e.status_code, response_body=e.response_body, request_id=e.request_id, - context={"dashboard_id": dashboard_id} + context={"dashboard_id": dashboard_id}, ) from e - + # Metrics API Methods - + async def query_metrics( self, query: str, from_time: str, to_time: str, requested_data_points: int = 600, - max_tab_results: int = 100 + max_tab_results: int = 100, ) -> Dict[str, Any]: """Execute metrics query for time-series data retrieval. - + Args: query: Metrics query string from_time: Start time (ISO format or relative like '-1h') to_time: End time (ISO format or relative like 'now') requested_data_points: Number of data points to return (1-1440) max_tab_results: Maximum tabular results (1-1000) - + Returns: Dictionary containing metrics query results - + Raises: ValidationError: If query parameters are invalid APIError: If API request fails @@ -1326,26 +1360,28 @@ async def query_metrics( from_time=from_time, to_time=to_time, requested_data_points=requested_data_points, - max_tab_results=max_tab_results + max_tab_results=max_tab_results, ) except PydanticValidationError as e: raise ValidationError( f"Invalid metrics query parameters: {e}", - validation_errors={err['loc'][0]: err['msg'] for err in e.errors()}, - context={"query": query, "from_time": from_time, "to_time": to_time} + validation_errors={err["loc"][0]: err["msg"] for err in e.errors()}, + context={"query": query, "from_time": from_time, "to_time": to_time}, ) from e - + # Parse and validate time range with enhanced time handling try: # First validate the time range start_dt, end_dt = TimeParser.validate_time_range( - metrics_request.from_time, - metrics_request.to_time + metrics_request.from_time, metrics_request.to_time ) - + # For metrics API, we can use relative time if it's in the original format # or convert to epoch milliseconds - if TimeParser._is_relative_time(metrics_request.from_time) or metrics_request.from_time.lower() == 'now': + if ( + TimeParser._is_relative_time(metrics_request.from_time) + or metrics_request.from_time.lower() == "now" + ): from_time_value = metrics_request.from_time from_time_type = "RelativeTimeRangeBoundary" from_time_key = "relativeTime" @@ -1353,8 +1389,11 @@ async def query_metrics( from_time_value = str(int(start_dt.timestamp() * 1000)) from_time_type = "EpochTimeRangeBoundary" from_time_key = "epochMillis" - - if TimeParser._is_relative_time(metrics_request.to_time) or metrics_request.to_time.lower() == 'now': + + if ( + TimeParser._is_relative_time(metrics_request.to_time) + or metrics_request.to_time.lower() == "now" + ): to_time_value = metrics_request.to_time to_time_type = "RelativeTimeRangeBoundary" to_time_key = "relativeTime" @@ -1362,86 +1401,80 @@ async def query_metrics( to_time_value = str(int(end_dt.timestamp() * 1000)) to_time_type = "EpochTimeRangeBoundary" to_time_key = "epochMillis" - + except ValueError as e: raise ValidationError( f"Invalid time range for metrics query: {e}", field_name="time_range", - context={"from_time": metrics_request.from_time, "to_time": metrics_request.to_time} + context={ + "from_time": metrics_request.from_time, + "to_time": metrics_request.to_time, + }, ) from e - + # Build metrics query payload metrics_payload = { - "query": [ - { - "query": metrics_request.query, - "rowId": "A" - } - ], + "query": [{"query": metrics_request.query, "rowId": "A"}], "timeRange": { "type": "BeginBoundedTimeRange", - "from": { - "type": from_time_type, - from_time_key: from_time_value - }, - "to": { - "type": to_time_type, - to_time_key: to_time_value - } + "from": {"type": from_time_type, from_time_key: from_time_value}, + "to": {"type": to_time_type, to_time_key: to_time_value}, }, "requestedDataPoints": metrics_request.requested_data_points, - "maxTabResults": metrics_request.max_tab_results + "maxTabResults": metrics_request.max_tab_results, } - + logger.info( "Executing metrics query", extra={ "query": query[:100] + "..." if len(query) > 100 else query, "from_time": from_time, "to_time": to_time, - "data_points": requested_data_points - } + "data_points": requested_data_points, + }, ) - + try: response = await self._make_request( method="POST", endpoint="/api/v1/metrics/results", json_data=metrics_payload, - operation_type="metrics" + operation_type="metrics", ) - + metrics_result = await self._parse_json_response(response) - + logger.info( "Metrics query completed successfully", extra={ "query_count": len(metrics_result.get("queryResult", [])), - "has_tabular_data": bool(metrics_result.get("tabularData")) - } + "has_tabular_data": bool(metrics_result.get("tabularData")), + }, ) - + return metrics_result - + except APIError as e: raise APIError( f"Failed to execute metrics query: {e.message}", status_code=e.status_code, response_body=e.response_body, request_id=e.request_id, - context={"query": query, "from_time": from_time, "to_time": to_time} + context={"query": query, "from_time": from_time, "to_time": to_time}, ) from e - - async def list_metric_sources(self, limit: int = 100, offset: int = 0) -> Dict[str, Any]: + + async def list_metric_sources( + self, limit: int = 100, offset: int = 0 + ) -> Dict[str, Any]: """List available metric sources for discovery. - + Args: limit: Maximum sources to return (1-1000) offset: Result offset for pagination - + Returns: Dictionary containing metric sources list - + Raises: ValidationError: If parameters are invalid APIError: If API request fails @@ -1450,69 +1483,59 @@ async def list_metric_sources(self, limit: int = 100, offset: int = 0) -> Dict[s raise ValidationError( "Limit must be between 1 and 1000", field_name="limit", - field_value=limit + field_value=limit, ) - + if offset < 0: raise ValidationError( - "Offset must be non-negative", - field_name="offset", - field_value=offset + "Offset must be non-negative", field_name="offset", field_value=offset ) - - params = { - "limit": limit, - "offset": offset - } - - logger.debug( - "Listing metric sources", - extra={"limit": limit, "offset": offset} - ) - + + params = {"limit": limit, "offset": offset} + + logger.debug("Listing metric sources", extra={"limit": limit, "offset": offset}) + try: response = await self._make_request( method="GET", endpoint="/api/v1/metrics/sources", params=params, - operation_type="metrics" + operation_type="metrics", ) - + sources = await self._parse_json_response(response) - + logger.info( f"Retrieved {len(sources.get('data', []))} metric sources", extra={ "total_count": sources.get("totalCount"), "limit": limit, - "offset": offset - } + "offset": offset, + }, ) - + return sources - + except APIError as e: raise APIError( f"Failed to list metric sources: {e.message}", status_code=e.status_code, response_body=e.response_body, - request_id=e.request_id + request_id=e.request_id, ) from e - + async def get_metric_metadata( - self, - metric_name: str, - source_host: Optional[str] = None + self, metric_name: str, source_host: Optional[str] = None ) -> Dict[str, Any]: """Get metadata for a specific metric. - + Args: metric_name: Name of the metric to get metadata for source_host: Optional source host filter - + Returns: Dictionary containing metric metadata - + Raises: ValidationError: If metric_name is invalid APIError: If API request fails @@ -1521,52 +1544,54 @@ async def get_metric_metadata( raise ValidationError( "Metric name cannot be empty", field_name="metric_name", - field_value=metric_name + field_value=metric_name, ) - + metric_name = metric_name.strip() - + params = {"metric": metric_name} if source_host: params["sourceHost"] = source_host.strip() - + logger.debug( f"Getting metadata for metric '{metric_name}'", - extra={"source_host": source_host} + extra={"source_host": source_host}, ) - + try: response = await self._make_request( method="GET", endpoint="/api/v1/metrics/metadata", params=params, - operation_type="metrics" + operation_type="metrics", ) - + metadata = await self._parse_json_response(response) - + logger.info( f"Retrieved metadata for metric '{metric_name}'", extra={ "metric_name": metric_name, - "dimensions_count": len(metadata.get("dimensions", [])) - } + "dimensions_count": len(metadata.get("dimensions", [])), + }, ) - + return metadata - + except APIError as e: raise APIError( f"Failed to get metric metadata for '{metric_name}': {e.message}", status_code=e.status_code, response_body=e.response_body, request_id=e.request_id, - context={"metric_name": metric_name, "source_host": source_host} + context={"metric_name": metric_name, "source_host": source_host}, ) from e - + # Collector and Source API Methods - - async def list_collectors(self, limit: int = 100, offset: int = 0) -> Dict[str, Any]: + + async def list_collectors( + self, limit: int = 100, offset: int = 0 + ) -> Dict[str, Any]: """List collectors with pagination. Args: @@ -1584,63 +1609,52 @@ async def list_collectors(self, limit: int = 100, offset: int = 0) -> Dict[str, raise ValidationError( "Limit must be between 1 and 1000", field_name="limit", - field_value=limit + field_value=limit, ) - + if offset < 0: raise ValidationError( - "Offset must be non-negative", - field_name="offset", - field_value=offset + "Offset must be non-negative", field_name="offset", field_value=offset ) - - params = { - "limit": limit, - "offset": offset - } - - logger.debug( - "Listing collectors", - extra={"limit": limit, "offset": offset} - ) - + + params = {"limit": limit, "offset": offset} + + logger.debug("Listing collectors", extra={"limit": limit, "offset": offset}) + try: response = await self._make_request( method="GET", endpoint="/api/v1/collectors", params=params, - operation_type="collector" + operation_type="collector", ) - + result = await self._parse_json_response(response) logger.info( f"Retrieved {len(result.get('collectors', []))} collectors", - extra={ - "limit": limit, - "offset": offset - } + extra={"limit": limit, "offset": offset}, ) return result - + except APIError as e: raise APIError( f"Failed to list collectors: {e.message}", status_code=e.status_code, response_body=e.response_body, - request_id=e.request_id + request_id=e.request_id, ) from e - + async def get_collector(self, collector_id: Union[str, int]) -> Dict[str, Any]: """Get collector details and configuration. - + Args: collector_id: Collector ID (string or integer) - + Returns: Dictionary containing collector information - + Raises: ValidationError: If collector_id is invalid APIError: If API request fails @@ -1649,58 +1663,62 @@ async def get_collector(self, collector_id: Union[str, int]) -> Dict[str, Any]: raise ValidationError( "Collector ID cannot be empty", field_name="collector_id", - field_value=collector_id + field_value=collector_id, ) - + # Convert to string and validate collector_id_str = str(collector_id).strip() if not collector_id_str: raise ValidationError( "Collector ID cannot be empty after conversion", field_name="collector_id", - field_value=collector_id + field_value=collector_id, ) - + logger.debug(f"Retrieving collector {collector_id_str}") - + try: response = await self._make_request( method="GET", endpoint=f"/api/v1/collectors/{collector_id_str}", - operation_type="collector" + operation_type="collector", ) - + collector = await self._parse_json_response(response) - + logger.info( f"Retrieved collector", extra={ "collector_id": collector_id_str, "name": collector.get("collector", {}).get("name"), - "collector_type": collector.get("collector", {}).get("collectorType") - } + "collector_type": collector.get("collector", {}).get( + "collectorType" + ), + }, ) - + return collector - + except APIError as e: raise APIError( f"Failed to get collector {collector_id_str}: {e.message}", status_code=e.status_code, response_body=e.response_body, request_id=e.request_id, - context={"collector_id": collector_id_str} + context={"collector_id": collector_id_str}, ) from e - - async def create_collector(self, collector_config: Dict[str, Any]) -> Dict[str, Any]: + + async def create_collector( + self, collector_config: Dict[str, Any] + ) -> Dict[str, Any]: """Create new collector with specified configuration. - + Args: collector_config: Collector configuration dictionary - + Returns: Dictionary containing created collector information - + Raises: ValidationError: If collector configuration is invalid APIError: If API request fails @@ -1711,10 +1729,10 @@ async def create_collector(self, collector_config: Dict[str, Any]) -> Dict[str, except PydanticValidationError as e: raise ValidationError( f"Invalid collector configuration: {e}", - validation_errors={err['loc'][0]: err['msg'] for err in e.errors()}, - context={"config": collector_config} + validation_errors={err["loc"][0]: err["msg"] for err in e.errors()}, + context={"config": collector_config}, ) from e - + # Build collector payload collector_payload = { "collector": { @@ -1725,61 +1743,56 @@ async def create_collector(self, collector_config: Dict[str, Any]) -> Dict[str, "hostName": config.host_name, "timeZone": config.time_zone, "ephemeral": config.ephemeral, - "sourceSyncMode": config.source_sync_mode + "sourceSyncMode": config.source_sync_mode, } } - + logger.info( f"Creating collector '{config.name}'", - extra={ - "collector_type": "Hosted", - "ephemeral": config.ephemeral - } + extra={"collector_type": "Hosted", "ephemeral": config.ephemeral}, ) - + try: response = await self._make_request( method="POST", endpoint="/api/v1/collectors", json_data=collector_payload, - operation_type="collector" + operation_type="collector", ) - + created_collector = await self._parse_json_response(response) - + logger.info( f"Collector created successfully", extra={ "collector_id": created_collector.get("collector", {}).get("id"), - "name": created_collector.get("collector", {}).get("name") - } + "name": created_collector.get("collector", {}).get("name"), + }, ) - + return created_collector - + except APIError as e: raise APIError( f"Failed to create collector: {e.message}", status_code=e.status_code, response_body=e.response_body, request_id=e.request_id, - context={"name": config.name} + context={"name": config.name}, ) from e - + async def update_collector( - self, - collector_id: Union[str, int], - collector_config: Dict[str, Any] + self, collector_id: Union[str, int], collector_config: Dict[str, Any] ) -> Dict[str, Any]: """Update collector configuration. - + Args: collector_id: Collector ID to update collector_config: Updated collector configuration - + Returns: Dictionary containing updated collector information - + Raises: ValidationError: If parameters are invalid APIError: If API request fails @@ -1788,11 +1801,11 @@ async def update_collector( raise ValidationError( "Collector ID cannot be empty", field_name="collector_id", - field_value=collector_id + field_value=collector_id, ) - + collector_id_str = str(collector_id).strip() - + # Get current collector to merge with updates try: current_collector = await self.get_collector(collector_id_str) @@ -1801,13 +1814,13 @@ async def update_collector( raise APIError( f"Failed to get current collector for update: {e.message}", status_code=e.status_code, - context={"collector_id": collector_id_str} + context={"collector_id": collector_id_str}, ) from e - + # Merge current config with updates updated_config = current_config.copy() updated_config.update(collector_config) - + # Validate merged configuration try: config = CollectorConfig( @@ -1817,63 +1830,61 @@ async def update_collector( host_name=updated_config.get("hostName"), time_zone=updated_config.get("timeZone", "UTC"), ephemeral=updated_config.get("ephemeral", True), - source_sync_mode=updated_config.get("sourceSyncMode", "UI") + source_sync_mode=updated_config.get("sourceSyncMode", "UI"), ) except PydanticValidationError as e: raise ValidationError( f"Invalid updated collector configuration: {e}", - validation_errors={err['loc'][0]: err['msg'] for err in e.errors()}, - context={"collector_id": collector_id_str, "updates": collector_config} + validation_errors={err["loc"][0]: err["msg"] for err in e.errors()}, + context={"collector_id": collector_id_str, "updates": collector_config}, ) from e - + # Build update payload - update_payload = { - "collector": updated_config - } - + update_payload = {"collector": updated_config} + logger.info( f"Updating collector {collector_id_str}", - extra={"collector_name": config.name} + extra={"collector_name": config.name}, ) - + try: response = await self._make_request( method="PUT", endpoint=f"/api/v1/collectors/{collector_id_str}", json_data=update_payload, - operation_type="collector" + operation_type="collector", ) - + updated_collector = await self._parse_json_response(response) - + logger.info( f"Collector updated successfully", extra={ "collector_id": collector_id_str, - "name": updated_collector.get("collector", {}).get("name") - } + "name": updated_collector.get("collector", {}).get("name"), + }, ) - + return updated_collector - + except APIError as e: raise APIError( f"Failed to update collector {collector_id_str}: {e.message}", status_code=e.status_code, response_body=e.response_body, request_id=e.request_id, - context={"collector_id": collector_id_str} + context={"collector_id": collector_id_str}, ) from e - + async def delete_collector(self, collector_id: Union[str, int]) -> bool: """Delete specified collector. - + Args: collector_id: Collector ID to delete - + Returns: True if deletion was successful - + Raises: ValidationError: If collector_id is invalid APIError: If API request fails @@ -1882,55 +1893,54 @@ async def delete_collector(self, collector_id: Union[str, int]) -> bool: raise ValidationError( "Collector ID cannot be empty", field_name="collector_id", - field_value=collector_id + field_value=collector_id, ) - + collector_id_str = str(collector_id).strip() - + logger.info(f"Deleting collector {collector_id_str}") - + try: response = await self._make_request( method="DELETE", endpoint=f"/api/v1/collectors/{collector_id_str}", - operation_type="collector" + operation_type="collector", ) - + # Sumo Logic returns 200 for successful deletion success = response.status_code == 200 - + if success: logger.info(f"Collector {collector_id_str} deleted successfully") else: - logger.warning(f"Collector {collector_id_str} deletion returned status {response.status_code}") - + logger.warning( + f"Collector {collector_id_str} deletion returned status {response.status_code}" + ) + return success - + except APIError as e: raise APIError( f"Failed to delete collector {collector_id_str}: {e.message}", status_code=e.status_code, response_body=e.response_body, request_id=e.request_id, - context={"collector_id": collector_id_str} + context={"collector_id": collector_id_str}, ) from e - + async def list_sources( - self, - collector_id: Union[str, int], - limit: int = 100, - offset: int = 0 + self, collector_id: Union[str, int], limit: int = 100, offset: int = 0 ) -> Dict[str, Any]: """List sources for specified collector. - + Args: collector_id: Collector ID to list sources for limit: Maximum sources to return (1-1000) offset: Result offset for pagination - + Returns: Dictionary containing sources list and metadata - + Raises: ValidationError: If parameters are invalid APIError: If API request fails @@ -1939,79 +1949,72 @@ async def list_sources( raise ValidationError( "Collector ID cannot be empty", field_name="collector_id", - field_value=collector_id + field_value=collector_id, ) - + if limit < 1 or limit > 1000: raise ValidationError( "Limit must be between 1 and 1000", field_name="limit", - field_value=limit + field_value=limit, ) - + if offset < 0: raise ValidationError( - "Offset must be non-negative", - field_name="offset", - field_value=offset + "Offset must be non-negative", field_name="offset", field_value=offset ) - + collector_id_str = str(collector_id).strip() - - params = { - "limit": limit, - "offset": offset - } - + + params = {"limit": limit, "offset": offset} + logger.debug( f"Listing sources for collector {collector_id_str}", - extra={"limit": limit, "offset": offset} + extra={"limit": limit, "offset": offset}, ) - + try: response = await self._make_request( method="GET", endpoint=f"/api/v1/collectors/{collector_id_str}/sources", params=params, - operation_type="collector" + operation_type="collector", ) - + sources = await self._parse_json_response(response) - + logger.info( f"Retrieved {len(sources.get('sources', []))} sources for collector {collector_id_str}", extra={ "collector_id": collector_id_str, "limit": limit, - "offset": offset - } + "offset": offset, + }, ) - + return sources - + except APIError as e: raise APIError( f"Failed to list sources for collector {collector_id_str}: {e.message}", status_code=e.status_code, response_body=e.response_body, request_id=e.request_id, - context={"collector_id": collector_id_str} + context={"collector_id": collector_id_str}, ) from e - + async def create_source( - self, - collector_id: Union[str, int], - source_config: Dict[str, Any] + self, collector_id: Union[str, int], source_config: Dict[str, Any] ) -> Dict[str, Any]: """Create new source in specified collector. - + Args: collector_id: Collector ID to create source in source_config: Source configuration dictionary - + Returns: Dictionary containing created source information - + Raises: ValidationError: If configuration is invalid APIError: If API request fails @@ -2020,25 +2023,27 @@ async def create_source( raise ValidationError( "Collector ID cannot be empty", field_name="collector_id", - field_value=collector_id + field_value=collector_id, ) - + collector_id_str = str(collector_id).strip() - + # Validate source configuration try: config = SourceConfig(**source_config) except PydanticValidationError as e: raise ValidationError( f"Invalid source configuration: {e}", - validation_errors={err['loc'][0]: err['msg'] for err in e.errors()}, - context={"collector_id": collector_id_str, "config": source_config} + validation_errors={err["loc"][0]: err["msg"] for err in e.errors()}, + context={"collector_id": collector_id_str, "config": source_config}, ) from e - + # Build source payload source_payload = { "source": { - "sourceType": source_config.get("sourceType", "HTTP"), # Default to HTTP source + "sourceType": source_config.get( + "sourceType", "HTTP" + ), # Default to HTTP source "name": config.name, "description": config.description or "", "category": config.category, @@ -2047,56 +2052,56 @@ async def create_source( "automaticDateParsing": config.automatic_date_parsing, "multilineProcessingEnabled": config.multiline_processing_enabled, "useAutolineMatching": config.use_autoline_matching, - "forceTimeZone": config.force_time_zone + "forceTimeZone": config.force_time_zone, } } - + if config.default_date_format: source_payload["source"]["defaultDateFormat"] = config.default_date_format - + if config.filters: source_payload["source"]["filters"] = config.filters - + logger.info( f"Creating source '{config.name}' in collector {collector_id_str}", extra={ "collector_id": collector_id_str, - "source_type": source_config.get("sourceType", "HTTP") - } + "source_type": source_config.get("sourceType", "HTTP"), + }, ) - + try: response = await self._make_request( method="POST", endpoint=f"/api/v1/collectors/{collector_id_str}/sources", json_data=source_payload, - operation_type="collector" + operation_type="collector", ) - + created_source = await self._parse_json_response(response) - + logger.info( f"Source created successfully", extra={ "collector_id": collector_id_str, "source_id": created_source.get("source", {}).get("id"), - "name": created_source.get("source", {}).get("name") - } + "name": created_source.get("source", {}).get("name"), + }, ) - + return created_source - + except APIError as e: raise APIError( f"Failed to create source in collector {collector_id_str}: {e.message}", status_code=e.status_code, response_body=e.response_body, request_id=e.request_id, - context={"collector_id": collector_id_str, "name": config.name} + context={"collector_id": collector_id_str, "name": config.name}, ) from e # Monitor API Methods - + async def list_monitors( self, limit: int = 100, @@ -2105,10 +2110,10 @@ async def list_monitors( filter_type: Optional[str] = None, filter_status: Optional[str] = None, content_type: Optional[str] = None, - include_folders: bool = True + include_folders: bool = True, ) -> Dict[str, Any]: """List monitors with optional filtering and pagination, including folder-based searches. - + Args: limit: Maximum monitors to return (1-1000) offset: Result offset for pagination @@ -2117,10 +2122,10 @@ async def list_monitors( filter_status: Optional status filter (enabled, disabled, triggered) content_type: Optional content type filter (MonitorsLibraryMonitor, MonitorsLibraryFolder, *) include_folders: Whether to include monitors within folders (default: True) - + Returns: Dictionary containing monitor list and metadata with folder information - + Raises: ValidationError: If parameters are invalid APIError: If API request fails @@ -2136,17 +2141,19 @@ async def list_monitors( status_mapping = { "enabled": "isDisabled:false", "disabled": "isDisabled:true", - "triggered": "status:Triggered" + "triggered": "status:Triggered", } - mapped_status = status_mapping.get(filter_status.lower(), f'status:"{filter_status}"') + mapped_status = status_mapping.get( + filter_status.lower(), f'status:"{filter_status}"' + ) filter_parts.append(mapped_status) - + # Always provide a query parameter - use "*" for all if no filters if filter_parts: query = " AND ".join(filter_parts) else: query = "*" # Search for all monitors - + # Determine content type for folder inclusion if content_type: # Use explicitly provided content type @@ -2157,24 +2164,26 @@ async def list_monitors( else: # Only search monitors, not folders search_content_type = "MonitorsLibraryMonitor" - + # Prepare parameters for validation monitor_params = { "query": query, "limit": limit, "offset": offset, - "type": search_content_type + "type": search_content_type, } - + # Validate parameters against official API schema try: - validated_params = SumoLogicAPIValidator.validate_monitor_params(monitor_params) + validated_params = SumoLogicAPIValidator.validate_monitor_params( + monitor_params + ) except (ValidationError, ValueError) as e: raise ValidationError( f"Monitor parameter validation failed: {e}", - context={"limit": limit, "offset": offset, "query": query} + context={"limit": limit, "offset": offset, "query": query}, ) from e - + logger.debug( "Listing monitors", extra={ @@ -2185,21 +2194,21 @@ async def list_monitors( "name": filter_name, "type": filter_type, "status": filter_status, - "content_type": content_type - } - } + "content_type": content_type, + }, + }, ) - + try: response = await self._make_request( method="GET", endpoint="/api/v1/monitors/search", params=validated_params, - operation_type="monitor" + operation_type="monitor", ) - + monitors = await self._parse_json_response(response) - + # Handle both list and dict responses if isinstance(monitors, list): # Direct list response from search endpoint @@ -2207,20 +2216,20 @@ async def list_monitors( total_count = len(monitors) else: # Dict response with data key - monitor_list = monitors.get('data', []) + monitor_list = monitors.get("data", []) total_count = monitors.get("totalCount", len(monitor_list)) - + # Process folder information and perform recursive search if needed processed_monitors = [] folder_count = 0 - + for item in monitor_list: # Check if this is a folder if item.get("contentType") == "MonitorsLibraryFolder": folder_count += 1 # Add folder path information item["folder_path"] = self._extract_folder_path(item) - + # If include_folders is True and we're searching all content types, # perform recursive search within this folder if include_folders and search_content_type == "*": @@ -2236,15 +2245,15 @@ async def list_monitors( except Exception as e: logger.warning( f"Failed to search monitors in folder {item.get('name', 'unknown')}: {e}", - extra={"folder_id": item.get("id"), "error": str(e)} + extra={"folder_id": item.get("id"), "error": str(e)}, ) - + # Add monitor/folder to results with folder information if item.get("contentType") == "MonitorsLibraryMonitor": item["folder_path"] = self._extract_folder_path(item) - + processed_monitors.append(item) - + logger.info( f"Retrieved {len(processed_monitors)} items ({len(processed_monitors) - folder_count} monitors, {folder_count} folders)", extra={ @@ -2252,10 +2261,10 @@ async def list_monitors( "limit": limit, "offset": offset, "include_folders": include_folders, - "content_type": search_content_type - } + "content_type": search_content_type, + }, ) - + # Return in expected format with folder metadata return { "data": processed_monitors, @@ -2267,61 +2276,57 @@ async def list_monitors( "folders_found": folder_count, "monitors_found": len(processed_monitors) - folder_count, "include_folders": include_folders, - "content_type_filter": search_content_type - } + "content_type_filter": search_content_type, + }, } - + except APIError as e: raise APIError( f"Failed to list monitors: {e.message}", status_code=e.status_code, response_body=e.response_body, - request_id=e.request_id + request_id=e.request_id, ) from e - + def _extract_folder_path(self, item: Dict[str, Any]) -> str: """Extract folder path from monitor or folder item. - + Args: item: Monitor or folder item from API response - + Returns: Folder path string (e.g., "/Monitors/Production/Alerts") """ # Try to get path from various possible fields path_fields = ["path", "parentPath", "folderPath"] - + for field in path_fields: if field in item and item[field]: return item[field] - + # If no explicit path, try to construct from name and parent info name = item.get("name", "") parent_id = item.get("parentId") - + if parent_id: # This item is in a folder, but we don't have the full path # Return a partial path indication return f"/.../{name}" - + # Root level item return f"/{name}" if name else "/unknown" - + async def _search_monitors_in_folder( - self, - folder_id: str, - base_query: str, - limit: int, - offset: int + self, folder_id: str, base_query: str, limit: int, offset: int ) -> List[Dict[str, Any]]: """Search for monitors within a specific folder. - + Args: folder_id: ID of the folder to search in base_query: Base search query to apply limit: Maximum results to return offset: Result offset - + Returns: List of monitors found in the folder """ @@ -2330,48 +2335,50 @@ async def _search_monitors_in_folder( folder_query = f"parentId:{folder_id}" if base_query and base_query != "*": folder_query = f"({base_query}) AND {folder_query}" - + folder_params = { "query": folder_query, "limit": limit, "offset": offset, - "type": "MonitorsLibraryMonitor" # Only search for monitors in folders + "type": "MonitorsLibraryMonitor", # Only search for monitors in folders } - + # Validate parameters - validated_folder_params = SumoLogicAPIValidator.validate_monitor_params(folder_params) - + validated_folder_params = SumoLogicAPIValidator.validate_monitor_params( + folder_params + ) + response = await self._make_request( method="GET", endpoint="/api/v1/monitors/search", params=validated_folder_params, - operation_type="monitor" + operation_type="monitor", ) - + folder_monitors = await self._parse_json_response(response) - + # Handle response format if isinstance(folder_monitors, list): return folder_monitors else: - return folder_monitors.get('data', []) - + return folder_monitors.get("data", []) + except Exception as e: logger.warning( f"Failed to search monitors in folder {folder_id}: {e}", - extra={"folder_id": folder_id, "error": str(e)} + extra={"folder_id": folder_id, "error": str(e)}, ) return [] - + async def get_monitor(self, monitor_id: str) -> Dict[str, Any]: """Get specific monitor configuration and details. - + Args: monitor_id: Monitor ID to retrieve - + Returns: Dictionary containing monitor configuration - + Raises: ValidationError: If monitor_id is invalid APIError: If API request fails @@ -2380,67 +2387,67 @@ async def get_monitor(self, monitor_id: str) -> Dict[str, Any]: raise ValidationError( "Monitor ID cannot be empty", field_name="monitor_id", - field_value=monitor_id + field_value=monitor_id, ) - + monitor_id = monitor_id.strip() - + logger.debug(f"Retrieving monitor {monitor_id}") - + try: response = await self._make_request( method="GET", endpoint=f"/api/v1/monitors/{monitor_id}", - operation_type="monitor" + operation_type="monitor", ) - + monitor = await self._parse_json_response(response) - + logger.info( f"Retrieved monitor", extra={ "monitor_id": monitor_id, "monitor_name": monitor.get("name"), "monitor_type": monitor.get("monitorType"), - "is_disabled": monitor.get("isDisabled") - } + "is_disabled": monitor.get("isDisabled"), + }, ) - + return monitor - + except APIError as e: raise APIError( f"Failed to get monitor {monitor_id}: {e.message}", status_code=e.status_code, response_body=e.response_body, request_id=e.request_id, - context={"monitor_id": monitor_id} + context={"monitor_id": monitor_id}, ) from e - + async def create_monitor(self, monitor_config: Dict[str, Any]) -> Dict[str, Any]: """Create new monitor with specified configuration. - + Args: monitor_config: Monitor configuration dictionary - + Returns: Dictionary containing created monitor information - + Raises: ValidationError: If monitor configuration is invalid APIError: If API request fails """ from .models.monitor import MonitorConfig - + # Validate monitor configuration try: config = MonitorConfig(**monitor_config) except Exception as e: raise ValidationError( f"Invalid monitor configuration: {e}", - context={"config": monitor_config} + context={"config": monitor_config}, ) from e - + # Build monitor payload for Sumo Logic API monitor_payload = { "name": config.name, @@ -2448,16 +2455,11 @@ async def create_monitor(self, monitor_config: Dict[str, Any]) -> Dict[str, Any] "type": config.type.value, "isDisabled": config.is_disabled, "contentType": "Monitor", - "queries": [ - { - "rowId": "A", - "query": config.query - } - ], + "queries": [{"rowId": "A", "query": config.query}], "triggers": [], - "notifications": [] + "notifications": [], } - + # Convert trigger conditions to Sumo Logic format for trigger_type, condition in config.trigger_conditions.items(): trigger_payload = { @@ -2466,87 +2468,89 @@ async def create_monitor(self, monitor_config: Dict[str, Any]) -> Dict[str, Any] "thresholdType": condition.threshold_type.value, "timeRange": condition.time_range, "occurrenceType": condition.occurrence_type.value, - "triggerSource": condition.trigger_source.value + "triggerSource": condition.trigger_source.value, } monitor_payload["triggers"].append(trigger_payload) - + # Convert notifications to Sumo Logic format for notification in config.notifications: notification_payload = { - "notification": { - "actionType": notification.action_type.value - } + "notification": {"actionType": notification.action_type.value} } - + if notification.subject: notification_payload["notification"]["subject"] = notification.subject if notification.message_body: - notification_payload["notification"]["messageBody"] = notification.message_body + notification_payload["notification"][ + "messageBody" + ] = notification.message_body if notification.recipients: - notification_payload["notification"]["recipients"] = notification.recipients + notification_payload["notification"][ + "recipients" + ] = notification.recipients if notification.webhook_url: - notification_payload["notification"]["connectionId"] = notification.webhook_url - + notification_payload["notification"][ + "connectionId" + ] = notification.webhook_url + monitor_payload["notifications"].append(notification_payload) - + # Add optional fields if config.evaluation_delay and config.evaluation_delay != "0m": monitor_payload["evaluationDelay"] = config.evaluation_delay - + monitor_payload["groupNotifications"] = config.group_notifications - + logger.info( f"Creating monitor '{config.name}'", extra={ "type": config.type.value, "trigger_count": len(config.trigger_conditions), - "notification_count": len(config.notifications) - } + "notification_count": len(config.notifications), + }, ) - + try: response = await self._make_request( method="POST", endpoint="/api/v1/monitors", json_data=monitor_payload, - operation_type="monitor" + operation_type="monitor", ) - + created_monitor = await self._parse_json_response(response) - + logger.info( f"Monitor created successfully", extra={ "monitor_id": created_monitor.get("id"), - "name": created_monitor.get("name") - } + "name": created_monitor.get("name"), + }, ) - + return created_monitor - + except APIError as e: raise APIError( f"Failed to create monitor: {e.message}", status_code=e.status_code, response_body=e.response_body, request_id=e.request_id, - context={"name": config.name} + context={"name": config.name}, ) from e - + async def update_monitor( - self, - monitor_id: str, - monitor_config: Dict[str, Any] + self, monitor_id: str, monitor_config: Dict[str, Any] ) -> Dict[str, Any]: """Update existing monitor configuration. - + Args: monitor_id: Monitor ID to update monitor_config: Updated monitor configuration (partial updates supported) - + Returns: Dictionary containing updated monitor information - + Raises: ValidationError: If parameters are invalid APIError: If API request fails @@ -2555,11 +2559,11 @@ async def update_monitor( raise ValidationError( "Monitor ID cannot be empty", field_name="monitor_id", - field_value=monitor_id + field_value=monitor_id, ) - + monitor_id = monitor_id.strip() - + # Get current monitor to merge with updates try: current_monitor = await self.get_monitor(monitor_id) @@ -2567,12 +2571,12 @@ async def update_monitor( raise APIError( f"Failed to get current monitor for update: {e.message}", status_code=e.status_code, - context={"monitor_id": monitor_id} + context={"monitor_id": monitor_id}, ) from e - + # Merge current config with updates updated_config = current_monitor.copy() - + # Handle partial updates carefully for key, value in monitor_config.items(): if key in ["trigger_conditions", "notifications"]: @@ -2580,53 +2584,50 @@ async def update_monitor( updated_config[key] = value else: updated_config[key] = value - + logger.info( f"Updating monitor {monitor_id}", extra={ "name": updated_config.get("name"), - "update_fields": list(monitor_config.keys()) - } + "update_fields": list(monitor_config.keys()), + }, ) - + try: response = await self._make_request( method="PUT", endpoint=f"/api/v1/monitors/{monitor_id}", json_data=updated_config, - operation_type="monitor" + operation_type="monitor", ) - + updated_monitor = await self._parse_json_response(response) - + logger.info( f"Monitor updated successfully", - extra={ - "monitor_id": monitor_id, - "name": updated_monitor.get("name") - } + extra={"monitor_id": monitor_id, "name": updated_monitor.get("name")}, ) - + return updated_monitor - + except APIError as e: raise APIError( f"Failed to update monitor {monitor_id}: {e.message}", status_code=e.status_code, response_body=e.response_body, request_id=e.request_id, - context={"monitor_id": monitor_id} + context={"monitor_id": monitor_id}, ) from e - + async def delete_monitor(self, monitor_id: str) -> Dict[str, Any]: """Delete specified monitor. - + Args: monitor_id: Monitor ID to delete - + Returns: Dictionary containing deletion confirmation and deleted monitor info - + Raises: ValidationError: If monitor_id is invalid APIError: If API request fails @@ -2635,30 +2636,30 @@ async def delete_monitor(self, monitor_id: str) -> Dict[str, Any]: raise ValidationError( "Monitor ID cannot be empty", field_name="monitor_id", - field_value=monitor_id + field_value=monitor_id, ) - + monitor_id = monitor_id.strip() - + # Get monitor details before deletion for confirmation try: monitor_info = await self.get_monitor(monitor_id) except APIError as e: # If monitor doesn't exist, still try deletion to get proper error monitor_info = {"id": monitor_id, "name": "Unknown"} - + logger.info(f"Deleting monitor {monitor_id}") - + try: response = await self._make_request( method="DELETE", endpoint=f"/api/v1/monitors/{monitor_id}", - operation_type="monitor" + operation_type="monitor", ) - + # Sumo Logic returns 204 for successful deletion success = response.status_code == 204 - + if success: logger.info(f"Monitor {monitor_id} deleted successfully") return { @@ -2667,26 +2668,28 @@ async def delete_monitor(self, monitor_id: str) -> Dict[str, Any]: "deleted_monitor": { "id": monitor_id, "name": monitor_info.get("name"), - "type": monitor_info.get("monitorType") - } + "type": monitor_info.get("monitorType"), + }, } else: - logger.warning(f"Monitor {monitor_id} deletion returned status {response.status_code}") + logger.warning( + f"Monitor {monitor_id} deletion returned status {response.status_code}" + ) return { "success": False, "message": f"Monitor deletion returned unexpected status {response.status_code}", - "monitor_id": monitor_id + "monitor_id": monitor_id, } - + except APIError as e: raise APIError( f"Failed to delete monitor {monitor_id}: {e.message}", status_code=e.status_code, response_body=e.response_body, request_id=e.request_id, - context={"monitor_id": monitor_id} + context={"monitor_id": monitor_id}, ) from e - + # Severity ranking used to pick the most significant active trigger when a # monitor reports multiple non-normal states (highest priority first). _TRIGGER_SEVERITY_ORDER = ["Critical", "Warning", "MissingData"] @@ -2770,7 +2773,7 @@ async def get_monitor_status( monitor_id: Optional[str] = None, filter_status: Optional[str] = None, limit: int = 100, - offset: int = 0 + offset: int = 0, ) -> Dict[str, Any]: """Get monitor status information. @@ -2798,14 +2801,12 @@ async def get_monitor_status( raise ValidationError( "Limit must be between 1 and 1000", field_name="limit", - field_value=limit + field_value=limit, ) if offset < 0: raise ValidationError( - "Offset must be non-negative", - field_name="offset", - field_value=offset + "Offset must be non-negative", field_name="offset", field_value=offset ) # Single monitor: read the monitor object directly and normalize it. @@ -2814,13 +2815,13 @@ async def get_monitor_status( raise ValidationError( "Monitor ID cannot be empty", field_name="monitor_id", - field_value=monitor_id + field_value=monitor_id, ) monitor_id = monitor_id.strip() logger.debug( "Getting monitor status", - extra={"monitor_id": monitor_id, "filter_status": filter_status} + extra={"monitor_id": monitor_id, "filter_status": filter_status}, ) monitor = await self.get_monitor(monitor_id) @@ -2830,7 +2831,7 @@ async def get_monitor_status( logger.info( "Retrieved monitor status", - extra={"monitor_id": monitor_id, "status": record["status"]} + extra={"monitor_id": monitor_id, "status": record["status"]}, ) return {"data": record} @@ -2841,7 +2842,7 @@ async def get_monitor_status( # monitor from each returned object's `status` array. logger.debug( "Getting status for all monitors", - extra={"filter_status": filter_status, "limit": limit, "offset": offset} + extra={"filter_status": filter_status, "limit": limit, "offset": offset}, ) try: @@ -2856,7 +2857,7 @@ async def get_monitor_status( status_code=e.status_code, response_body=e.response_body, request_id=e.request_id, - context={"monitor_id": monitor_id} + context={"monitor_id": monitor_id}, ) from e records: List[Dict[str, Any]] = [] @@ -2867,22 +2868,19 @@ async def get_monitor_status( continue records.append(self._build_monitor_status_record(monitor)) - logger.info( - "Retrieved monitor status", - extra={"status_count": len(records)} - ) + logger.info("Retrieved monitor status", extra={"status_count": len(records)}) return {"data": records, "total": len(records)} - + async def enable_monitor(self, monitor_id: str) -> Dict[str, Any]: """Enable a disabled monitor. - + Args: monitor_id: Monitor ID to enable - + Returns: Dictionary containing enable operation result - + Raises: ValidationError: If monitor_id is invalid APIError: If API request fails @@ -2891,52 +2889,52 @@ async def enable_monitor(self, monitor_id: str) -> Dict[str, Any]: raise ValidationError( "Monitor ID cannot be empty", field_name="monitor_id", - field_value=monitor_id + field_value=monitor_id, ) - + monitor_id = monitor_id.strip() - + logger.info(f"Enabling monitor {monitor_id}") - + try: # Update monitor to set isDisabled = false response = await self._make_request( method="PUT", endpoint=f"/api/v1/monitors/{monitor_id}/disable", json_data={"isDisabled": False}, - operation_type="monitor" + operation_type="monitor", ) - + result = await self._parse_json_response(response) - + logger.info(f"Monitor {monitor_id} enabled successfully") - + return { "success": True, "message": f"Monitor {monitor_id} enabled successfully", "monitor_id": monitor_id, "status": "enabled", - "timestamp": result.get("modifiedAt") + "timestamp": result.get("modifiedAt"), } - + except APIError as e: raise APIError( f"Failed to enable monitor {monitor_id}: {e.message}", status_code=e.status_code, response_body=e.response_body, request_id=e.request_id, - context={"monitor_id": monitor_id} + context={"monitor_id": monitor_id}, ) from e - + async def disable_monitor(self, monitor_id: str) -> Dict[str, Any]: """Disable an enabled monitor. - + Args: monitor_id: Monitor ID to disable - + Returns: Dictionary containing disable operation result - + Raises: ValidationError: If monitor_id is invalid APIError: If API request fails @@ -2945,62 +2943,59 @@ async def disable_monitor(self, monitor_id: str) -> Dict[str, Any]: raise ValidationError( "Monitor ID cannot be empty", field_name="monitor_id", - field_value=monitor_id + field_value=monitor_id, ) - + monitor_id = monitor_id.strip() - + logger.info(f"Disabling monitor {monitor_id}") - + try: # Update monitor to set isDisabled = true response = await self._make_request( method="PUT", endpoint=f"/api/v1/monitors/{monitor_id}/disable", json_data={"isDisabled": True}, - operation_type="monitor" + operation_type="monitor", ) - + result = await self._parse_json_response(response) - + logger.info(f"Monitor {monitor_id} disabled successfully") - + return { "success": True, "message": f"Monitor {monitor_id} disabled successfully", "monitor_id": monitor_id, "status": "disabled", - "timestamp": result.get("modifiedAt") + "timestamp": result.get("modifiedAt"), } - + except APIError as e: raise APIError( f"Failed to disable monitor {monitor_id}: {e.message}", status_code=e.status_code, response_body=e.response_body, request_id=e.request_id, - context={"monitor_id": monitor_id} + context={"monitor_id": monitor_id}, ) from e async def get_active_alerts( - self, - severity: Optional[str] = None, - limit: int = 100, - offset: int = 0 + self, severity: Optional[str] = None, limit: int = 100, offset: int = 0 ) -> Dict[str, Any]: """Get all currently active alerts with optional severity filtering. - + This method uses progressive endpoint fallback to find working API endpoints for retrieving active alerts, addressing the 400 error from incorrect endpoints. - + Args: severity: Optional severity filter (Critical, Warning, MissingData) limit: Maximum results to return (1-1000) offset: Result offset for pagination - + Returns: Dictionary containing active alerts information - + Raises: ValidationError: If parameters are invalid APIError: If API request fails after all fallback attempts @@ -3010,16 +3005,14 @@ async def get_active_alerts( raise ValidationError( "Limit must be between 1 and 1000", field_name="limit", - field_value=limit + field_value=limit, ) - + if offset < 0: raise ValidationError( - "Offset must be non-negative", - field_name="offset", - field_value=offset + "Offset must be non-negative", field_name="offset", field_value=offset ) - + # Validate and normalize severity if provided if severity: severity = severity.strip() @@ -3029,20 +3022,16 @@ async def get_active_alerts( f"Invalid severity '{severity}'. Must be one of: {', '.join(valid_severities)}", field_name="severity", field_value=severity, - context={"valid_options": valid_severities} + context={"valid_options": valid_severities}, ) - + # Check circuit breaker status before attempting requests if self.is_circuit_breaker_open(): logger.warning( "Circuit breaker is open - attempting reset before proceeding", - extra={ - "severity": severity, - "limit": limit, - "offset": offset - } + extra={"severity": severity, "limit": limit, "offset": offset}, ) - + # Try to reset circuit breaker reset_result = await self.reset_circuit_breaker() if not reset_result.get("reset_successful"): @@ -3059,69 +3048,64 @@ async def get_active_alerts( "processing_method": "circuit_breaker_open_degradation", "degradation_reason": "circuit_breaker_open", "circuit_breaker_reset_attempted": True, - "reset_result": reset_result + "reset_result": reset_result, }, "warning": "Circuit breaker is open due to previous failures. Returning empty result as graceful degradation.", - "circuit_breaker_info": reset_result + "circuit_breaker_info": reset_result, } - + logger.debug( "Getting active alerts with progressive endpoint fallback", - extra={ - "severity": severity, - "limit": limit, - "offset": offset - } + extra={"severity": severity, "limit": limit, "offset": offset}, ) - + # Try progressive endpoint fallback return await self._get_active_alerts_with_fallback(severity, limit, offset) - + async def _get_active_alerts_with_fallback( - self, - severity: Optional[str], - limit: int, - offset: int + self, severity: Optional[str], limit: int, offset: int ) -> Dict[str, Any]: """Get active alerts using progressive endpoint fallback mechanism. - + This method implements intelligent endpoint selection with caching and graceful degradation when endpoints fail. - + Args: severity: Optional severity filter limit: Maximum results to return offset: Result offset for pagination - + Returns: Dictionary containing active alerts information - + Raises: APIError: If all endpoints fail """ # Check for cached successful endpoint first cached_config = await self._get_cached_endpoint_config("get_active_alerts") - + # Define endpoint configurations in order of preference # Based on API discovery service findings and Sumo Logic API patterns - endpoint_configs = self._get_ordered_endpoint_configs(severity, limit, offset, cached_config) - + endpoint_configs = self._get_ordered_endpoint_configs( + severity, limit, offset, cached_config + ) + last_error = None attempted_endpoints = [] fallback_decisions = [] - + logger.info( f"Starting progressive endpoint fallback for active alerts", extra={ "total_endpoints": len(endpoint_configs), "has_cached_endpoint": cached_config is not None, - "cached_endpoint": cached_config.get("name") if cached_config else None - } + "cached_endpoint": cached_config.get("name") if cached_config else None, + }, ) - + for i, config in enumerate(endpoint_configs): is_cached = config.get("is_cached", False) - + try: logger.debug( f"Attempting endpoint {i+1}/{len(endpoint_configs)}: {config['name']}", @@ -3129,42 +3113,44 @@ async def _get_active_alerts_with_fallback( "endpoint": config["endpoint"], "params": config["params"], "is_cached": is_cached, - "priority": config.get("priority", "normal") - } + "priority": config.get("priority", "normal"), + }, ) - + # Record fallback decision - fallback_decisions.append({ - "attempt": i + 1, - "endpoint_name": config["name"], - "endpoint": config["endpoint"], - "reason": "cached_success" if is_cached else "fallback_attempt", - "timestamp": datetime.utcnow().isoformat() - }) - + fallback_decisions.append( + { + "attempt": i + 1, + "endpoint_name": config["name"], + "endpoint": config["endpoint"], + "reason": "cached_success" if is_cached else "fallback_attempt", + "timestamp": datetime.utcnow().isoformat(), + } + ) + response = await self._make_request( method="GET", endpoint=config["endpoint"], params=config["params"], - operation_type="monitor" + operation_type="monitor", ) - + alerts_result = await self._parse_json_response(response) - + # Process and filter the response based on the endpoint type processed_result = await self._process_alerts_response( alerts_result, config, severity ) - + # Add fallback metadata to response processed_result["fallback_info"] = { "endpoint_used": config["name"], "attempt_number": i + 1, "total_attempts": len(endpoint_configs), "was_cached": is_cached, - "fallback_decisions": fallback_decisions + "fallback_decisions": fallback_decisions, } - + logger.info( f"Successfully retrieved active alerts using {config['name']} (attempt {i+1})", extra={ @@ -3172,70 +3158,74 @@ async def _get_active_alerts_with_fallback( "alert_count": len(processed_result.get("data", [])), "severity_filter": severity, "was_cached": is_cached, - "response_time": processed_result.get("response_time_ms") - } + "response_time": processed_result.get("response_time_ms"), + }, ) - + # Cache successful endpoint configuration for future use await self._cache_successful_endpoint_config(config) - + # Update endpoint success metrics await self._update_endpoint_success_metrics(config["name"]) - + return processed_result - + except APIError as e: - attempted_endpoints.append({ - "name": config["name"], - "endpoint": config["endpoint"], - "error": e.message, - "status_code": e.status_code, - "was_cached": is_cached, - "attempt_number": i + 1 - }) - + attempted_endpoints.append( + { + "name": config["name"], + "endpoint": config["endpoint"], + "error": e.message, + "status_code": e.status_code, + "was_cached": is_cached, + "attempt_number": i + 1, + } + ) + logger.warning( f"Endpoint {config['name']} failed (attempt {i+1}): {e.message}", extra={ "endpoint": config["endpoint"], "status_code": e.status_code, "error_type": type(e).__name__, - "was_cached": is_cached - } + "was_cached": is_cached, + }, ) - + # If cached endpoint failed, remove it from cache if is_cached: await self._invalidate_cached_endpoint("get_active_alerts") logger.info("Invalidated cached endpoint due to failure") - + # Update endpoint failure metrics - await self._update_endpoint_failure_metrics(config["name"], e.status_code) - + await self._update_endpoint_failure_metrics( + config["name"], e.status_code + ) + last_error = e continue - + # All endpoints failed - implement graceful degradation degraded_response = await self._handle_complete_endpoint_failure( attempted_endpoints, fallback_decisions, severity, last_error ) - + if degraded_response: return degraded_response - + # No graceful degradation possible error_summary = { "attempted_endpoints": attempted_endpoints, "total_attempts": len(endpoint_configs), "fallback_decisions": fallback_decisions, - "last_error": last_error.message if last_error else "Unknown error" + "last_error": last_error.message if last_error else "Unknown error", } - + logger.error( "All alert endpoint attempts failed - no graceful degradation available", - extra=error_summary + extra=error_summary, ) - + raise APIError( f"Failed to retrieve active alerts after trying {len(endpoint_configs)} endpoints. " f"Last error: {last_error.message if last_error else 'Unknown error'}", @@ -3246,35 +3236,35 @@ async def _get_active_alerts_with_fallback( "severity": severity, "attempted_endpoints": [ep["name"] for ep in attempted_endpoints], "endpoint_errors": error_summary, - "fallback_decisions": fallback_decisions - } + "fallback_decisions": fallback_decisions, + }, ) - async def validate_monitor_query(self, query: str, monitor_type: str = "MonitorsLibraryMonitor") -> Dict[str, Any]: + async def validate_monitor_query( + self, query: str, monitor_type: str = "MonitorsLibraryMonitor" + ) -> Dict[str, Any]: """Validate monitor query syntax using dry-run approach. - + Args: query: Monitor query to validate monitor_type: Type of monitor (for context-specific validation) - + Returns: Dictionary containing validation results - + Raises: ValidationError: If query is invalid APIError: If API request fails """ if not query or not query.strip(): raise ValidationError( - "Query cannot be empty", - field_name="query", - field_value=query + "Query cannot be empty", field_name="query", field_value=query ) - + query = query.strip() - + logger.debug(f"Validating monitor query syntax") - + try: # Use search API with dry-run to validate query syntax # This approach validates the query without actually executing it @@ -3284,9 +3274,9 @@ async def validate_monitor_query(self, query: str, monitor_type: str = "Monitors "to": "now", "timeZone": "UTC", "byReceiptTime": False, - "validate_only": True # Custom parameter for validation + "validate_only": True, # Custom parameter for validation } - + # For logs monitors, use search validation if monitor_type == "MonitorsLibraryMonitor": endpoint = "/api/v1/search/jobs" @@ -3299,33 +3289,35 @@ async def validate_monitor_query(self, query: str, monitor_type: str = "Monitors # For SLI monitors, use basic search validation endpoint = "/api/v1/search/jobs" validation_payload["maxCount"] = 1 - + response = await self._make_request( method="POST", endpoint=endpoint, json_data=validation_payload, - operation_type="search" + operation_type="search", ) - + result = await self._parse_json_response(response) - + logger.info("Query validation completed successfully") - + return { "valid": True, "query": query, "monitor_type": monitor_type, "validation_method": "dry_run", - "message": "Query syntax is valid" + "message": "Query syntax is valid", } - + except APIError as e: # Parse validation errors from API response error_message = e.message - + # Check if it's a syntax error vs other API error - if e.status_code == 400 and any(keyword in error_message.lower() for keyword in - ['syntax', 'parse', 'invalid query', 'malformed']): + if e.status_code == 400 and any( + keyword in error_message.lower() + for keyword in ["syntax", "parse", "invalid query", "malformed"] + ): logger.warning(f"Query validation failed: {error_message}") return { "valid": False, @@ -3333,7 +3325,7 @@ async def validate_monitor_query(self, query: str, monitor_type: str = "Monitors "monitor_type": monitor_type, "validation_method": "dry_run", "error": error_message, - "error_type": "syntax_error" + "error_type": "syntax_error", } else: # Re-raise non-syntax API errors @@ -3342,27 +3334,23 @@ async def validate_monitor_query(self, query: str, monitor_type: str = "Monitors status_code=e.status_code, response_body=e.response_body, request_id=e.request_id, - context={"query": query, "monitor_type": monitor_type} + context={"query": query, "monitor_type": monitor_type}, ) from e async def get_monitor_history( - self, - monitor_id: str, - from_time: str, - to_time: str, - limit: int = 100 + self, monitor_id: str, from_time: str, to_time: str, limit: int = 100 ) -> Dict[str, Any]: """Get monitor execution history and performance metrics. - + Args: monitor_id: Unique identifier for the monitor from_time: Start time for history range (ISO format or relative like '-1h') to_time: End time for history range (ISO format or relative like 'now') limit: Maximum number of history entries to return (1-1000) - + Returns: Dictionary containing monitor execution history and performance metrics - + Raises: ValidationError: If parameters are invalid APIError: If API request fails @@ -3371,69 +3359,65 @@ async def get_monitor_history( raise ValidationError( "Monitor ID must be a non-empty string", field_name="monitor_id", - field_value=monitor_id + field_value=monitor_id, ) - + if not from_time or not isinstance(from_time, str): raise ValidationError( "from_time must be a non-empty string", field_name="from_time", - field_value=from_time + field_value=from_time, ) - + if not to_time or not isinstance(to_time, str): raise ValidationError( "to_time must be a non-empty string", field_name="to_time", - field_value=to_time + field_value=to_time, ) - + if not isinstance(limit, int) or limit < 1 or limit > 1000: raise ValidationError( "Limit must be an integer between 1 and 1000", field_name="limit", - field_value=limit + field_value=limit, ) - + logger.debug( f"Getting monitor history for monitor {monitor_id} from {from_time} to {to_time}" ) - + try: # Convert relative time expressions to absolute timestamps if needed from_timestamp = self._parse_time_expression(from_time) to_timestamp = self._parse_time_expression(to_time) - + # Sumo Logic monitor history endpoint endpoint = f"/api/v1/monitors/{monitor_id}/history" - - params = { - "from": from_timestamp, - "to": to_timestamp, - "limit": limit - } - + + params = {"from": from_timestamp, "to": to_timestamp, "limit": limit} + response = await self._make_request( method="GET", endpoint=endpoint, params=params, - operation_type="monitor_management" + operation_type="monitor_management", ) - + result = await self._parse_json_response(response) - + logger.info( f"Successfully retrieved monitor history for monitor {monitor_id}", extra={ "monitor_id": monitor_id, "from_time": from_time, "to_time": to_time, - "entries_returned": len(result.get("data", [])) - } + "entries_returned": len(result.get("data", [])), + }, ) - + return result - + except APIError as e: if e.status_code == 404: raise APIError( @@ -3441,7 +3425,7 @@ async def get_monitor_history( status_code=404, response_body=e.response_body, request_id=e.request_id, - context={"monitor_id": monitor_id} + context={"monitor_id": monitor_id}, ) from e elif e.status_code == 403: raise APIError( @@ -3449,7 +3433,7 @@ async def get_monitor_history( status_code=403, response_body=e.response_body, request_id=e.request_id, - context={"monitor_id": monitor_id} + context={"monitor_id": monitor_id}, ) from e else: raise APIError( @@ -3457,69 +3441,77 @@ async def get_monitor_history( status_code=e.status_code, response_body=e.response_body, request_id=e.request_id, - context={"monitor_id": monitor_id, "from_time": from_time, "to_time": to_time} + context={ + "monitor_id": monitor_id, + "from_time": from_time, + "to_time": to_time, + }, ) from e - + except Exception as e: logger.error( f"Unexpected error getting monitor history for monitor {monitor_id}", - extra={"error": str(e), "error_type": type(e).__name__} + extra={"error": str(e), "error_type": type(e).__name__}, ) raise APIError( f"Unexpected error getting monitor history: {str(e)}", - context={"monitor_id": monitor_id, "from_time": from_time, "to_time": to_time} + context={ + "monitor_id": monitor_id, + "from_time": from_time, + "to_time": to_time, + }, ) from e def _parse_time_expression(self, time_expr: str) -> str: """Parse time expression and convert to appropriate format. - + Args: time_expr: Time expression (ISO format, 'now', or relative like '-1h') - + Returns: Formatted time string for API """ import re from datetime import datetime, timedelta - + time_expr = time_expr.strip() - + # Handle 'now' keyword - if time_expr.lower() == 'now': - return datetime.utcnow().isoformat() + 'Z' - + if time_expr.lower() == "now": + return datetime.utcnow().isoformat() + "Z" + # Handle relative time expressions like '-1h', '-30m', '-1d' - relative_pattern = r'^-(\d+)([smhdw])$' + relative_pattern = r"^-(\d+)([smhdw])$" match = re.match(relative_pattern, time_expr) - + if match: value, unit = match.groups() value = int(value) - + # Calculate timedelta based on unit - if unit == 's': + if unit == "s": delta = timedelta(seconds=value) - elif unit == 'm': + elif unit == "m": delta = timedelta(minutes=value) - elif unit == 'h': + elif unit == "h": delta = timedelta(hours=value) - elif unit == 'd': + elif unit == "d": delta = timedelta(days=value) - elif unit == 'w': + elif unit == "w": delta = timedelta(weeks=value) else: # Fallback to original expression return time_expr - + # Calculate absolute time absolute_time = datetime.utcnow() - delta - return absolute_time.isoformat() + 'Z' - + return absolute_time.isoformat() + "Z" + # Handle ISO format or other absolute time formats # Try to parse as ISO format first try: # If it's already a valid ISO format, return as-is - datetime.fromisoformat(time_expr.replace('Z', '+00:00')) + datetime.fromisoformat(time_expr.replace("Z", "+00:00")) return time_expr except ValueError: # If not ISO format, return as-is and let API handle it @@ -3528,26 +3520,26 @@ def _parse_time_expression(self, time_expr: str) -> str: async def __aenter__(self): """Async context manager entry.""" return self - + async def __aexit__(self, exc_type, exc_val, exc_tb): """Async context manager exit.""" await self.close() - + def _get_ordered_endpoint_configs( self, severity: Optional[str], limit: int, offset: int, - cached_config: Optional[Dict[str, Any]] = None + cached_config: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: """Get endpoint configurations ordered by preference and success history. - + Args: severity: Optional severity filter limit: Maximum results to return offset: Result offset for pagination cached_config: Previously successful cached configuration - + Returns: List of endpoint configurations ordered by preference """ @@ -3556,80 +3548,85 @@ def _get_ordered_endpoint_configs( { "name": "monitors_search_critical", "endpoint": "/api/v1/monitors/search", - "params": self._build_search_params("Critical", severity, limit, offset), + "params": self._build_search_params( + "Critical", severity, limit, offset + ), "description": "Monitor search with Critical status", - "priority": "high" + "priority": "high", }, { "name": "monitors_search_warning", - "endpoint": "/api/v1/monitors/search", + "endpoint": "/api/v1/monitors/search", "params": self._build_search_params("Warning", severity, limit, offset), "description": "Monitor search with Warning status", - "priority": "high" + "priority": "high", }, { "name": "monitors_search_all_triggered", "endpoint": "/api/v1/monitors/search", - "params": self._build_search_params("AllTriggered", severity, limit, offset), + "params": self._build_search_params( + "AllTriggered", severity, limit, offset + ), "description": "Monitor search with AllTriggered status", - "priority": "high" + "priority": "high", }, { "name": "monitors_list_filtered", "endpoint": "/api/v1/monitors", "params": self._build_monitor_list_params(severity, limit, offset), "description": "Monitor list with filtering", - "priority": "medium" + "priority": "medium", }, { "name": "monitors_alerts_no_status", "endpoint": "/api/v1/monitors/alerts", "params": self._build_alerts_params(None, severity, limit, offset), "description": "Alerts endpoint without status parameter", - "priority": "medium" - } + "priority": "medium", + }, ] - + # If we have a cached successful configuration, prioritize it if cached_config and self._is_cache_valid(cached_config): # Update cached config with current parameters cached_config_updated = { **cached_config, - "params": self._update_params_for_request(cached_config, severity, limit, offset), + "params": self._update_params_for_request( + cached_config, severity, limit, offset + ), "is_cached": True, - "priority": "cached" + "priority": "cached", } - + # Remove the cached config from base configs to avoid duplication - base_configs = [config for config in base_configs - if config["name"] != cached_config["name"]] - + base_configs = [ + config + for config in base_configs + if config["name"] != cached_config["name"] + ] + # Put cached config first return [cached_config_updated] + base_configs - + return base_configs - + def _build_search_params( - self, - monitor_status: str, - severity: Optional[str], - limit: int, - offset: int + self, monitor_status: str, severity: Optional[str], limit: int, offset: int ) -> Dict[str, Any]: """Build parameters for monitor search endpoint. - + Uses the correct Sumo Logic API format where monitorStatus is a filter within the query parameter, not a separate parameter. - + Args: monitor_status: Monitor status filter (Normal, Critical, Warning, MissingData, Disabled, AllTriggered) severity: Optional severity filter limit: Maximum results offset: Result offset - + Returns: Dictionary of query parameters with proper query string format - + Example: For Critical status: {"query": "monitorStatus:Critical", "limit": 100, "offset": 0} For Warning with severity: {"query": "monitorStatus:Warning AND severity:Critical", "limit": 100, "offset": 0} @@ -3637,99 +3634,81 @@ def _build_search_params( # Build the query string with monitorStatus filter # Format: monitorStatus:Critical or monitorStatus:Warning AND severity:Critical query_parts = [f"monitorStatus:{monitor_status}"] - + # Add severity filter to query if specified if severity: query_parts.append(f"severity:{severity}") - - params = { - "limit": limit, - "offset": offset, - "query": " AND ".join(query_parts) - } - + + params = {"limit": limit, "offset": offset, "query": " AND ".join(query_parts)} + return params - + def _build_monitor_list_params( - self, - severity: Optional[str], - limit: int, - offset: int + self, severity: Optional[str], limit: int, offset: int ) -> Dict[str, Any]: """Build parameters for monitor list endpoint. - + Args: severity: Optional severity filter limit: Maximum results offset: Result offset - + Returns: Dictionary of query parameters """ params = { "limit": limit, "offset": offset, - "isDisabled": False # Only get enabled monitors + "isDisabled": False, # Only get enabled monitors } - + # Note: Monitor list endpoint may not support direct severity filtering # We'll filter client-side in _process_alerts_response if needed - + return params - + def _build_alerts_params( - self, - status: Optional[str], - severity: Optional[str], - limit: int, - offset: int + self, status: Optional[str], severity: Optional[str], limit: int, offset: int ) -> Dict[str, Any]: """Build parameters for alerts endpoint. - + Args: status: Optional status filter (None to avoid the problematic 'active' parameter) severity: Optional severity filter limit: Maximum results offset: Result offset - + Returns: Dictionary of query parameters """ - params = { - "limit": limit, - "offset": offset - } - + params = {"limit": limit, "offset": offset} + # Only add status if explicitly provided and not the problematic 'active' if status and status != "active": params["status"] = status - + # Add severity filter if specified if severity: params["severity"] = severity - + return params - + def _update_params_for_request( - self, - config: Dict[str, Any], - severity: Optional[str], - limit: int, - offset: int + self, config: Dict[str, Any], severity: Optional[str], limit: int, offset: int ) -> Dict[str, Any]: """Update cached endpoint parameters for current request. - + Args: config: Cached endpoint configuration severity: Current severity filter limit: Current limit offset: Current offset - + Returns: Updated parameters dictionary """ endpoint_name = config.get("name", "") - + if "search" in endpoint_name: # Determine monitor status based on endpoint name if "critical" in endpoint_name: @@ -3740,31 +3719,31 @@ def _update_params_for_request( monitor_status = "AllTriggered" else: monitor_status = "AllTriggered" # Default fallback - + return self._build_search_params(monitor_status, severity, limit, offset) elif "monitors" in endpoint_name and "alerts" not in endpoint_name: return self._build_monitor_list_params(severity, limit, offset) else: return self._build_alerts_params(None, severity, limit, offset) - + async def _process_alerts_response( self, response_data: Dict[str, Any], endpoint_config: Dict[str, Any], - severity_filter: Optional[str] + severity_filter: Optional[str], ) -> Dict[str, Any]: """Process and normalize alerts response from different endpoints. - + Args: response_data: Raw response data from API endpoint_config: Configuration of the endpoint used severity_filter: Applied severity filter - + Returns: Normalized alerts response """ endpoint_name = endpoint_config["name"] - + # Ensure response_data is a dictionary if not isinstance(response_data, dict): logger.warning( @@ -3776,7 +3755,7 @@ async def _process_alerts_response( else: # For other types, return empty result response_data = {"data": []} - + # Handle different response formats based on endpoint type if "search" in endpoint_name: # Monitor search endpoints typically return monitors, not alerts @@ -3786,7 +3765,7 @@ async def _process_alerts_response( logger.warning(f"Expected list for monitors data, got {type(monitors)}") monitors = [] alerts = await self._extract_alerts_from_monitors(monitors, severity_filter) - + elif "monitors" in endpoint_name and "alerts" not in endpoint_name: # Monitor list endpoint - extract triggered monitors monitors = response_data.get("data", []) @@ -3794,19 +3773,23 @@ async def _process_alerts_response( logger.warning(f"Expected list for monitors data, got {type(monitors)}") monitors = [] alerts = await self._extract_alerts_from_monitors(monitors, severity_filter) - + else: # Direct alerts endpoint alerts = response_data.get("data", []) if not isinstance(alerts, list): logger.warning(f"Expected list for alerts data, got {type(alerts)}") alerts = [] - + # Apply client-side severity filtering if needed if severity_filter: - alerts = [alert for alert in alerts - if isinstance(alert, dict) and alert.get("severity") == severity_filter] - + alerts = [ + alert + for alert in alerts + if isinstance(alert, dict) + and alert.get("severity") == severity_filter + ] + # Normalize response format return { "data": alerts, @@ -3818,49 +3801,54 @@ async def _process_alerts_response( "metadata": { "source_endpoint": endpoint_config["endpoint"], "processing_method": self._get_processing_method(endpoint_name), - "filtered_client_side": severity_filter is not None and "search" not in endpoint_name - } + "filtered_client_side": severity_filter is not None + and "search" not in endpoint_name, + }, } - + async def _extract_alerts_from_monitors( - self, - monitors: List[Dict[str, Any]], - severity_filter: Optional[str] + self, monitors: List[Dict[str, Any]], severity_filter: Optional[str] ) -> List[Dict[str, Any]]: """Extract alert information from monitor data. - + Args: monitors: List of monitor objects severity_filter: Optional severity filter to apply - + Returns: List of alert objects extracted from monitors """ alerts = [] - + for monitor in monitors: # Ensure monitor is a dictionary before processing if not isinstance(monitor, dict): - logger.warning(f"Skipping non-dictionary monitor object: {type(monitor)}") + logger.warning( + f"Skipping non-dictionary monitor object: {type(monitor)}" + ) continue - + # Check if monitor is in a triggered/alert state monitor_status = monitor.get("status", "").lower() - monitor_state = monitor.get("monitorStatus", monitor.get("state", "")).lower() + monitor_state = monitor.get( + "monitorStatus", monitor.get("state", "") + ).lower() is_disabled = monitor.get("isDisabled", False) - + # Skip disabled monitors if is_disabled: continue - + # Look for indicators that this monitor has active alerts # Based on Sumo Logic monitorStatus values: Normal, Critical, Warning, MissingData, Disabled, AllTriggered has_alert = ( - monitor_status in ["critical", "warning", "missingdata", "alltriggered"] or - monitor_state in ["critical", "warning", "missingdata", "alltriggered"] or - monitor.get("alertStatus", "").lower() in ["critical", "warning", "missingdata", "alltriggered"] + monitor_status in ["critical", "warning", "missingdata", "alltriggered"] + or monitor_state + in ["critical", "warning", "missingdata", "alltriggered"] + or monitor.get("alertStatus", "").lower() + in ["critical", "warning", "missingdata", "alltriggered"] ) - + if has_alert: # Extract alert information from monitor alert = { @@ -3881,22 +3869,22 @@ async def _extract_alerts_from_monitors( "metadata": { "extracted_from_monitor": True, "original_status": monitor_status, - "monitor_data": monitor - } + "monitor_data": monitor, + }, } - + # Apply severity filter if specified if not severity_filter or alert["severity"] == severity_filter: alerts.append(alert) - + return alerts - + def _extract_severity_from_monitor(self, monitor: Dict[str, Any]) -> str: """Extract severity information from monitor data. - + Args: monitor: Monitor object - + Returns: Severity string (Critical, Warning, or MissingData) """ @@ -3908,9 +3896,9 @@ def _extract_severity_from_monitor(self, monitor: Dict[str, Any]) -> str: monitor.get("severity"), monitor.get("alertSeverity"), monitor.get("triggerSeverity"), - monitor.get("state") + monitor.get("state"), ] - + for field_value in severity_fields: if field_value: field_str = str(field_value).lower() @@ -3920,16 +3908,16 @@ def _extract_severity_from_monitor(self, monitor: Dict[str, Any]) -> str: return "Warning" elif "missing" in field_str or "missingdata" in field_str: return "MissingData" - + # Default to Warning if no specific severity found return "Warning" - + def _get_processing_method(self, endpoint_name: str) -> str: """Get the processing method description for an endpoint. - + Args: endpoint_name: Name of the endpoint used - + Returns: Description of processing method """ @@ -3939,65 +3927,67 @@ def _get_processing_method(self, endpoint_name: str) -> str: return "extracted_from_monitor_list" else: return "direct_alerts_endpoint" - - async def _get_cached_endpoint_config(self, operation: str) -> Optional[Dict[str, Any]]: + + async def _get_cached_endpoint_config( + self, operation: str + ) -> Optional[Dict[str, Any]]: """Get cached successful endpoint configuration. - + Args: operation: Operation name (e.g., 'get_active_alerts') - + Returns: Cached configuration or None if not found/expired """ - if not hasattr(self, '_endpoint_cache'): + if not hasattr(self, "_endpoint_cache"): return None - + cached = self._endpoint_cache.get(operation) if not cached: return None - + # Check if cache is still valid if not self._is_cache_valid(cached): # Remove expired cache del self._endpoint_cache[operation] return None - + return cached - + def _is_cache_valid(self, cached_config: Dict[str, Any]) -> bool: """Check if cached endpoint configuration is still valid. - + Args: cached_config: Cached configuration to validate - + Returns: True if cache is valid, False otherwise """ if not cached_config.get("cached_at"): return False - + try: cached_time = datetime.fromisoformat(cached_config["cached_at"]) cache_ttl = cached_config.get("cache_ttl", 3600) # Default 1 hour - + age_seconds = (datetime.utcnow() - cached_time).total_seconds() return age_seconds < cache_ttl - + except (ValueError, TypeError): return False - + async def _cache_successful_endpoint_config(self, config: Dict[str, Any]) -> None: """Cache successful endpoint configuration for future use. - + Args: config: Successful endpoint configuration """ - if not hasattr(self, '_endpoint_cache'): + if not hasattr(self, "_endpoint_cache"): self._endpoint_cache = {} - + cache_key = "get_active_alerts" current_cache = self._endpoint_cache.get(cache_key, {}) - + # Enhanced caching with success tracking and TTL cached_config = { "name": config["name"], @@ -4011,95 +4001,103 @@ async def _cache_successful_endpoint_config(self, config: Dict[str, Any]) -> Non "last_used": datetime.utcnow().isoformat(), "performance_metrics": { "avg_response_time": config.get("response_time_ms", 0), - "reliability_score": self._calculate_reliability_score(config["name"]) - } + "reliability_score": self._calculate_reliability_score(config["name"]), + }, } - + self._endpoint_cache[cache_key] = cached_config - + logger.info( f"Cached successful endpoint configuration for {cache_key}", extra={ "endpoint": config["endpoint"], "endpoint_name": config["name"], "success_count": cached_config["success_count"], - "reliability_score": cached_config["performance_metrics"]["reliability_score"] - } + "reliability_score": cached_config["performance_metrics"][ + "reliability_score" + ], + }, ) - + async def _invalidate_cached_endpoint(self, operation: str) -> None: """Invalidate cached endpoint configuration. - + Args: operation: Operation name to invalidate """ - if hasattr(self, '_endpoint_cache') and operation in self._endpoint_cache: + if hasattr(self, "_endpoint_cache") and operation in self._endpoint_cache: del self._endpoint_cache[operation] logger.debug(f"Invalidated cached endpoint for {operation}") - + async def _update_endpoint_success_metrics(self, endpoint_name: str) -> None: """Update success metrics for an endpoint. - + Args: endpoint_name: Name of successful endpoint """ - if not hasattr(self, '_endpoint_metrics'): + if not hasattr(self, "_endpoint_metrics"): self._endpoint_metrics = {} - + if endpoint_name not in self._endpoint_metrics: self._endpoint_metrics[endpoint_name] = { "success_count": 0, "failure_count": 0, "last_success": None, - "last_failure": None + "last_failure": None, } - + self._endpoint_metrics[endpoint_name]["success_count"] += 1 - self._endpoint_metrics[endpoint_name]["last_success"] = datetime.utcnow().isoformat() - - async def _update_endpoint_failure_metrics(self, endpoint_name: str, status_code: Optional[int]) -> None: + self._endpoint_metrics[endpoint_name][ + "last_success" + ] = datetime.utcnow().isoformat() + + async def _update_endpoint_failure_metrics( + self, endpoint_name: str, status_code: Optional[int] + ) -> None: """Update failure metrics for an endpoint. - + Args: endpoint_name: Name of failed endpoint status_code: HTTP status code of failure """ - if not hasattr(self, '_endpoint_metrics'): + if not hasattr(self, "_endpoint_metrics"): self._endpoint_metrics = {} - + if endpoint_name not in self._endpoint_metrics: self._endpoint_metrics[endpoint_name] = { "success_count": 0, "failure_count": 0, "last_success": None, "last_failure": None, - "failure_codes": {} + "failure_codes": {}, } - + metrics = self._endpoint_metrics[endpoint_name] metrics["failure_count"] += 1 metrics["last_failure"] = datetime.utcnow().isoformat() - + if status_code: if "failure_codes" not in metrics: metrics["failure_codes"] = {} - metrics["failure_codes"][str(status_code)] = metrics["failure_codes"].get(str(status_code), 0) + 1 - + metrics["failure_codes"][str(status_code)] = ( + metrics["failure_codes"].get(str(status_code), 0) + 1 + ) + async def _handle_complete_endpoint_failure( self, attempted_endpoints: List[Dict[str, Any]], fallback_decisions: List[Dict[str, Any]], severity_filter: Optional[str], - last_error: Optional[APIError] + last_error: Optional[APIError], ) -> Optional[Dict[str, Any]]: """Handle complete endpoint failure with graceful degradation. - + Args: attempted_endpoints: List of attempted endpoints and their errors fallback_decisions: List of fallback decisions made severity_filter: Applied severity filter last_error: Last error encountered - + Returns: Graceful degradation response or None if not possible """ @@ -4107,10 +4105,10 @@ async def _handle_complete_endpoint_failure( "Implementing graceful degradation for complete endpoint failure", extra={ "attempted_count": len(attempted_endpoints), - "severity_filter": severity_filter - } + "severity_filter": severity_filter, + }, ) - + # Check if we can provide a meaningful empty response if self._should_provide_empty_response(attempted_endpoints): return { @@ -4125,7 +4123,7 @@ async def _handle_complete_endpoint_failure( "processing_method": "empty_response_due_to_failures", "degradation_reason": "all_endpoints_failed", "attempted_endpoints": attempted_endpoints, - "fallback_decisions": fallback_decisions + "fallback_decisions": fallback_decisions, }, "fallback_info": { "endpoint_used": "graceful_degradation", @@ -4133,62 +4131,71 @@ async def _handle_complete_endpoint_failure( "total_attempts": len(attempted_endpoints), "was_cached": False, "degradation_applied": True, - "fallback_decisions": fallback_decisions + "fallback_decisions": fallback_decisions, }, - "warning": "All API endpoints failed. Returning empty result as graceful degradation." + "warning": "All API endpoints failed. Returning empty result as graceful degradation.", } - + return None - - def _should_provide_empty_response(self, attempted_endpoints: List[Dict[str, Any]]) -> bool: + + def _should_provide_empty_response( + self, attempted_endpoints: List[Dict[str, Any]] + ) -> bool: """Determine if graceful degradation with empty response is appropriate. - + Args: attempted_endpoints: List of attempted endpoints and their errors - + Returns: True if empty response degradation is appropriate """ # Provide empty response if all failures are client errors (4xx) # This suggests the endpoints exist but parameters are wrong - client_errors = [ep for ep in attempted_endpoints - if ep.get("status_code") is not None and - ep.get("status_code", 0) >= 400 and ep.get("status_code", 0) < 500] - + client_errors = [ + ep + for ep in attempted_endpoints + if ep.get("status_code") is not None + and ep.get("status_code", 0) >= 400 + and ep.get("status_code", 0) < 500 + ] + # If more than half are client errors, it's likely a parameter issue # and returning empty is better than failing completely return len(client_errors) > len(attempted_endpoints) / 2 - + def _calculate_reliability_score(self, endpoint_name: str) -> float: """Calculate reliability score for an endpoint based on success/failure history. - + Args: endpoint_name: Name of the endpoint - + Returns: Reliability score between 0.0 and 1.0 """ - if not hasattr(self, '_endpoint_metrics') or endpoint_name not in self._endpoint_metrics: + if ( + not hasattr(self, "_endpoint_metrics") + or endpoint_name not in self._endpoint_metrics + ): return 1.0 # New endpoint gets benefit of doubt - + metrics = self._endpoint_metrics[endpoint_name] success_count = metrics.get("success_count", 0) failure_count = metrics.get("failure_count", 0) - + total_attempts = success_count + failure_count if total_attempts == 0: return 1.0 - + return success_count / total_attempts - + def get_endpoint_fallback_status(self) -> Dict[str, Any]: """Get status information about endpoint fallback mechanisms. - + Returns: Dictionary containing fallback status and metrics """ cache_info = {} - if hasattr(self, '_endpoint_cache'): + if hasattr(self, "_endpoint_cache"): for operation, config in self._endpoint_cache.items(): cache_info[operation] = { "endpoint_name": config.get("name"), @@ -4196,52 +4203,56 @@ def get_endpoint_fallback_status(self) -> Dict[str, Any]: "cached_at": config.get("cached_at"), "success_count": config.get("success_count", 0), "is_valid": self._is_cache_valid(config), - "reliability_score": config.get("performance_metrics", {}).get("reliability_score", 0.0) + "reliability_score": config.get("performance_metrics", {}).get( + "reliability_score", 0.0 + ), } - + metrics_info = {} - if hasattr(self, '_endpoint_metrics'): + if hasattr(self, "_endpoint_metrics"): for endpoint_name, metrics in self._endpoint_metrics.items(): metrics_info[endpoint_name] = { "success_count": metrics.get("success_count", 0), "failure_count": metrics.get("failure_count", 0), - "reliability_score": self._calculate_reliability_score(endpoint_name), + "reliability_score": self._calculate_reliability_score( + endpoint_name + ), "last_success": metrics.get("last_success"), "last_failure": metrics.get("last_failure"), - "failure_codes": metrics.get("failure_codes", {}) + "failure_codes": metrics.get("failure_codes", {}), } - + return { "fallback_mechanism": { "enabled": True, "cache_enabled": True, - "graceful_degradation": True + "graceful_degradation": True, }, "cached_endpoints": cache_info, "endpoint_metrics": metrics_info, "summary": { "total_cached_operations": len(cache_info), "total_tracked_endpoints": len(metrics_info), - "most_reliable_endpoint": self._get_most_reliable_endpoint() - } + "most_reliable_endpoint": self._get_most_reliable_endpoint(), + }, } - + def _get_most_reliable_endpoint(self) -> Optional[str]: """Get the name of the most reliable endpoint based on metrics. - + Returns: Name of most reliable endpoint or None if no metrics available """ - if not hasattr(self, '_endpoint_metrics'): + if not hasattr(self, "_endpoint_metrics"): return None - + best_endpoint = None best_score = 0.0 - + for endpoint_name in self._endpoint_metrics: score = self._calculate_reliability_score(endpoint_name) if score > best_score: best_score = score best_endpoint = endpoint_name - - return best_endpoint \ No newline at end of file + + return best_endpoint diff --git a/sumologic_mcp/main.py b/sumologic_mcp/main.py index 34ad6c0..63ba356 100644 --- a/sumologic_mcp/main.py +++ b/sumologic_mcp/main.py @@ -7,19 +7,20 @@ import sys from pathlib import Path from typing import Optional + import structlog from pydantic import ValidationError from .config import SumoLogicConfig -from .server import SumoLogicMCPServer from .exceptions import SumoLogicError +from .server import SumoLogicMCPServer def setup_logging(config: SumoLogicConfig) -> None: """Set up structured logging based on configuration.""" # Set the root logger level logging.basicConfig(level=getattr(logging, config.log_level)) - + processors = [ structlog.stdlib.filter_by_level, structlog.stdlib.add_logger_name, @@ -29,12 +30,12 @@ def setup_logging(config: SumoLogicConfig) -> None: structlog.processors.StackInfoRenderer(), structlog.processors.format_exc_info, ] - + if config.log_format == "json": processors.append(structlog.processors.JSONRenderer()) else: processors.append(structlog.dev.ConsoleRenderer()) - + structlog.configure( processors=processors, wrapper_class=structlog.stdlib.BoundLogger, @@ -73,39 +74,61 @@ def parse_arguments() -> argparse.Namespace: # Start server with text logging format SUMOLOGIC_LOG_FORMAT=text python -m sumologic_mcp - """ + """, ) - + parser.add_argument( "--config-file", type=Path, - help="Path to configuration file (optional, environment variables take precedence)" + help="Path to configuration file (optional, environment variables take precedence)", ) - + parser.add_argument( - "--validate-config", - action="store_true", - help="Validate configuration and exit" + "--validate-config", action="store_true", help="Validate configuration and exit" ) - + parser.add_argument( "--log-level", choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], - help="Override log level from environment" + help="Override log level from environment", ) - + parser.add_argument( "--log-format", choices=["json", "text"], - help="Override log format from environment" + help="Override log format from environment", ) - - parser.add_argument( - "--version", - action="version", - version="%(prog)s 0.1.0" + + parser.add_argument("--version", action="version", version="%(prog)s 0.1.0") + + # Subcommands + subparsers = parser.add_subparsers(dest="command", help="Subcommands") + + # 'api' subcommand + api_parser = subparsers.add_parser( + "api", help="Execute a raw HTTP request to any Sumo Logic API endpoint" + ) + api_parser.add_argument( + "-X", + "--method", + choices=["GET", "POST", "PUT", "DELETE", "PATCH"], + default="GET", + help="HTTP Method (default: GET)", + ) + api_parser.add_argument("path", help="API endpoint path (e.g., /v1/users)") + api_parser.add_argument( + "-b", "--body", help="JSON string body for POST/PUT/PATCH/DELETE requests" + ) + api_parser.add_argument( + "-p", "--param", action="append", help="Query parameters in KEY=VALUE format" + ) + api_parser.add_argument( + "-H", + "--header", + action="append", + help="Custom headers in KEY=VALUE or KEY:VALUE format", ) - + return parser.parse_args() @@ -114,77 +137,91 @@ def load_configuration(args: argparse.Namespace) -> SumoLogicConfig: try: # Load configuration from environment and optional config file config = SumoLogicConfig.from_env_and_file(args.config_file) - + # Override with command-line arguments if provided if args.log_level: config.log_level = args.log_level if args.log_format: config.log_format = args.log_format - + return config - + except ValidationError as e: print("=" * 60, file=sys.stderr) print("CONFIGURATION VALIDATION ERROR", file=sys.stderr) print("=" * 60, file=sys.stderr) - + print("\nThe following configuration errors were found:", file=sys.stderr) for error in e.errors(): field = " -> ".join(str(x) for x in error["loc"]) message = error["msg"] print(f" ❌ {field}: {message}", file=sys.stderr) - + print("\n" + "=" * 60, file=sys.stderr) print("CONFIGURATION HELP", file=sys.stderr) print("=" * 60, file=sys.stderr) - + print("\n1. Required Environment Variables:", file=sys.stderr) print(" export SUMOLOGIC_ACCESS_ID='your_access_id_here'", file=sys.stderr) print(" export SUMOLOGIC_ACCESS_KEY='your_access_key_here'", file=sys.stderr) - print(" export SUMOLOGIC_ENDPOINT='https://api.sumologic.com'", file=sys.stderr) - + print( + " export SUMOLOGIC_ENDPOINT='https://api.sumologic.com'", file=sys.stderr + ) + print("\n2. Optional Environment Variables:", file=sys.stderr) print(" export SUMOLOGIC_TIMEOUT=30", file=sys.stderr) print(" export SUMOLOGIC_MAX_RETRIES=3", file=sys.stderr) print(" export SUMOLOGIC_RATE_LIMIT_DELAY=1.0", file=sys.stderr) print(" export SUMOLOGIC_LOG_LEVEL=INFO", file=sys.stderr) print(" export SUMOLOGIC_LOG_FORMAT=json", file=sys.stderr) - + if args.config_file: print(f"\n3. Configuration File: {args.config_file}", file=sys.stderr) - print(" Note: Environment variables take precedence over config file values.", file=sys.stderr) + print( + " Note: Environment variables take precedence over config file values.", + file=sys.stderr, + ) else: print("\n3. Alternative: Use a JSON configuration file", file=sys.stderr) print(" Create config.json with:", file=sys.stderr) - print(' {', file=sys.stderr) + print(" {", file=sys.stderr) print(' "access_id": "your_access_id",', file=sys.stderr) print(' "access_key": "your_access_key",', file=sys.stderr) print(' "endpoint": "https://api.sumologic.com"', file=sys.stderr) - print(' }', file=sys.stderr) - print(" Then run: python -m sumologic_mcp --config-file config.json", file=sys.stderr) - + print(" }", file=sys.stderr) + print( + " Then run: python -m sumologic_mcp --config-file config.json", + file=sys.stderr, + ) + print("\n4. Validate Configuration:", file=sys.stderr) print(" python -m sumologic_mcp --validate-config", file=sys.stderr) - + print("\n5. Get Help:", file=sys.stderr) print(" python -m sumologic_mcp --help", file=sys.stderr) - + print("=" * 60, file=sys.stderr) sys.exit(1) - + except FileNotFoundError as e: print(f"❌ Configuration file error: {e}", file=sys.stderr) print("\nEither:", file=sys.stderr) - print(" • Remove --config-file option to use environment variables only", file=sys.stderr) + print( + " • Remove --config-file option to use environment variables only", + file=sys.stderr, + ) print(" • Create the specified configuration file", file=sys.stderr) print(" • Use a different path with --config-file", file=sys.stderr) sys.exit(1) - + except ValueError as e: print(f"❌ Configuration error: {e}", file=sys.stderr) - print("\nPlease check your configuration file format and environment variable values.", file=sys.stderr) + print( + "\nPlease check your configuration file format and environment variable values.", + file=sys.stderr, + ) sys.exit(1) - + except Exception as e: print(f"❌ Unexpected error loading configuration: {e}", file=sys.stderr) print("Please check your configuration and try again.", file=sys.stderr) @@ -196,12 +233,12 @@ def validate_configuration(config: SumoLogicConfig) -> None: print("=" * 60) print("SUMO LOGIC MCP SERVER - CONFIGURATION VALIDATION") print("=" * 60) - + # Get comprehensive validation results validation = config.validate_startup_configuration() - + # Print configuration sources - if hasattr(config, '_config_sources'): + if hasattr(config, "_config_sources"): sources = config._config_sources print("\nConfiguration Sources:") if sources.get("file_loaded"): @@ -212,12 +249,18 @@ def validate_configuration(config: SumoLogicConfig) -> None: defaults = validation["config_sources"]["defaults_used"] if defaults: print(f" ⚙️ Using defaults for: {', '.join(defaults)}") - + # Print current configuration print("\nCurrent Configuration:") - print(f" Access ID: {'✓' if config.access_id else '✗'} {'(configured)' if config.access_id else '(missing)'}") - print(f" Access Key: {'✓' if config.access_key else '✗'} {'(configured)' if config.access_key else '(missing)'}") - print(f" Endpoint: {'✓' if config.endpoint else '✗'} {config.endpoint or '(missing)'}") + print( + f" Access ID: {'✓' if config.access_id else '✗'} {'(configured)' if config.access_id else '(missing)'}" + ) + print( + f" Access Key: {'✓' if config.access_key else '✗'} {'(configured)' if config.access_key else '(missing)'}" + ) + print( + f" Endpoint: {'✓' if config.endpoint else '✗'} {config.endpoint or '(missing)'}" + ) print(f" Timeout: {config.timeout}s") print(f" Max Retries: {config.max_retries}") print(f" Rate Limit Delay: {config.rate_limit_delay}s") @@ -225,13 +268,13 @@ def validate_configuration(config: SumoLogicConfig) -> None: print(f" Log Format: {config.log_format}") print(f" Server Name: {config.server_name}") print(f" Server Version: {config.server_version}") - + # Print validation errors if validation["errors"]: print("\n❌ CONFIGURATION ERRORS:") for error in validation["errors"]: print(f" • {error['field']}: {error['message']}") - + # Print warnings if validation["warnings"]: print("\n⚠️ CONFIGURATION WARNINGS:") @@ -239,7 +282,7 @@ def validate_configuration(config: SumoLogicConfig) -> None: print(f" • {warning['field']}: {warning['message']}") if "recommendation" in warning: print(f" 💡 Recommendation: {warning['recommendation']}") - + # Print recommendations if validation["recommendations"]: print("\n💡 RECOMMENDATIONS:") @@ -247,7 +290,7 @@ def validate_configuration(config: SumoLogicConfig) -> None: print(f" • {rec['field']}: {rec['message']}") if "recommendation" in rec: print(f" {rec['recommendation']}") - + # Print final status print("\n" + "=" * 60) if validation["valid"]: @@ -261,19 +304,21 @@ def validate_configuration(config: SumoLogicConfig) -> None: print(" export SUMOLOGIC_ACCESS_KEY='your_access_key'") print(" export SUMOLOGIC_ENDPOINT='https://api.sumologic.com'") print("\nOr create a config.json file with:") - print(' {"access_id": "your_id", "access_key": "your_key", "endpoint": "https://api.sumologic.com"}') + print( + ' {"access_id": "your_id", "access_key": "your_key", "endpoint": "https://api.sumologic.com"}' + ) sys.exit(1) print("=" * 60) class GracefulShutdown: """Handle graceful shutdown of the server.""" - + def __init__(self): self.shutdown_event = asyncio.Event() self.server: Optional[SumoLogicMCPServer] = None self.logger = structlog.get_logger(__name__) - + def setup_signal_handlers(self) -> None: """Set up signal handlers for graceful shutdown.""" if sys.platform != "win32": @@ -285,36 +330,38 @@ def setup_signal_handlers(self) -> None: # Windows signal.signal(signal.SIGINT, self._windows_signal_handler) signal.signal(signal.SIGTERM, self._windows_signal_handler) - + def signal_handler(self, signum: int) -> None: """Handle shutdown signals.""" self.logger.info(f"Received signal {signum}, initiating graceful shutdown") self.shutdown_event.set() - + def _windows_signal_handler(self, signum: int, frame) -> None: """Handle shutdown signals on Windows.""" self.signal_handler(signum) - + async def shutdown(self) -> None: """Perform graceful shutdown.""" self.logger.info("Starting graceful shutdown") - + if self.server: try: await self.server.shutdown() self.logger.info("Server shutdown completed") except Exception as e: self.logger.error("Error during server shutdown", error=str(e)) - + # Cancel all remaining tasks - tasks = [task for task in asyncio.all_tasks() if task is not asyncio.current_task()] + tasks = [ + task for task in asyncio.all_tasks() if task is not asyncio.current_task() + ] if tasks: self.logger.info(f"Cancelling {len(tasks)} remaining tasks") for task in tasks: task.cancel() - + await asyncio.gather(*tasks, return_exceptions=True) - + self.logger.info("Graceful shutdown completed") @@ -322,25 +369,28 @@ async def main_async(args: argparse.Namespace) -> None: """Async main function.""" # Load configuration config = load_configuration(args) - + # If validation requested, validate and exit if args.validate_config: validate_configuration(config) return - + # Perform startup configuration validation validation = config.validate_startup_configuration() if not validation["valid"]: print("❌ Configuration validation failed:", file=sys.stderr) for error in validation["errors"]: print(f" • {error['field']}: {error['message']}", file=sys.stderr) - print("\nRun with --validate-config for detailed validation information.", file=sys.stderr) + print( + "\nRun with --validate-config for detailed validation information.", + file=sys.stderr, + ) sys.exit(1) - + # Set up logging setup_logging(config) logger = structlog.get_logger(__name__) - + # Log configuration warnings if any if validation["warnings"]: for warning in validation["warnings"]: @@ -348,13 +398,13 @@ async def main_async(args: argparse.Namespace) -> None: "Configuration warning", field=warning["field"], message=warning["message"], - recommendation=warning.get("recommendation") + recommendation=warning.get("recommendation"), ) - + # Set up graceful shutdown shutdown_handler = GracefulShutdown() shutdown_handler.setup_signal_handlers() - + try: logger.info( "Initializing Sumo Logic MCP server", @@ -363,20 +413,20 @@ async def main_async(args: argparse.Namespace) -> None: endpoint=config.endpoint, timeout=config.timeout, max_retries=config.max_retries, - rate_limit_delay=config.rate_limit_delay + rate_limit_delay=config.rate_limit_delay, ) - + # Create and start server server = SumoLogicMCPServer(config) shutdown_handler.server = server - + await server.start() - + logger.info("Sumo Logic MCP server started successfully") - + # Run the MCP server with stdio transport for client communication await server.run_stdio() - + except SumoLogicError as e: logger.error("Sumo Logic specific error", error=str(e)) sys.exit(1) @@ -387,11 +437,106 @@ async def main_async(args: argparse.Namespace) -> None: await shutdown_handler.shutdown() +async def run_api_command(args: argparse.Namespace) -> None: + """Execute a raw API request and print the results.""" + import json + + from .api_client import SumoLogicAPIClient + from .auth import SumoLogicAuth + + # Load configuration + config = load_configuration(args) + + # Validate configuration + validation = config.validate_startup_configuration() + if not validation["valid"]: + print("❌ Configuration validation failed:", file=sys.stderr) + for error in validation["errors"]: + print(f" • {error['field']}: {error['message']}", file=sys.stderr) + sys.exit(1) + + # For CLI api commands, default to WARNING log level to keep stdout/stderr clean, unless explicitly overridden + if not args.log_level: + config.log_level = "WARNING" + + # Set up logging + setup_logging(config) + + # Parse query parameters from -p/--param + params = {} + if args.param: + for p in args.param: + if "=" in p: + k, v = p.split("=", 1) + params[k] = v + else: + params[p] = "" + + # Parse headers from -H/--header + headers = {} + if args.header: + for h in args.header: + if "=" in h: + k, v = h.split("=", 1) + headers[k] = v + elif ":" in h: + k, v = h.split(":", 1) + headers[k.strip()] = v.strip() + else: + headers[h] = "" + + # Parse request body from -b/--body + body = None + if args.body: + try: + body = json.loads(args.body) + except json.JSONDecodeError as e: + print(f"❌ Error: Invalid JSON body: {e}", file=sys.stderr) + sys.exit(1) + + # Instantiate client and make request + auth = SumoLogicAuth(config) + async with SumoLogicAPIClient(config, auth) as client: + try: + result = await client.execute_raw_request( + method=args.method, + path=args.path, + params=params, + body=body, + headers=headers, + ) + + status_code = result["status_code"] + response_body = result["body"] + + if 200 <= status_code < 300: + if isinstance(response_body, (dict, list)): + print(json.dumps(response_body, indent=2)) + elif response_body: + print(response_body) + else: + print(f"Status: {status_code} OK (No Content)", file=sys.stderr) + else: + print(f"❌ Request failed with status {status_code}", file=sys.stderr) + if isinstance(response_body, (dict, list)): + print(json.dumps(response_body, indent=2), file=sys.stderr) + elif response_body: + print(response_body, file=sys.stderr) + sys.exit(1) + + except Exception as e: + print(f"❌ Error executing request: {e}", file=sys.stderr) + sys.exit(1) + + def main() -> None: """Main entry point for the server.""" try: args = parse_arguments() - asyncio.run(main_async(args)) + if args.command == "api": + asyncio.run(run_api_command(args)) + else: + asyncio.run(main_async(args)) except KeyboardInterrupt: print("\nShutdown complete") sys.exit(0) @@ -401,4 +546,4 @@ def main() -> None: if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/sumologic_mcp/server.py b/sumologic_mcp/server.py index f3ba6ad..d1e0471 100644 --- a/sumologic_mcp/server.py +++ b/sumologic_mcp/server.py @@ -8,42 +8,44 @@ import asyncio import logging -from typing import Dict, Any, List, Optional, Callable +from typing import Any, Callable, Dict, List, Optional + import structlog from mcp.server import Server from mcp.server.stdio import stdio_server from mcp.types import Tool -from .config import SumoLogicConfig -from .auth import SumoLogicAuth from .api_client import SumoLogicAPIClient -from .tools.search_tools import SearchTools +from .auth import SumoLogicAuth +from .config import SumoLogicConfig +from .error_handler import ErrorHandler +from .exceptions import APIError, SumoLogicError, ValidationError +from .monitoring import MonitoringManager +from .tools.api_tools import APITools +from .tools.collector_tools import CollectorTools from .tools.dashboard_tools import DashboardTools from .tools.metrics_tools import MetricsTools -from .tools.collector_tools import CollectorTools from .tools.monitor_tools import MonitorTools -from .exceptions import SumoLogicError, ValidationError, APIError -from .error_handler import ErrorHandler -from .monitoring import MonitoringManager +from .tools.search_tools import SearchTools logger = structlog.get_logger(__name__) class SumoLogicMCPServer: """Main MCP server class for Sumo Logic integration.""" - + def __init__(self, config: SumoLogicConfig): """Initialize the MCP server with configuration. - + Args: config: SumoLogicConfig instance with server configuration """ self.config = config self.logger = logger.bind(server_name=config.server_name) - + # Initialize MCP server self.mcp_server = Server(config.server_name) - + # Initialize components self.auth: Optional[SumoLogicAuth] = None self.api_client: Optional[SumoLogicAPIClient] = None @@ -52,25 +54,25 @@ def __init__(self, config: SumoLogicConfig): self.metrics_tools: Optional[MetricsTools] = None self.collector_tools: Optional[CollectorTools] = None self.monitor_tools: Optional[MonitorTools] = None - + self.api_tools: Optional[APITools] = None + # Tool registry self.tool_handlers: Dict[str, Callable] = {} - + # Initialize error handler and monitoring self.error_handler = ErrorHandler(config.server_name) self.monitoring_manager = MonitoringManager() - + # Configure logging self._configure_logging() - + def _configure_logging(self) -> None: """Configure structured logging for the server.""" # Use centralized logging configuration ErrorHandler.configure_logging( - log_level=self.config.log_level, - log_format=self.config.log_format + log_level=self.config.log_level, log_format=self.config.log_format ) - + async def start(self) -> None: """Initialize and start the MCP server.""" try: @@ -78,165 +80,166 @@ async def start(self) -> None: "Starting Sumo Logic MCP server", version=self.config.server_version, endpoint=self.config.endpoint, - log_level=self.config.log_level + log_level=self.config.log_level, ) - + # Initialize authentication (defer actual authentication until first use) self.auth = SumoLogicAuth(self.config) # Note: We don't authenticate here to allow the server to start even with invalid credentials # Authentication will happen on first API call - + # Initialize API client self.api_client = SumoLogicAPIClient(self.config, self.auth) - + # Initialize tool handlers self.search_tools = SearchTools(self.api_client) self.dashboard_tools = DashboardTools(self.api_client) self.metrics_tools = MetricsTools(self.api_client) self.collector_tools = CollectorTools(self.api_client) self.monitor_tools = MonitorTools(self.api_client) - + self.api_tools = APITools(self.api_client) + # Start monitoring await self.monitoring_manager.start() - + # Register connection monitoring for Sumo Logic API await self.monitoring_manager.connection_monitor.register_connection( name="sumologic_api", endpoint=self.config.endpoint, - check_func=self._check_api_connection + check_func=self._check_api_connection, ) - + # Register health checks await self._register_health_checks() - + # Register all tools self.register_tools() - + # Register health check tool self._register_health_check_tool() - + self.logger.info( "Sumo Logic MCP server started successfully", - tools_registered=len(self.tool_handlers) + tools_registered=len(self.tool_handlers), ) - + except Exception as e: self.logger.error( - "Failed to start MCP server", - error=str(e), - error_type=type(e).__name__ + "Failed to start MCP server", error=str(e), error_type=type(e).__name__ ) raise - - async def handle_tool_call(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]: + + async def handle_tool_call( + self, tool_name: str, arguments: Dict[str, Any] + ) -> Dict[str, Any]: """Route tool calls to appropriate handlers. - + Args: tool_name: Name of the tool to execute arguments: Tool arguments - + Returns: Dict with tool execution result (workaround for MCP SDK bug) """ start_time = asyncio.get_event_loop().time() - + # Log incoming request self.error_handler.log_request(tool_name, arguments) - + try: # Check if tool exists if tool_name not in self.tool_handlers: error_msg = f"Unknown tool: {tool_name}" - self.logger.error(error_msg, available_tools=list(self.tool_handlers.keys())) + self.logger.error( + error_msg, available_tools=list(self.tool_handlers.keys()) + ) # Return dict directly to avoid MCP SDK serialization bug return { "content": [ { "type": "text", "text": f"Error: {error_msg}. Available tools: {', '.join(self.tool_handlers.keys())}", - "annotations": None + "annotations": None, } ], - "isError": True + "isError": True, } - + # Get tool handler handler = self.tool_handlers[tool_name] - + # Execute tool result = await handler(**arguments) - + # Calculate execution time execution_time = (asyncio.get_event_loop().time() - start_time) * 1000 - + # Log successful response response_text = self._format_tool_result(result) self.error_handler.log_response( - tool_name, - success=True, + tool_name, + success=True, execution_time_ms=execution_time, - response_size=len(response_text) + response_size=len(response_text), ) - + # Return dict directly to avoid MCP SDK serialization bug return { "content": [ - { - "type": "text", - "text": response_text, - "annotations": None - } + {"type": "text", "text": response_text, "annotations": None} ], - "isError": False + "isError": False, } - + except Exception as e: # Calculate execution time for error case execution_time = (asyncio.get_event_loop().time() - start_time) * 1000 - + # Log error response self.error_handler.log_response( - tool_name, - success=False, - execution_time_ms=execution_time + tool_name, success=False, execution_time_ms=execution_time ) - + # Use centralized error handler return self.error_handler.handle_tool_error( error=e, tool_name=tool_name, arguments=arguments, - execution_time_ms=execution_time + execution_time_ms=execution_time, ) - + def register_tools(self) -> None: """Register all available Sumo Logic tools.""" self.logger.info("Registering MCP tools") - + try: # Collect all tool definitions all_tools = [] - + # Get search tools search_tool_defs = self.search_tools.get_tool_definitions() all_tools.extend(search_tool_defs) - - # Get dashboard tools + + # Get dashboard tools dashboard_tool_defs = self.dashboard_tools.get_tool_definitions() all_tools.extend(dashboard_tool_defs) - + # Get metrics tools metrics_tool_defs = self.metrics_tools.get_tool_definitions() all_tools.extend(metrics_tool_defs) - + # Get collector tools collector_tool_defs = self.collector_tools.get_tool_definitions() all_tools.extend(collector_tool_defs) - + # Get monitor tools monitor_tool_defs = self.monitor_tools.get_tool_definitions() all_tools.extend(monitor_tool_defs) - + + # Get api tools + api_tool_defs = self.api_tools.get_tool_definitions() + all_tools.extend(api_tool_defs) + # Add health check tool health_check_tool = { "name": "health_check", @@ -247,23 +250,23 @@ def register_tools(self) -> None: "include_metrics": { "type": "boolean", "description": "Include detailed metrics in the response", - "default": False + "default": False, }, "include_connections": { - "type": "boolean", + "type": "boolean", "description": "Include connection status in the response", - "default": True - } + "default": True, + }, }, - "additionalProperties": False - } + "additionalProperties": False, + }, } all_tools.append(health_check_tool) - + # Register tool handlers for tool_def in all_tools: tool_name = tool_def["name"] - + # Find the appropriate tool class and register handler if tool_name in [t["name"] for t in search_tool_defs]: handler = getattr(self.search_tools, tool_name) @@ -275,24 +278,27 @@ def register_tools(self) -> None: handler = getattr(self.collector_tools, tool_name) elif tool_name in [t["name"] for t in monitor_tool_defs]: handler = getattr(self.monitor_tools, tool_name) + elif tool_name in [t["name"] for t in api_tool_defs]: + handler = getattr(self.api_tools, tool_name) else: continue - + self.tool_handlers[tool_name] = handler - + # Register the list_tools handler with MCP server @self.mcp_server.list_tools() async def list_tools_handler(): from mcp.types import Tool + return [ Tool( name=tool_def["name"], description=tool_def["description"], - inputSchema=tool_def["inputSchema"] + inputSchema=tool_def["inputSchema"], ) for tool_def in all_tools ] - + self.logger.info( "All MCP tools registered successfully", total_tools=len(all_tools), @@ -300,38 +306,36 @@ async def list_tools_handler(): dashboard_tools=len(dashboard_tool_defs), metrics_tools=len(metrics_tool_defs), collector_tools=len(collector_tool_defs), - monitor_tools=len(monitor_tool_defs) + monitor_tools=len(monitor_tool_defs), + api_tools=len(api_tool_defs), ) - + except Exception as e: self.logger.error( - "Failed to register tools", - error=str(e), - error_type=type(e).__name__ + "Failed to register tools", error=str(e), error_type=type(e).__name__ ) raise - - def _format_tool_result(self, result: Any) -> str: """Format tool execution result for MCP response. - + Args: result: Tool execution result - + Returns: Formatted string representation of the result """ try: import json + return json.dumps(result, indent=2, default=str) except Exception: return str(result) - + async def run_stdio(self) -> None: """Run the MCP server using stdio transport.""" self.logger.info("Starting MCP server with stdio transport") - + # Set up tool call handler @self.mcp_server.call_tool() async def handle_call_tool(name: str, arguments: dict): @@ -349,25 +353,25 @@ async def handle_call_tool(name: str, arguments: dict): { "type": "text", "text": f"Internal error: {str(e)}", - "annotations": None + "annotations": None, } ], - "isError": True + "isError": True, } - + # Run server with stdio async with stdio_server() as (read_stream, write_stream): await self.mcp_server.run( read_stream, write_stream, - self.mcp_server.create_initialization_options() + self.mcp_server.create_initialization_options(), ) - + async def _check_api_connection(self) -> None: """Check connection to Sumo Logic API.""" if not self.api_client: raise SumoLogicError("API client not initialized") - + # Try to make a simple API call to check connectivity try: # Use a lightweight endpoint to check connectivity @@ -375,51 +379,51 @@ async def _check_api_connection(self) -> None: method="GET", endpoint="/api/v1/collectors", params={"limit": 1}, - operation_type="health_check" + operation_type="health_check", ) except Exception as e: raise SumoLogicError(f"API connection check failed: {e}") from e - + async def _register_health_checks(self) -> None: """Register health checks for the server.""" # API client health check await self.monitoring_manager.health_checker.register_health_check( "api_client", self._check_api_client_health, - "Check if API client is healthy and responsive" + "Check if API client is healthy and responsive", ) - + # Authentication health check await self.monitoring_manager.health_checker.register_health_check( "authentication", self._check_authentication_health, - "Check if authentication is valid and working" + "Check if authentication is valid and working", ) - + # Tool handlers health check await self.monitoring_manager.health_checker.register_health_check( "tool_handlers", self._check_tool_handlers_health, - "Check if all tool handlers are properly initialized" + "Check if all tool handlers are properly initialized", ) - + async def _check_api_client_health(self) -> None: """Health check for API client.""" if not self.api_client: raise SumoLogicError("API client not initialized") - + # Get API client health status health_status = await self.api_client.get_health_status() - + # Check if resilience components are healthy if health_status["resilience"]["health"] == "unhealthy": raise SumoLogicError("API client resilience components are unhealthy") - + async def _check_authentication_health(self) -> None: """Health check for authentication.""" if not self.auth: raise SumoLogicError("Authentication not initialized") - + # Check if authentication is valid (but don't fail if not authenticated yet) try: await self.auth.get_auth_headers() @@ -428,7 +432,7 @@ async def _check_authentication_health(self) -> None: # Authentication will be attempted on first API call self.logger.warning(f"Authentication not yet validated: {e}") # Don't raise an error here - just log the warning - + async def _check_tool_handlers_health(self) -> None: """Health check for tool handlers.""" required_tools = [ @@ -436,112 +440,110 @@ async def _check_tool_handlers_health(self) -> None: self.dashboard_tools, self.metrics_tools, self.collector_tools, - self.monitor_tools + self.monitor_tools, ] - + for tool in required_tools: if tool is None: - raise SumoLogicError(f"Tool handler {type(tool).__name__} not initialized") - + raise SumoLogicError( + f"Tool handler {type(tool).__name__} not initialized" + ) + if not self.tool_handlers: raise SumoLogicError("No tool handlers registered") - + def _register_health_check_tool(self) -> None: """Register the health check tool.""" # The health check tool is now registered in the main register_tools method # Just register the handler here self.tool_handlers["health_check"] = self._handle_health_check - + async def _handle_health_check( - self, - include_metrics: bool = False, - include_connections: bool = True + self, include_metrics: bool = False, include_connections: bool = True ) -> Dict[str, Any]: """Handle health check tool call. - + Args: include_metrics: Whether to include detailed metrics include_connections: Whether to include connection status - + Returns: Comprehensive health status """ try: # Get comprehensive monitoring status status = await self.monitoring_manager.get_comprehensive_status() - + # Add server-specific information status["server"] = { "name": self.config.server_name, "version": self.config.server_version, "endpoint": self.config.endpoint, "tools_registered": len(self.tool_handlers), - "uptime_info": "Server is running" # Could be enhanced with actual uptime + "uptime_info": "Server is running", # Could be enhanced with actual uptime } - + # Optionally exclude metrics for lighter response if not include_metrics: status.pop("metrics", None) - + # Optionally exclude connections if not include_connections: status.pop("connections", None) - + return status - + except Exception as e: self.logger.error( - "Health check failed", - error=str(e), - error_type=type(e).__name__ + "Health check failed", error=str(e), error_type=type(e).__name__ ) - + return { "timestamp": "error", "health": { "status": "unhealthy", "message": f"Health check failed: {str(e)}", - "checks": {} + "checks": {}, }, "server": { "name": self.config.server_name, "version": self.config.server_version, - "error": str(e) - } + "error": str(e), + }, } async def shutdown(self) -> None: """Shutdown the MCP server and clean up resources.""" self.logger.info("Shutting down Sumo Logic MCP server") - + try: # Stop monitoring if self.monitoring_manager: await self.monitoring_manager.stop() - + # Close API client if self.api_client: await self.api_client.close() - + # Logout from authentication if self.auth: await self.auth.logout() - + self.logger.info("MCP server shutdown completed") - + except Exception as e: self.logger.error( "Error during server shutdown", error=str(e), - error_type=type(e).__name__ + error_type=type(e).__name__, ) raise - + async def __aenter__(self): """Async context manager entry.""" await self.start() return self - + async def __aexit__(self, exc_type, exc_val, exc_tb): """Async context manager exit.""" - await self.shutdown() \ No newline at end of file + await self.shutdown() diff --git a/sumologic_mcp/tools/__init__.py b/sumologic_mcp/tools/__init__.py index 5352fc5..2c11a3f 100644 --- a/sumologic_mcp/tools/__init__.py +++ b/sumologic_mcp/tools/__init__.py @@ -1,15 +1,17 @@ """MCP tool handlers for Sumo Logic operations.""" -from .search_tools import SearchTools +from .api_tools import APITools +from .collector_tools import CollectorTools from .dashboard_tools import DashboardTools from .metrics_tools import MetricsTools -from .collector_tools import CollectorTools from .monitor_tools import MonitorTools +from .search_tools import SearchTools __all__ = [ "SearchTools", - "DashboardTools", + "DashboardTools", "MetricsTools", "CollectorTools", - "MonitorTools" -] \ No newline at end of file + "MonitorTools", + "APITools", +] diff --git a/sumologic_mcp/tools/api_tools.py b/sumologic_mcp/tools/api_tools.py new file mode 100644 index 0000000..9a5030b --- /dev/null +++ b/sumologic_mcp/tools/api_tools.py @@ -0,0 +1,121 @@ +""" +API tools for Sumo Logic MCP server. + +This module implements the generic api_call MCP tool to hit any raw Sumo Logic API endpoint. +""" + +import logging +from typing import Any, Dict, List, Optional + +from ..api_client import SumoLogicAPIClient +from ..exceptions import ValidationError + +logger = logging.getLogger(__name__) + + +class APITools: + """MCP tools for generic Sumo Logic API operations.""" + + def __init__(self, api_client: SumoLogicAPIClient): + """Initialize APITools with API client. + + Args: + api_client: Configured SumoLogicAPIClient instance + """ + self.api_client = api_client + + async def api_call( + self, + method: str, + path: str, + params: Optional[Dict[str, Any]] = None, + body: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + ) -> Dict[str, Any]: + """Execute a raw HTTP request to any Sumo Logic API endpoint. + + This tool executes a raw HTTP request (GET, POST, PUT, DELETE, PATCH) + against the specified Sumo Logic API endpoint using the configured credentials. + + Args: + method: HTTP method to use (GET, POST, PUT, DELETE, PATCH) + path: The API endpoint path (e.g. '/v1/users', '/v1/folders/0000000000000002') + params: Optional query parameters to include in the URL + body: Optional JSON request body for POST/PUT/PATCH/DELETE requests + headers: Optional custom headers to include in the request + + Returns: + Dict containing response status_code, headers, and parsed body. + + Raises: + ValidationError: If path or method is invalid + APIError: If the request execution fails + """ + # Validate path and method + if not path or not isinstance(path, str): + raise ValidationError( + "Path must be a non-empty string", field_name="path", field_value=path + ) + + valid_methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] + if not method or method.upper() not in valid_methods: + raise ValidationError( + f"Method must be one of: {', '.join(valid_methods)}", + field_name="method", + field_value=method, + ) + + logger.info(f"Executing raw API call: {method.upper()} {path}") + + return await self.api_client.execute_raw_request( + method=method, path=path, params=params, body=body, headers=headers + ) + + def get_tool_definitions(self) -> List[Dict[str, Any]]: + """Get MCP tool definitions for API operations. + + Returns: + List of tool definitions for MCP server registration + """ + return [ + { + "name": "api_call", + "description": ( + "Execute a raw, authenticated HTTP request to any Sumo Logic API endpoint. " + "Use this tool when you need to access Sumo Logic API endpoints that do not " + "have dedicated MCP tools (e.g. user/role management, folders, partitions, " + "connections, ingestion keys, content import/export, etc.)." + ), + "inputSchema": { + "type": "object", + "properties": { + "method": { + "type": "string", + "description": "HTTP method to use (GET, POST, PUT, DELETE, PATCH)", + "enum": ["GET", "POST", "PUT", "DELETE", "PATCH"], + }, + "path": { + "type": "string", + "description": ( + "The API endpoint path starting with a slash (e.g., '/v1/users', " + "'/v1/folders/0000000000000002'). Do not include the base URL." + ), + }, + "params": { + "type": "object", + "description": "Optional query parameters as key-value pairs", + }, + "body": { + "type": "object", + "description": "Optional JSON request body for POST/PUT/PATCH/DELETE requests", + }, + "headers": { + "type": "object", + "description": "Optional custom headers as key-value pairs", + "additionalProperties": {"type": "string"}, + }, + }, + "required": ["method", "path"], + }, + } + ] diff --git a/tests/test_api_tools.py b/tests/test_api_tools.py new file mode 100644 index 0000000..b1605c9 --- /dev/null +++ b/tests/test_api_tools.py @@ -0,0 +1,74 @@ +"""Unit tests for the APITools class and execute_raw_request method.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from sumologic_mcp.api_client import SumoLogicAPIClient +from sumologic_mcp.exceptions import ValidationError +from sumologic_mcp.tools.api_tools import APITools + + +@pytest.fixture +def mock_api_client(): + client = MagicMock(spec=SumoLogicAPIClient) + client.execute_raw_request = AsyncMock() + return client + + +class TestAPITools: + @pytest.mark.asyncio + async def test_api_call_valid_get(self, mock_api_client): + tools = APITools(mock_api_client) + + mock_response = { + "status_code": 200, + "headers": {"content-type": "application/json"}, + "body": {"status": "ok"}, + } + mock_api_client.execute_raw_request.return_value = mock_response + + result = await tools.api_call( + method="GET", + path="/v1/users", + params={"limit": 5}, + headers={"Custom-Header": "value"}, + ) + + assert result == mock_response + mock_api_client.execute_raw_request.assert_called_once_with( + method="GET", + path="/v1/users", + params={"limit": 5}, + body=None, + headers={"Custom-Header": "value"}, + ) + + @pytest.mark.asyncio + async def test_api_call_invalid_method(self, mock_api_client): + tools = APITools(mock_api_client) + + with pytest.raises(ValidationError) as excinfo: + await tools.api_call(method="INVALID", path="/v1/users") + + assert "Method must be one of" in str(excinfo.value) + mock_api_client.execute_raw_request.assert_not_called() + + @pytest.mark.asyncio + async def test_api_call_empty_path(self, mock_api_client): + tools = APITools(mock_api_client) + + with pytest.raises(ValidationError) as excinfo: + await tools.api_call(method="GET", path="") + + assert "Path must be a non-empty string" in str(excinfo.value) + mock_api_client.execute_raw_request.assert_not_called() + + def test_get_tool_definitions(self, mock_api_client): + tools = APITools(mock_api_client) + defs = tools.get_tool_definitions() + + assert len(defs) == 1 + assert defs[0]["name"] == "api_call" + assert "method" in defs[0]["inputSchema"]["properties"] + assert "path" in defs[0]["inputSchema"]["properties"] From f51e43385cd1199ebed6255b5a56165cf0a3dde8 Mon Sep 17 00:00:00 2001 From: "a.key" Date: Tue, 16 Jun 2026 18:37:45 +0100 Subject: [PATCH 12/14] fix: resolve monitor tools scope issue and handle missing history endpoint gracefully - Add nonlocal search_fields declaration inside _search_monitors_impl to prevent UnboundLocalError - Handle 404 in get_monitor_history gracefully by returning empty data and warning metadata since public Sumo Logic REST APIs do not support it - Make endpoint validation in config.py lenient by stripping trailing /api, /api/v1, /api/v2 paths and trailing slashes - Accept list response formats for search alerts fallback without triggering warning - Add unit tests in test_config.py --- sumologic_mcp/api_client.py | 31 +- sumologic_mcp/config.py | 416 +- sumologic_mcp/tools/monitor_tools.py | 5542 +++++++++++++++----------- tests/test_config.py | 56 + 4 files changed, 3416 insertions(+), 2629 deletions(-) create mode 100644 tests/test_config.py diff --git a/sumologic_mcp/api_client.py b/sumologic_mcp/api_client.py index 55ef720..7f7dee1 100644 --- a/sumologic_mcp/api_client.py +++ b/sumologic_mcp/api_client.py @@ -3420,13 +3420,16 @@ async def get_monitor_history( except APIError as e: if e.status_code == 404: - raise APIError( - f"Monitor not found: {monitor_id}", - status_code=404, - response_body=e.response_body, - request_id=e.request_id, - context={"monitor_id": monitor_id}, - ) from e + logger.warning( + f"Monitor history endpoint returned 404 for monitor {monitor_id}. " + "This endpoint is not supported by the public Sumo Logic REST APIs in this deployment.", + extra={"monitor_id": monitor_id}, + ) + return { + "data": [], + "total": 0, + "warning": "Monitor execution history is not publicly supported by the Sumo Logic API.", + } elif e.status_code == 403: raise APIError( f"Insufficient permissions to access monitor history: {monitor_id}", @@ -3744,17 +3747,15 @@ async def _process_alerts_response( """ endpoint_name = endpoint_config["name"] - # Ensure response_data is a dictionary - if not isinstance(response_data, dict): + # Ensure response_data is a dictionary or a list + if not isinstance(response_data, (dict, list)): logger.warning( - f"Unexpected response format from {endpoint_name}: expected dict, got {type(response_data)}" + f"Unexpected response format from {endpoint_name}: expected dict or list, got {type(response_data)}" ) + response_data = {"data": []} + elif isinstance(response_data, list): # If response_data is a list, treat it as the data array - if isinstance(response_data, list): - response_data = {"data": response_data} - else: - # For other types, return empty result - response_data = {"data": []} + response_data = {"data": response_data} # Handle different response formats based on endpoint type if "search" in endpoint_name: diff --git a/sumologic_mcp/config.py b/sumologic_mcp/config.py index 49a58dc..8406bb2 100644 --- a/sumologic_mcp/config.py +++ b/sumologic_mcp/config.py @@ -3,9 +3,10 @@ import json import os from pathlib import Path -from typing import Optional, Dict, Any -from pydantic import BaseModel, Field, field_validator, ValidationError +from typing import Any, Dict, Optional + from dotenv import load_dotenv +from pydantic import BaseModel, Field, ValidationError, field_validator # Load environment variables from .env file if it exists load_dotenv() @@ -13,68 +14,42 @@ class SumoLogicConfig(BaseModel): """Configuration for Sumo Logic MCP server.""" - + # Sumo Logic API credentials (supports both new and reference environment variables). # Environment loading is handled explicitly in from_env/from_env_and_file. - access_id: str = Field( - ..., - description="Sumo Logic Access ID" - ) - access_key: str = Field( - ..., - description="Sumo Logic Access Key" - ) - endpoint: str = Field( - ..., - description="Sumo Logic API endpoint" - ) - + access_id: str = Field(..., description="Sumo Logic Access ID") + access_key: str = Field(..., description="Sumo Logic Access Key") + endpoint: str = Field(..., description="Sumo Logic API endpoint") + # Server configuration - timeout: int = Field( - default=30, - description="Request timeout in seconds" - ) - max_retries: int = Field( - default=3, - description="Maximum retry attempts" - ) + timeout: int = Field(default=30, description="Request timeout in seconds") + max_retries: int = Field(default=3, description="Maximum retry attempts") rate_limit_delay: float = Field( - default=1.0, - description="Delay between rate-limited requests in seconds" + default=1.0, description="Delay between rate-limited requests in seconds" ) - + # Logging configuration - log_level: str = Field( - default="INFO", - description="Logging level" - ) - log_format: str = Field( - default="json", - description="Log format (json or text)" - ) - + log_level: str = Field(default="INFO", description="Logging level") + log_format: str = Field(default="json", description="Log format (json or text)") + # MCP server configuration server_name: str = Field( - default="sumologic-mcp-server", - description="MCP server name" + default="sumologic-mcp-server", description="MCP server name" ) - server_version: str = Field( - default="0.1.0", - description="MCP server version" - ) - + server_version: str = Field(default="0.1.0", description="MCP server version") + # Reference implementation compatibility query_timeout: int = Field( default=300, - description="Query timeout for compatibility with reference implementation" + description="Query timeout for compatibility with reference implementation", ) max_results: int = Field( default=1000, - description="Maximum results for compatibility with reference implementation" + description="Maximum results for compatibility with reference implementation", ) default_vmware_source: str = Field( default="otel/vmware", - description="Default VMware source category for metrics exploration" + description="Default VMware source category for metrics exploration", ) @field_validator("endpoint") @@ -83,10 +58,18 @@ def validate_endpoint(cls, v: str) -> str: """Validate Sumo Logic endpoint URL.""" if not v.startswith(("http://", "https://")): raise ValueError("Endpoint must be a valid HTTP/HTTPS URL") - if not v.endswith(".sumologic.com"): + + # Clean any trailing paths (like /api, /api/v1, /api/v2) and slashes + clean_v = v.rstrip("/") + for suffix in ["/api/v1", "/api/v2", "/api"]: + if clean_v.endswith(suffix): + clean_v = clean_v[: -len(suffix)].rstrip("/") + + if not clean_v.endswith(".sumologic.com"): raise ValueError("Endpoint must be a valid Sumo Logic domain") - return v.rstrip("/") - + + return clean_v + @field_validator("log_level") @classmethod def validate_log_level(cls, v: str) -> str: @@ -96,7 +79,7 @@ def validate_log_level(cls, v: str) -> str: if v_upper not in valid_levels: raise ValueError(f"Log level must be one of: {', '.join(valid_levels)}") return v_upper - + @field_validator("log_format") @classmethod def validate_log_format(cls, v: str) -> str: @@ -106,7 +89,7 @@ def validate_log_format(cls, v: str) -> str: if v_lower not in valid_formats: raise ValueError(f"Log format must be one of: {', '.join(valid_formats)}") return v_lower - + @field_validator("timeout") @classmethod def validate_timeout(cls, v: int) -> int: @@ -116,7 +99,7 @@ def validate_timeout(cls, v: int) -> int: if v > 300: # 5 minutes max raise ValueError("Timeout must not exceed 300 seconds") return v - + @field_validator("max_retries") @classmethod def validate_max_retries(cls, v: int) -> int: @@ -126,7 +109,7 @@ def validate_max_retries(cls, v: int) -> int: if v > 10: raise ValueError("Max retries should not exceed 10") return v - + @field_validator("rate_limit_delay") @classmethod def validate_rate_limit_delay(cls, v: float) -> float: @@ -136,7 +119,7 @@ def validate_rate_limit_delay(cls, v: float) -> float: if v > 60: # 1 minute max raise ValueError("Rate limit delay should not exceed 60 seconds") return v - + @field_validator("query_timeout") @classmethod def validate_query_timeout(cls, v: int) -> int: @@ -146,7 +129,7 @@ def validate_query_timeout(cls, v: int) -> int: if v > 3600: # 1 hour max raise ValueError("Query timeout must not exceed 3600 seconds (1 hour)") return v - + @field_validator("max_results") @classmethod def validate_max_results(cls, v: int) -> int: @@ -156,7 +139,7 @@ def validate_max_results(cls, v: int) -> int: if v > 100000: # 100k max raise ValueError("Max results should not exceed 100,000") return v - + @field_validator("default_vmware_source") @classmethod def validate_default_vmware_source(cls, v: str) -> str: @@ -168,42 +151,65 @@ def validate_default_vmware_source(cls, v: str) -> str: @classmethod def from_env(cls) -> "SumoLogicConfig": """Create configuration from environment variables. - + Supports both new environment variables and reference implementation variables for backward compatibility. Reference variables take precedence if both are set. """ + # Helper function to get environment variable with fallback - def get_env_with_fallback(primary: str, fallback: str, default: str = "") -> str: + def get_env_with_fallback( + primary: str, fallback: str, default: str = "" + ) -> str: return os.getenv(primary) or os.getenv(fallback) or default - + return cls( # Support both SUMOLOGIC_* and SUMO_* (reference) environment variables access_id=get_env_with_fallback("SUMOLOGIC_ACCESS_ID", "SUMO_ACCESS_ID"), access_key=get_env_with_fallback("SUMOLOGIC_ACCESS_KEY", "SUMO_ACCESS_KEY"), endpoint=get_env_with_fallback("SUMOLOGIC_ENDPOINT", "SUMO_ENDPOINT"), timeout=int(get_env_with_fallback("SUMOLOGIC_TIMEOUT", "TIMEOUT", "30")), - max_retries=int(get_env_with_fallback("SUMOLOGIC_MAX_RETRIES", "MAX_RETRIES", "3")), - rate_limit_delay=float(get_env_with_fallback("SUMOLOGIC_RATE_LIMIT_DELAY", "RATE_LIMIT_DELAY", "1.0")), + max_retries=int( + get_env_with_fallback("SUMOLOGIC_MAX_RETRIES", "MAX_RETRIES", "3") + ), + rate_limit_delay=float( + get_env_with_fallback( + "SUMOLOGIC_RATE_LIMIT_DELAY", "RATE_LIMIT_DELAY", "1.0" + ) + ), log_level=get_env_with_fallback("SUMOLOGIC_LOG_LEVEL", "LOG_LEVEL", "INFO"), - log_format=get_env_with_fallback("SUMOLOGIC_LOG_FORMAT", "LOG_FORMAT", "json"), - server_name=get_env_with_fallback("SUMOLOGIC_SERVER_NAME", "SERVER_NAME", "sumologic-mcp-server"), - server_version=get_env_with_fallback("SUMOLOGIC_SERVER_VERSION", "SERVER_VERSION", "0.1.0"), + log_format=get_env_with_fallback( + "SUMOLOGIC_LOG_FORMAT", "LOG_FORMAT", "json" + ), + server_name=get_env_with_fallback( + "SUMOLOGIC_SERVER_NAME", "SERVER_NAME", "sumologic-mcp-server" + ), + server_version=get_env_with_fallback( + "SUMOLOGIC_SERVER_VERSION", "SERVER_VERSION", "0.1.0" + ), # Reference compatibility fields - query_timeout=int(get_env_with_fallback("QUERY_TIMEOUT", "SUMOLOGIC_QUERY_TIMEOUT", "300")), - max_results=int(get_env_with_fallback("MAX_RESULTS", "SUMOLOGIC_MAX_RESULTS", "1000")), - default_vmware_source=get_env_with_fallback("DEFAULT_VMWARE_SOURCE", "SUMOLOGIC_DEFAULT_VMWARE_SOURCE", "otel/vmware"), + query_timeout=int( + get_env_with_fallback("QUERY_TIMEOUT", "SUMOLOGIC_QUERY_TIMEOUT", "300") + ), + max_results=int( + get_env_with_fallback("MAX_RESULTS", "SUMOLOGIC_MAX_RESULTS", "1000") + ), + default_vmware_source=get_env_with_fallback( + "DEFAULT_VMWARE_SOURCE", + "SUMOLOGIC_DEFAULT_VMWARE_SOURCE", + "otel/vmware", + ), ) - + @classmethod def from_file(cls, config_path: Path) -> "SumoLogicConfig": """Create configuration from a JSON configuration file. - + Args: config_path: Path to the JSON configuration file - + Returns: SumoLogicConfig instance - + Raises: FileNotFoundError: If config file doesn't exist json.JSONDecodeError: If config file is not valid JSON @@ -211,27 +217,27 @@ def from_file(cls, config_path: Path) -> "SumoLogicConfig": """ if not config_path.exists(): raise FileNotFoundError(f"Configuration file not found: {config_path}") - + try: - with open(config_path, 'r', encoding='utf-8') as f: + with open(config_path, "r", encoding="utf-8") as f: config_data = json.load(f) except json.JSONDecodeError as e: raise ValueError(f"Invalid JSON in configuration file {config_path}: {e}") - + return cls(**config_data) - + @classmethod def from_env_and_file(cls, config_path: Optional[Path] = None) -> "SumoLogicConfig": """Create configuration from environment variables and optional config file. - + Environment variables take precedence over config file values. - + Args: config_path: Optional path to JSON configuration file - + Returns: SumoLogicConfig instance - + Raises: ValueError: If configuration file is invalid or environment variables have invalid values FileNotFoundError: If specified config file doesn't exist @@ -252,36 +258,40 @@ def from_env_and_file(cls, config_path: Optional[Path] = None) -> "SumoLogicConf "max_results": 1000, "default_vmware_source": "otel/vmware", } - + config_sources = { "file_loaded": False, "file_path": None, "env_vars_found": [], - "parsing_errors": [] + "parsing_errors": [], } - + # Load from config file if provided if config_path: if not config_path.exists(): raise FileNotFoundError(f"Configuration file not found: {config_path}") - + try: - with open(config_path, 'r', encoding='utf-8') as f: + with open(config_path, "r", encoding="utf-8") as f: file_config = json.load(f) - + # Validate that file_config is a dictionary if not isinstance(file_config, dict): - raise ValueError(f"Configuration file must contain a JSON object, got {type(file_config).__name__}") - + raise ValueError( + f"Configuration file must contain a JSON object, got {type(file_config).__name__}" + ) + config_data.update(file_config) config_sources["file_loaded"] = True config_sources["file_path"] = str(config_path) - + except json.JSONDecodeError as e: - raise ValueError(f"Invalid JSON in configuration file {config_path}: {e}") + raise ValueError( + f"Invalid JSON in configuration file {config_path}: {e}" + ) except IOError as e: raise ValueError(f"Error reading configuration file {config_path}: {e}") - + # Override with environment variables with proper type conversion and validation # Support both new and reference environment variables env_mappings = { @@ -299,7 +309,6 @@ def from_env_and_file(cls, config_path: Optional[Path] = None) -> "SumoLogicConf "SUMOLOGIC_QUERY_TIMEOUT": ("query_timeout", int), "SUMOLOGIC_MAX_RESULTS": ("max_results", int), "SUMOLOGIC_DEFAULT_VMWARE_SOURCE": ("default_vmware_source", str), - # Reference implementation compatibility variables (take precedence) "SUMO_ACCESS_ID": ("access_id", str), "SUMO_ACCESS_KEY": ("access_key", str), @@ -308,7 +317,7 @@ def from_env_and_file(cls, config_path: Optional[Path] = None) -> "SumoLogicConf "MAX_RESULTS": ("max_results", int), "DEFAULT_VMWARE_SOURCE": ("default_vmware_source", str), } - + for env_var, (config_key, value_type) in env_mappings.items(): env_value = os.getenv(env_var) if env_value is not None: @@ -319,42 +328,48 @@ def from_env_and_file(cls, config_path: Optional[Path] = None) -> "SumoLogicConf config_data[config_key] = int(env_value) elif value_type == float: config_data[config_key] = float(env_value) - + config_sources["env_vars_found"].append(env_var) - + except (ValueError, TypeError) as e: error_msg = f"Invalid value for {env_var}: '{env_value}' (expected {value_type.__name__})" config_sources["parsing_errors"].append(error_msg) raise ValueError(error_msg) from e - + # Store configuration source information for debugging config_instance = cls(**config_data) config_instance._config_sources = config_sources - + return config_instance - + def validate_required_fields(self) -> Dict[str, str]: """Validate that all required fields are present and valid. - + Returns: Dictionary of validation errors (empty if valid) """ errors = {} - + if not self.access_id: - errors["access_id"] = "Sumo Logic Access ID is required. Set SUMOLOGIC_ACCESS_ID environment variable or provide in config file." - + errors["access_id"] = ( + "Sumo Logic Access ID is required. Set SUMOLOGIC_ACCESS_ID environment variable or provide in config file." + ) + if not self.access_key: - errors["access_key"] = "Sumo Logic Access Key is required. Set SUMOLOGIC_ACCESS_KEY environment variable or provide in config file." - + errors["access_key"] = ( + "Sumo Logic Access Key is required. Set SUMOLOGIC_ACCESS_KEY environment variable or provide in config file." + ) + if not self.endpoint: - errors["endpoint"] = "Sumo Logic API endpoint is required. Set SUMOLOGIC_ENDPOINT environment variable or provide in config file." - + errors["endpoint"] = ( + "Sumo Logic API endpoint is required. Set SUMOLOGIC_ENDPOINT environment variable or provide in config file." + ) + return errors - + def validate_startup_configuration(self) -> Dict[str, Any]: """Comprehensive startup configuration validation with detailed error messages. - + Returns: Dictionary with validation results including errors, warnings, and recommendations """ @@ -366,71 +381,85 @@ def validate_startup_configuration(self) -> Dict[str, Any]: "config_sources": { "environment_variables": {}, "config_file": None, - "defaults_used": [] - } + "defaults_used": [], + }, } - + # Check required fields required_errors = self.validate_required_fields() if required_errors: validation_result["valid"] = False for field, message in required_errors.items(): - validation_result["errors"].append({ - "field": field, - "message": message, - "severity": "error", - "category": "required_field" - }) - + validation_result["errors"].append( + { + "field": field, + "message": message, + "severity": "error", + "category": "required_field", + } + ) + # Check configuration values and provide warnings/recommendations if self.timeout < 10: - validation_result["warnings"].append({ - "field": "timeout", - "message": f"Timeout of {self.timeout}s is quite low and may cause request failures", - "recommendation": "Consider using a timeout of at least 10 seconds", - "current_value": self.timeout - }) + validation_result["warnings"].append( + { + "field": "timeout", + "message": f"Timeout of {self.timeout}s is quite low and may cause request failures", + "recommendation": "Consider using a timeout of at least 10 seconds", + "current_value": self.timeout, + } + ) elif self.timeout > 120: - validation_result["warnings"].append({ - "field": "timeout", - "message": f"Timeout of {self.timeout}s is very high", - "recommendation": "Consider using a timeout between 30-120 seconds for better performance", - "current_value": self.timeout - }) - + validation_result["warnings"].append( + { + "field": "timeout", + "message": f"Timeout of {self.timeout}s is very high", + "recommendation": "Consider using a timeout between 30-120 seconds for better performance", + "current_value": self.timeout, + } + ) + if self.max_retries > 5: - validation_result["warnings"].append({ - "field": "max_retries", - "message": f"Max retries of {self.max_retries} is quite high", - "recommendation": "Consider using 3-5 retries to balance reliability and performance", - "current_value": self.max_retries - }) - + validation_result["warnings"].append( + { + "field": "max_retries", + "message": f"Max retries of {self.max_retries} is quite high", + "recommendation": "Consider using 3-5 retries to balance reliability and performance", + "current_value": self.max_retries, + } + ) + if self.rate_limit_delay < 0.5: - validation_result["warnings"].append({ - "field": "rate_limit_delay", - "message": f"Rate limit delay of {self.rate_limit_delay}s is very low", - "recommendation": "Consider using at least 0.5s to avoid hitting rate limits", - "current_value": self.rate_limit_delay - }) - + validation_result["warnings"].append( + { + "field": "rate_limit_delay", + "message": f"Rate limit delay of {self.rate_limit_delay}s is very low", + "recommendation": "Consider using at least 0.5s to avoid hitting rate limits", + "current_value": self.rate_limit_delay, + } + ) + # Check endpoint configuration if self.endpoint and not self.endpoint.startswith("https://api."): - validation_result["warnings"].append({ - "field": "endpoint", - "message": "Endpoint should typically start with 'https://api.' for Sumo Logic", - "recommendation": "Verify your endpoint URL is correct", - "current_value": self.endpoint - }) - + validation_result["warnings"].append( + { + "field": "endpoint", + "message": "Endpoint should typically start with 'https://api.' for Sumo Logic", + "recommendation": "Verify your endpoint URL is correct", + "current_value": self.endpoint, + } + ) + # Add recommendations for optimal configuration if self.log_level == "DEBUG": - validation_result["recommendations"].append({ - "field": "log_level", - "message": "DEBUG logging is enabled", - "recommendation": "Use INFO or WARNING level in production for better performance" - }) - + validation_result["recommendations"].append( + { + "field": "log_level", + "message": "DEBUG logging is enabled", + "recommendation": "Use INFO or WARNING level in production for better performance", + } + ) + # Track which values are using defaults defaults_used = [] if self.timeout == 30: @@ -443,19 +472,19 @@ def validate_startup_configuration(self) -> Dict[str, Any]: defaults_used.append("log_level") if self.log_format == "json": defaults_used.append("log_format") - + validation_result["config_sources"]["defaults_used"] = defaults_used - + return validation_result - + def get_validation_summary(self) -> Dict[str, Any]: """Get a summary of configuration validation status. - + Returns: Dictionary with validation details """ errors = self.validate_required_fields() - + return { "valid": len(errors) == 0, "errors": errors, @@ -471,31 +500,24 @@ def get_validation_summary(self) -> Dict[str, Any]: "log_format": self.log_format, "server_name": self.server_name, "server_version": self.server_version, - } + }, } class SearchRequest(BaseModel): """Model for search request parameters.""" - + query: str = Field(..., description="Search query string") from_time: str = Field(..., description="Start time (ISO format or relative)") to_time: str = Field(..., description="End time (ISO format or relative)") limit: int = Field( - default=100, - ge=1, - le=10000, - description="Maximum results to return" - ) - offset: int = Field( - default=0, - ge=0, - description="Result offset for pagination" + default=100, ge=1, le=10000, description="Maximum results to return" ) + offset: int = Field(default=0, ge=0, description="Result offset for pagination") time_zone: Optional[str] = Field(None, description="Time zone for query") by_receipt_time: bool = Field(default=False, description="Search by receipt time") auto_parsing_mode: Optional[str] = Field(None, description="Auto parsing mode") - + @field_validator("query") @classmethod def validate_query(cls, v: str) -> str: @@ -503,42 +525,42 @@ def validate_query(cls, v: str) -> str: if not v.strip(): raise ValueError("Query cannot be empty") return v.strip() - + @field_validator("from_time", "to_time") @classmethod def validate_time_format(cls, v: str) -> str: """Validate time format (ISO 8601, relative time, or 'now').""" if not v.strip(): raise ValueError("Time cannot be empty") - + v = v.strip() - + # Allow 'now' as a valid time format - if v.lower() == 'now': + if v.lower() == "now": return v - + import re - + # Enhanced ISO 8601 patterns to match Sumo Logic API requirements iso_patterns = [ - r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$', # With milliseconds and Z - r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{6})?Z$', # With microseconds and Z - r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?[+-]\d{2}:\d{2}$', # With timezone offset - r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$', # Basic ISO format - r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?$' # With optional milliseconds + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$", # With milliseconds and Z + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{6})?Z$", # With microseconds and Z + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?[+-]\d{2}:\d{2}$", # With timezone offset + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$", # Basic ISO format + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?$", # With optional milliseconds ] - + # Enhanced relative time pattern to match TimeParser implementation - relative_pattern = r'^-?(\d+)([smhdw])$' - + relative_pattern = r"^-?(\d+)([smhdw])$" + # Check epoch time patterns (seconds or milliseconds) - epoch_pattern = r'^\d{10,13}$' # 10-13 digits for epoch seconds/milliseconds - + epoch_pattern = r"^\d{10,13}$" # 10-13 digits for epoch seconds/milliseconds + # Validate against all supported patterns is_iso = any(re.match(pattern, v, re.IGNORECASE) for pattern in iso_patterns) is_relative = re.match(relative_pattern, v, re.IGNORECASE) is_epoch = re.match(epoch_pattern, v) - + if not (is_iso or is_relative or is_epoch): raise ValueError( f"Invalid time format '{v}'. Supported formats:\n" @@ -552,16 +574,14 @@ def validate_time_format(cls, v: str) -> str: class DashboardConfig(BaseModel): """Model for dashboard configuration.""" - + title: str = Field(..., description="Dashboard title") description: Optional[str] = Field(None, description="Dashboard description") panels: list = Field(..., description="Dashboard panels configuration") refresh_interval: Optional[int] = Field( - None, - ge=30, - description="Auto-refresh interval in seconds (minimum 30)" + None, ge=30, description="Auto-refresh interval in seconds (minimum 30)" ) - + @field_validator("title") @classmethod def validate_title(cls, v: str) -> str: @@ -571,11 +591,11 @@ def validate_title(cls, v: str) -> str: if len(v.strip()) > 255: raise ValueError("Title cannot exceed 255 characters") return v.strip() - + @field_validator("panels") @classmethod def validate_panels(cls, v: list) -> list: """Validate panels configuration.""" if not v: raise ValueError("Dashboard must have at least one panel") - return v \ No newline at end of file + return v diff --git a/sumologic_mcp/tools/monitor_tools.py b/sumologic_mcp/tools/monitor_tools.py index 69b6b01..490fa8d 100644 --- a/sumologic_mcp/tools/monitor_tools.py +++ b/sumologic_mcp/tools/monitor_tools.py @@ -6,36 +6,36 @@ status and alerts with comprehensive configuration validation and error handling. """ -from typing import Dict, Any, Optional, List, Callable import logging import time from datetime import datetime +from typing import Any, Callable, Dict, List, Optional from ..api_client import SumoLogicAPIClient -from ..exceptions import ValidationError, APIError, RateLimitError, TimeoutError +from ..exceptions import APIError, RateLimitError, TimeoutError, ValidationError from ..models.monitor import ( + ActiveAlert, MonitorConfig, MonitorResponse, + MonitorStatus, MonitorStatusInfo, - ActiveAlert, - MonitorValidationResult, MonitorType, - TriggerType, + MonitorValidationResult, NotificationType, - MonitorStatus, - ThresholdType + ThresholdType, + TriggerType, ) from .monitor_error_handler import ( - MonitorErrorHandler, + MonitorConfigurationError, MonitorError, - MonitorValidationError, + MonitorErrorHandler, MonitorNotFoundError, - MonitorPermissionError, - MonitorConfigurationError, MonitorOperationError, + MonitorPermissionError, + MonitorValidationError, + create_monitor_error_context, validate_monitor_id, validate_pagination_params, - create_monitor_error_context ) logger = logging.getLogger(__name__) @@ -43,31 +43,31 @@ class MonitorTools: """MCP tools for Sumo Logic monitor operations with comprehensive error handling.""" - + def __init__(self, api_client: SumoLogicAPIClient): """Initialize MonitorTools with API client and error handling. - + Args: api_client: Configured SumoLogicAPIClient instance """ self.api_client = api_client - + # Initialize enhanced error handler for monitor operations self.error_handler = MonitorErrorHandler("monitor_tools") - + logger.info( "Initialized MonitorTools with enhanced error handling and resilience patterns", extra={ - "api_endpoint": getattr(api_client.config, 'endpoint', 'unknown'), + "api_endpoint": getattr(api_client.config, "endpoint", "unknown"), "error_handler": "enabled", "circuit_breaker": "enabled", - "retry_logic": "enabled" - } + "retry_logic": "enabled", + }, ) - + def get_tool_definitions(self) -> List[Dict[str, Any]]: """Get MCP tool definitions for monitor operations. - + Returns: List of tool definitions for MCP server registration """ @@ -80,39 +80,39 @@ def get_tool_definitions(self) -> List[Dict[str, Any]]: "properties": { "filter_name": { "type": "string", - "description": "Optional name filter with pattern matching support (supports * and ? wildcards)" + "description": "Optional name filter with pattern matching support (supports * and ? wildcards)", }, "filter_type": { "type": "string", "description": "Optional monitor type filter (logs, metrics, SLI)", - "enum": ["logs", "metrics", "SLI"] + "enum": ["logs", "metrics", "SLI"], }, "filter_status": { "type": "string", "description": "Optional status filter (enabled, disabled, triggered)", - "enum": ["enabled", "disabled", "triggered"] + "enum": ["enabled", "disabled", "triggered"], }, "limit": { "type": "integer", "description": "Maximum number of monitors to return (1-1000)", "minimum": 1, "maximum": 1000, - "default": 100 + "default": 100, }, "offset": { "type": "integer", "description": "Starting position for pagination (0-based)", "minimum": 0, - "default": 0 + "default": 0, }, "include_folders": { "type": "boolean", "description": "Whether to include monitors within folders in search results (default: true)", - "default": True - } + "default": True, + }, }, - "required": [] - } + "required": [], + }, }, { "name": "search_monitors", @@ -122,33 +122,33 @@ def get_tool_definitions(self) -> List[Dict[str, Any]]: "properties": { "search_query": { "type": "string", - "description": "Search query string to match against monitor fields" + "description": "Search query string to match against monitor fields", }, "search_fields": { "type": "array", "description": "Fields to search in (default: name, description, query)", "items": { "type": "string", - "enum": ["name", "description", "query"] + "enum": ["name", "description", "query"], }, - "default": ["name", "description", "query"] + "default": ["name", "description", "query"], }, "limit": { "type": "integer", "description": "Maximum number of results to return (1-1000)", "minimum": 1, "maximum": 1000, - "default": 100 + "default": 100, }, "offset": { "type": "integer", "description": "Starting position for pagination (0-based)", "minimum": 0, - "default": 0 - } + "default": 0, + }, }, - "required": ["search_query"] - } + "required": ["search_query"], + }, }, { "name": "get_monitor", @@ -158,11 +158,11 @@ def get_tool_definitions(self) -> List[Dict[str, Any]]: "properties": { "monitor_id": { "type": "string", - "description": "Unique identifier for the monitor" + "description": "Unique identifier for the monitor", } }, - "required": ["monitor_id"] - } + "required": ["monitor_id"], + }, }, { "name": "create_monitor", @@ -172,22 +172,26 @@ def get_tool_definitions(self) -> List[Dict[str, Any]]: "properties": { "name": { "type": "string", - "description": "Monitor name (required, max 255 characters)" + "description": "Monitor name (required, max 255 characters)", }, "description": { "type": "string", "description": "Monitor description", - "default": "" + "default": "", }, "type": { "type": "string", "description": "Monitor type", - "enum": ["MonitorsLibraryMonitor", "MetricsMonitor", "SliMonitor"], - "default": "MonitorsLibraryMonitor" + "enum": [ + "MonitorsLibraryMonitor", + "MetricsMonitor", + "SliMonitor", + ], + "default": "MonitorsLibraryMonitor", }, "query": { "type": "string", - "description": "Monitor query string (required)" + "description": "Monitor query string (required)", }, "trigger_conditions": { "type": "object", @@ -195,28 +199,28 @@ def get_tool_definitions(self) -> List[Dict[str, Any]]: "properties": { "Critical": {"type": "object"}, "Warning": {"type": "object"}, - "MissingData": {"type": "object"} - } + "MissingData": {"type": "object"}, + }, }, "notifications": { "type": "array", "description": "Notification actions", "items": {"type": "object"}, - "default": [] + "default": [], }, "is_disabled": { "type": "boolean", "description": "Whether monitor is disabled", - "default": False + "default": False, }, "evaluation_delay": { "type": "string", "description": "Delay before evaluation (e.g., '5m')", - "default": "0m" - } + "default": "0m", + }, }, - "required": ["name", "query", "trigger_conditions"] - } + "required": ["name", "query", "trigger_conditions"], + }, }, { "name": "update_monitor", @@ -226,40 +230,40 @@ def get_tool_definitions(self) -> List[Dict[str, Any]]: "properties": { "monitor_id": { "type": "string", - "description": "Unique identifier for the monitor to update" + "description": "Unique identifier for the monitor to update", }, "name": { "type": "string", - "description": "New monitor name (max 255 characters)" + "description": "New monitor name (max 255 characters)", }, "description": { "type": "string", - "description": "New monitor description" + "description": "New monitor description", }, "query": { "type": "string", - "description": "New monitor query string" + "description": "New monitor query string", }, "trigger_conditions": { "type": "object", - "description": "New trigger conditions" + "description": "New trigger conditions", }, "notifications": { "type": "array", "description": "New notification actions", - "items": {"type": "object"} + "items": {"type": "object"}, }, "is_disabled": { "type": "boolean", - "description": "Whether monitor is disabled" + "description": "Whether monitor is disabled", }, "evaluation_delay": { "type": "string", - "description": "New evaluation delay" - } + "description": "New evaluation delay", + }, }, - "required": ["monitor_id"] - } + "required": ["monitor_id"], + }, }, { "name": "delete_monitor", @@ -269,11 +273,11 @@ def get_tool_definitions(self) -> List[Dict[str, Any]]: "properties": { "monitor_id": { "type": "string", - "description": "Unique identifier for the monitor to delete" + "description": "Unique identifier for the monitor to delete", } }, - "required": ["monitor_id"] - } + "required": ["monitor_id"], + }, }, { "name": "get_monitor_status", @@ -283,16 +287,16 @@ def get_tool_definitions(self) -> List[Dict[str, Any]]: "properties": { "monitor_id": { "type": "string", - "description": "Optional monitor ID to get status for specific monitor" + "description": "Optional monitor ID to get status for specific monitor", }, "filter_status": { "type": "string", "description": "Optional status filter (triggered, normal, disabled)", - "enum": ["triggered", "normal", "disabled", "unknown"] - } + "enum": ["triggered", "normal", "disabled", "unknown"], + }, }, - "required": [] - } + "required": [], + }, }, { "name": "get_active_alerts", @@ -303,18 +307,18 @@ def get_tool_definitions(self) -> List[Dict[str, Any]]: "severity": { "type": "string", "description": "Optional severity filter", - "enum": ["Critical", "Warning", "MissingData"] + "enum": ["Critical", "Warning", "MissingData"], }, "limit": { "type": "integer", "description": "Maximum number of alerts to return (1-1000)", "minimum": 1, "maximum": 1000, - "default": 100 - } + "default": 100, + }, }, - "required": [] - } + "required": [], + }, }, { "name": "enable_monitor", @@ -324,11 +328,11 @@ def get_tool_definitions(self) -> List[Dict[str, Any]]: "properties": { "monitor_id": { "type": "string", - "description": "Unique identifier for the monitor to enable" + "description": "Unique identifier for the monitor to enable", } }, - "required": ["monitor_id"] - } + "required": ["monitor_id"], + }, }, { "name": "disable_monitor", @@ -338,11 +342,11 @@ def get_tool_definitions(self) -> List[Dict[str, Any]]: "properties": { "monitor_id": { "type": "string", - "description": "Unique identifier for the monitor to disable" + "description": "Unique identifier for the monitor to disable", } }, - "required": ["monitor_id"] - } + "required": ["monitor_id"], + }, }, { "name": "validate_monitor_config", @@ -357,13 +361,13 @@ def get_tool_definitions(self) -> List[Dict[str, Any]]: "name": {"type": "string"}, "query": {"type": "string"}, "trigger_conditions": {"type": "object"}, - "notifications": {"type": "array"} + "notifications": {"type": "array"}, }, - "required": ["name", "query", "trigger_conditions"] + "required": ["name", "query", "trigger_conditions"], } }, - "required": ["config"] - } + "required": ["config"], + }, }, { "name": "get_monitor_history", @@ -373,29 +377,29 @@ def get_tool_definitions(self) -> List[Dict[str, Any]]: "properties": { "monitor_id": { "type": "string", - "description": "Unique identifier for the monitor" + "description": "Unique identifier for the monitor", }, "from_time": { "type": "string", - "description": "Start time for history range (ISO format or relative like '-1h')" + "description": "Start time for history range (ISO format or relative like '-1h')", }, "to_time": { "type": "string", - "description": "End time for history range (ISO format or relative like 'now')" + "description": "End time for history range (ISO format or relative like 'now')", }, "limit": { "type": "integer", "description": "Maximum number of history entries to return (1-1000)", "minimum": 1, "maximum": 1000, - "default": 100 - } + "default": 100, + }, }, - "required": ["monitor_id", "from_time", "to_time"] - } - } + "required": ["monitor_id", "from_time", "to_time"], + }, + }, ] - + async def list_monitors( self, filter_name: Optional[str] = None, @@ -403,10 +407,10 @@ async def list_monitors( filter_status: Optional[str] = None, limit: int = 100, offset: int = 0, - include_folders: bool = True + include_folders: bool = True, ) -> Dict[str, Any]: """List monitors with optional filtering and pagination, including folder-based searches. - + Args: filter_name: Optional name filter to search for specific monitors filter_type: Optional monitor type filter (logs, metrics, SLI) @@ -414,22 +418,23 @@ async def list_monitors( limit: Maximum number of monitors to return (1-1000) offset: Starting position for pagination (0-based) include_folders: Whether to include monitors within folders (default: True) - + Returns: Dictionary containing monitor list with metadata, statistics, and folder information - + Raises: MonitorValidationError: If parameters are invalid MonitorError: If monitor operation fails APIError: If API request fails RateLimitError: If rate limit is exceeded """ + async def _list_monitors_impl(): # Validate pagination parameters validated_limit, validated_offset = await validate_pagination_params( limit, offset, "list_monitors" ) - + # Validate filter_type if provided if filter_type: valid_types = ["logs", "metrics", "SLI"] @@ -439,11 +444,10 @@ async def _list_monitors_impl(): field_name="filter_type", field_value=filter_type, context=create_monitor_error_context( - "list_monitors", - valid_types=valid_types - ) + "list_monitors", valid_types=valid_types + ), ) - + # Validate filter_status if provided if filter_status: valid_statuses = ["enabled", "disabled", "triggered"] @@ -453,11 +457,10 @@ async def _list_monitors_impl(): field_name="filter_status", field_value=filter_status, context=create_monitor_error_context( - "list_monitors", - valid_statuses=valid_statuses - ) + "list_monitors", valid_statuses=valid_statuses + ), ) - + # Call API client to get monitors with rate limiting and retry logic try: api_response = await self.api_client.list_monitors( @@ -466,7 +469,7 @@ async def _list_monitors_impl(): filter_name=filter_name, filter_type=filter_type, filter_status=filter_status, - include_folders=include_folders + include_folders=include_folders, ) except RateLimitError as e: # Add monitor-specific context to rate limit error @@ -479,15 +482,15 @@ async def _list_monitors_impl(): filters={ "name": filter_name, "type": filter_type, - "status": filter_status - } - ) + "status": filter_status, + }, + ), ) from e - + # Extract monitors from response monitors = api_response.get("data", []) total_count = api_response.get("total", len(monitors)) - + # Apply client-side filtering if needed (for more complex filtering) try: filtered_monitors = self._apply_client_side_filters( @@ -500,10 +503,10 @@ async def _list_monitors_impl(): context=create_monitor_error_context( "list_monitors", filter_error=str(e), - monitor_count=len(monitors) - ) + monitor_count=len(monitors), + ), ) from e - + # Calculate statistics safely including folder information try: statistics = self._calculate_monitor_statistics(filtered_monitors) @@ -511,14 +514,24 @@ async def _list_monitors_impl(): except Exception as e: logger.warning( "Failed to calculate monitor statistics, using defaults", - extra={"error": str(e), "monitor_count": len(filtered_monitors)} + extra={"error": str(e), "monitor_count": len(filtered_monitors)}, ) - statistics = {"total": len(filtered_monitors), "by_type": {}, "by_status": {}} - folder_statistics = {"folders_found": 0, "monitors_in_folders": 0, "root_level_monitors": 0} - + statistics = { + "total": len(filtered_monitors), + "by_type": {}, + "by_status": {}, + } + folder_statistics = { + "folders_found": 0, + "monitors_in_folders": 0, + "root_level_monitors": 0, + } + # Enhance monitors with folder display information - enhanced_monitors = self._enhance_monitors_with_folder_info(filtered_monitors) - + enhanced_monitors = self._enhance_monitors_with_folder_info( + filtered_monitors + ) + # Format response with metadata, statistics, and folder information response = { "success": True, @@ -528,7 +541,8 @@ async def _list_monitors_impl(): "returned_count": len(enhanced_monitors), "offset": validated_offset, "limit": validated_limit, - "has_more": (validated_offset + len(enhanced_monitors)) < total_count + "has_more": (validated_offset + len(enhanced_monitors)) + < total_count, }, "statistics": statistics, "folder_statistics": folder_statistics, @@ -537,101 +551,101 @@ async def _list_monitors_impl(): "name": filter_name, "type": filter_type, "status": filter_status, - "include_folders": include_folders - } + "include_folders": include_folders, + }, } - + return response - + # Execute with comprehensive error handling return await self.error_handler.execute_with_error_handling( "list_monitors", _list_monitors_impl, - timeout_override=30.0 # List operations may take longer with large datasets + timeout_override=30.0, # List operations may take longer with large datasets ) - + def _apply_client_side_filters( self, monitors: List[Dict[str, Any]], filter_name: Optional[str], filter_type: Optional[str], - filter_status: Optional[str] + filter_status: Optional[str], ) -> List[Dict[str, Any]]: """Apply client-side filtering for more complex filter logic. - + Args: monitors: List of monitor dictionaries filter_name: Name filter pattern filter_type: Type filter filter_status: Status filter - + Returns: Filtered list of monitors """ filtered = monitors - + # Apply name filtering with pattern matching if filter_name: filtered = self._filter_by_name_pattern(filtered, filter_name) - + # Apply type filtering (convert from user-friendly to API format) if filter_type: filtered = self._filter_by_monitor_type(filtered, filter_type) - + # Apply status filtering if filter_status: filtered = self._filter_by_status(filtered, filter_status) - + return filtered - + def _filter_by_name_pattern( - self, - monitors: List[Dict[str, Any]], - name_pattern: str + self, monitors: List[Dict[str, Any]], name_pattern: str ) -> List[Dict[str, Any]]: """Filter monitors by name pattern with advanced matching. - + Args: monitors: List of monitor dictionaries name_pattern: Name pattern to match (supports wildcards and regex-like patterns) - + Returns: Filtered list of monitors """ import re - + # Convert simple wildcards to regex if needed - if '*' in name_pattern or '?' in name_pattern: + if "*" in name_pattern or "?" in name_pattern: # Convert shell-style wildcards to regex - regex_pattern = name_pattern.replace('*', '.*').replace('?', '.') + regex_pattern = name_pattern.replace("*", ".*").replace("?", ".") try: compiled_pattern = re.compile(regex_pattern, re.IGNORECASE) return [ - monitor for monitor in monitors + monitor + for monitor in monitors if compiled_pattern.search(monitor.get("name", "")) ] except re.error: # Fall back to simple substring matching if regex is invalid - logger.warning(f"Invalid regex pattern '{name_pattern}', falling back to substring matching") - + logger.warning( + f"Invalid regex pattern '{name_pattern}', falling back to substring matching" + ) + # Simple case-insensitive substring matching filter_name_lower = name_pattern.lower() return [ - monitor for monitor in monitors + monitor + for monitor in monitors if filter_name_lower in monitor.get("name", "").lower() ] - + def _filter_by_monitor_type( - self, - monitors: List[Dict[str, Any]], - monitor_type: str + self, monitors: List[Dict[str, Any]], monitor_type: str ) -> List[Dict[str, Any]]: """Filter monitors by type with support for multiple formats. - + Args: monitors: List of monitor dictionaries monitor_type: Monitor type filter (logs, metrics, SLI, or API format) - + Returns: Filtered list of monitors """ @@ -640,41 +654,34 @@ def _filter_by_monitor_type( "logs": "MonitorsLibraryMonitor", "metrics": "MetricsMonitor", "SLI": "SliMonitor", - "sli": "SliMonitor" # Case insensitive + "sli": "SliMonitor", # Case insensitive } - + # Support both user-friendly and API format api_type = type_mapping.get(monitor_type.lower(), monitor_type) - + return [ - monitor for monitor in monitors - if monitor.get("monitorType") == api_type + monitor for monitor in monitors if monitor.get("monitorType") == api_type ] - + def _filter_by_status( - self, - monitors: List[Dict[str, Any]], - status_filter: str + self, monitors: List[Dict[str, Any]], status_filter: str ) -> List[Dict[str, Any]]: """Filter monitors by status. - + Args: monitors: List of monitor dictionaries status_filter: Status filter (enabled, disabled, triggered) - + Returns: Filtered list of monitors """ if status_filter == "enabled": return [ - monitor for monitor in monitors - if not monitor.get("isDisabled", False) + monitor for monitor in monitors if not monitor.get("isDisabled", False) ] elif status_filter == "disabled": - return [ - monitor for monitor in monitors - if monitor.get("isDisabled", False) - ] + return [monitor for monitor in monitors if monitor.get("isDisabled", False)] elif status_filter == "triggered": # For triggered status, we would need to make additional API calls # to get the current status of each monitor. For now, log a warning @@ -685,15 +692,19 @@ def _filter_by_status( ) return monitors else: - logger.warning(f"Unknown status filter '{status_filter}', returning all monitors") + logger.warning( + f"Unknown status filter '{status_filter}', returning all monitors" + ) return monitors - - def _calculate_folder_statistics(self, monitors: List[Dict[str, Any]]) -> Dict[str, Any]: + + def _calculate_folder_statistics( + self, monitors: List[Dict[str, Any]] + ) -> Dict[str, Any]: """Calculate folder-related statistics from monitor list. - + Args: monitors: List of monitor dictionaries - + Returns: Dictionary containing folder statistics """ @@ -701,134 +712,152 @@ def _calculate_folder_statistics(self, monitors: List[Dict[str, Any]]) -> Dict[s monitors_in_folders = 0 root_level_monitors = 0 folder_paths = set() - + for monitor in monitors: content_type = monitor.get("contentType", "") folder_path = monitor.get("folder_path", "") - + if content_type == "MonitorsLibraryFolder": folder_count += 1 if folder_path: folder_paths.add(folder_path) elif content_type == "MonitorsLibraryMonitor": - if folder_path and folder_path != "/" and not folder_path.startswith("/unknown"): + if ( + folder_path + and folder_path != "/" + and not folder_path.startswith("/unknown") + ): monitors_in_folders += 1 # Extract parent folder path parent_path = "/".join(folder_path.split("/")[:-1]) or "/" folder_paths.add(parent_path) else: root_level_monitors += 1 - + return { "folders_found": folder_count, "monitors_in_folders": monitors_in_folders, "root_level_monitors": root_level_monitors, "unique_folder_paths": len(folder_paths), - "folder_paths": sorted(list(folder_paths)) + "folder_paths": sorted(list(folder_paths)), } - - def _enhance_monitors_with_folder_info(self, monitors: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + + def _enhance_monitors_with_folder_info( + self, monitors: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: """Enhance monitor objects with folder display information. - + Args: monitors: List of monitor dictionaries - + Returns: Enhanced list of monitors with folder display information """ enhanced_monitors = [] - + for monitor in monitors: enhanced_monitor = monitor.copy() content_type = monitor.get("contentType", "") folder_path = monitor.get("folder_path", "") - + # Add display information enhanced_monitor["display_info"] = { "is_folder": content_type == "MonitorsLibraryFolder", "is_monitor": content_type == "MonitorsLibraryMonitor", "folder_path": folder_path, - "is_in_folder": bool(folder_path and folder_path != "/" and not folder_path.startswith("/unknown")), + "is_in_folder": bool( + folder_path + and folder_path != "/" + and not folder_path.startswith("/unknown") + ), "parent_folder_id": monitor.get("parent_folder_id"), - "display_name": self._format_display_name(monitor) + "display_name": self._format_display_name(monitor), } - + # Add folder breadcrumb for better navigation if folder_path and folder_path != "/": path_parts = [part for part in folder_path.split("/") if part] enhanced_monitor["display_info"]["breadcrumb"] = " > ".join(path_parts) else: enhanced_monitor["display_info"]["breadcrumb"] = "Root" - + enhanced_monitors.append(enhanced_monitor) - + return enhanced_monitors - + def _format_display_name(self, monitor: Dict[str, Any]) -> str: """Format display name for monitor with folder context. - + Args: monitor: Monitor dictionary - + Returns: Formatted display name """ name = monitor.get("name", "Unnamed") content_type = monitor.get("contentType", "") folder_path = monitor.get("folder_path", "") - + if content_type == "MonitorsLibraryFolder": return f"📁 {name}" elif content_type == "MonitorsLibraryMonitor": - if folder_path and folder_path != "/" and not folder_path.startswith("/unknown"): + if ( + folder_path + and folder_path != "/" + and not folder_path.startswith("/unknown") + ): return f"📊 {name}" else: return f"📊 {name}" else: return name - + async def search_monitors( self, search_query: str, search_fields: Optional[List[str]] = None, limit: int = 100, - offset: int = 0 + offset: int = 0, ) -> Dict[str, Any]: """Search monitors with advanced search capabilities and comprehensive error handling. - + Args: search_query: Search query string search_fields: Fields to search in (name, description, query) limit: Maximum number of results to return offset: Starting position for pagination - + Returns: Dictionary containing search results with relevance scoring - + Raises: MonitorValidationError: If search parameters are invalid MonitorError: If search operation fails RateLimitError: If rate limit is exceeded """ + async def _search_monitors_impl(): + nonlocal search_fields # Validate search query if not search_query or not search_query.strip(): raise MonitorValidationError( "Search query cannot be empty", field_name="search_query", field_value=search_query, - context=create_monitor_error_context("search_monitors") + context=create_monitor_error_context("search_monitors"), ) - + # Validate pagination parameters validated_limit, validated_offset = await validate_pagination_params( limit, offset, "search_monitors" ) - + # Validate search fields if search_fields is not None: valid_fields = ["name", "description", "query"] - invalid_fields = [field for field in search_fields if field not in valid_fields] + invalid_fields = [ + field for field in search_fields if field not in valid_fields + ] if invalid_fields: raise MonitorValidationError( f"Invalid search fields: {', '.join(invalid_fields)}. Valid fields: {', '.join(valid_fields)}", @@ -837,14 +866,14 @@ async def _search_monitors_impl(): context=create_monitor_error_context( "search_monitors", valid_fields=valid_fields, - invalid_fields=invalid_fields - ) + invalid_fields=invalid_fields, + ), ) - + # Default search fields if not specified if search_fields is None: search_fields = ["name", "description", "query"] - + # Get all monitors first with error handling try: all_monitors_response = await self.list_monitors(limit=1000, offset=0) @@ -855,9 +884,8 @@ async def _search_monitors_impl(): retry_after=e.retry_after, limit_type=e.limit_type, context=create_monitor_error_context( - "search_monitors", - search_query=search_query.strip() - ) + "search_monitors", search_query=search_query.strip() + ), ) from e except Exception as e: raise MonitorError( @@ -866,10 +894,10 @@ async def _search_monitors_impl(): context=create_monitor_error_context( "search_monitors", search_query=search_query.strip(), - retrieval_error=str(e) - ) + retrieval_error=str(e), + ), ) from e - + # Perform client-side search with relevance scoring try: search_results = self._perform_search_with_scoring( @@ -883,14 +911,16 @@ async def _search_monitors_impl(): "search_monitors", search_query=search_query.strip(), scoring_error=str(e), - monitor_count=len(all_monitors) - ) + monitor_count=len(all_monitors), + ), ) from e - + # Apply pagination to search results total_results = len(search_results) - paginated_results = search_results[validated_offset:validated_offset + validated_limit] - + paginated_results = search_results[ + validated_offset : validated_offset + validated_limit + ] + response = { "success": True, "monitors": paginated_results, @@ -901,48 +931,49 @@ async def _search_monitors_impl(): "returned_count": len(paginated_results), "offset": validated_offset, "limit": validated_limit, - "has_more": (validated_offset + len(paginated_results)) < total_results - } + "has_more": (validated_offset + len(paginated_results)) + < total_results, + }, } - + return response - + # Execute with comprehensive error handling return await self.error_handler.execute_with_error_handling( "search_monitors", _search_monitors_impl, - timeout_override=30.0 # Search operations may take longer + timeout_override=30.0, # Search operations may take longer ) - + def _perform_search_with_scoring( self, monitors: List[Dict[str, Any]], search_query: str, - search_fields: List[str] + search_fields: List[str], ) -> List[Dict[str, Any]]: """Perform search with relevance scoring. - + Args: monitors: List of monitor dictionaries search_query: Search query string search_fields: Fields to search in - + Returns: List of monitors with relevance scores, sorted by relevance """ import re - + search_terms = search_query.lower().split() scored_monitors = [] - + for monitor in monitors: score = 0 matches = {} - + for field in search_fields: field_value = str(monitor.get(field, "")).lower() field_matches = [] - + for term in search_terms: # Exact match gets highest score if term in field_value: @@ -952,54 +983,55 @@ def _perform_search_with_scoring( score += 5 elif field == "query": score += 3 - + field_matches.append(term) - + # Bonus for exact word match - if re.search(r'\b' + re.escape(term) + r'\b', field_value): + if re.search(r"\b" + re.escape(term) + r"\b", field_value): score += 2 - + if field_matches: matches[field] = field_matches - + # Only include monitors that have at least one match if score > 0: monitor_with_score = monitor.copy() monitor_with_score["_search_score"] = score monitor_with_score["_search_matches"] = matches scored_monitors.append(monitor_with_score) - + # Sort by relevance score (highest first) scored_monitors.sort(key=lambda x: x["_search_score"], reverse=True) - + return scored_monitors - + async def get_monitor(self, monitor_id: str) -> Dict[str, Any]: """Get detailed monitor configuration and metadata. - + Args: monitor_id: Unique identifier for the monitor - + Returns: Dictionary containing detailed monitor information with formatted configuration - + Raises: MonitorValidationError: If monitor_id is invalid MonitorNotFoundError: If monitor is not found MonitorPermissionError: If insufficient permissions MonitorError: If monitor operation fails """ + async def _get_monitor_impl(): # Validate monitor_id with enhanced validation validated_monitor_id = await validate_monitor_id(monitor_id, "get_monitor") - + # Get monitor from API with error handling try: monitor_data = await self.api_client.get_monitor(validated_monitor_id) except APIError as e: # The error handler will convert this to appropriate monitor error raise e - + # Format the response with enhanced information try: formatted_response = self._format_monitor_details(monitor_data) @@ -1012,25 +1044,23 @@ async def _get_monitor_impl(): context=create_monitor_error_context( "get_monitor", monitor_id=validated_monitor_id, - format_error=str(e) - ) + format_error=str(e), + ), ) from e - + return formatted_response - + # Execute with comprehensive error handling return await self.error_handler.execute_with_error_handling( - "get_monitor", - _get_monitor_impl, - monitor_id=monitor_id + "get_monitor", _get_monitor_impl, monitor_id=monitor_id ) - + def _format_monitor_details(self, monitor_data: Dict[str, Any]) -> Dict[str, Any]: """Format monitor data with enhanced details and readable configuration. - + Args: monitor_data: Raw monitor data from API - + Returns: Dictionary with formatted monitor details """ @@ -1041,34 +1071,36 @@ def _format_monitor_details(self, monitor_data: Dict[str, Any]) -> Dict[str, Any monitor_type = monitor_data.get("monitorType") query = monitor_data.get("query", "") is_disabled = monitor_data.get("isDisabled", False) - + # Format timestamps created_at = monitor_data.get("createdAt") modified_at = monitor_data.get("modifiedAt") created_by = monitor_data.get("createdBy") modified_by = monitor_data.get("modifiedBy") version = monitor_data.get("version", 1) - + # Parse and format trigger conditions trigger_conditions = monitor_data.get("triggers", []) formatted_triggers = self._format_trigger_conditions(trigger_conditions) - + # Parse and format notifications notifications = monitor_data.get("notifications", []) - formatted_notifications = self._format_notification_configurations(notifications) - + formatted_notifications = self._format_notification_configurations( + notifications + ) + # Extract schedule and evaluation information evaluation_delay = monitor_data.get("evaluationDelay", "0m") group_notifications = monitor_data.get("groupNotifications", True) - + # Format query with syntax highlighting hints formatted_query = self._format_monitor_query(query, monitor_type) - + # Calculate configuration summary and statistics config_summary = self._generate_configuration_summary( monitor_data, formatted_triggers, formatted_notifications ) - + # Build the formatted response response = { "success": True, @@ -1084,34 +1116,34 @@ def _format_monitor_details(self, monitor_data: Dict[str, Any]) -> Dict[str, Any "notifications": formatted_notifications, "schedule": { "evaluation_delay": evaluation_delay, - "group_notifications": group_notifications + "group_notifications": group_notifications, }, "metadata": { "created_at": created_at, "created_by": created_by, "modified_at": modified_at, "modified_by": modified_by, - "version": version + "version": version, }, - "configuration_summary": config_summary - } + "configuration_summary": config_summary, + }, } - + return response - + def sort_monitors( self, monitors: List[Dict[str, Any]], sort_by: str = "name", - sort_order: str = "asc" + sort_order: str = "asc", ) -> List[Dict[str, Any]]: """Sort monitors by specified field and order. - + Args: monitors: List of monitor dictionaries sort_by: Field to sort by (name, created_at, modified_at, type, status) sort_order: Sort order (asc, desc) - + Returns: Sorted list of monitors """ @@ -1119,56 +1151,56 @@ def sort_monitors( if sort_by not in valid_sort_fields: logger.warning(f"Invalid sort field '{sort_by}', using 'name'") sort_by = "name" - + if sort_order not in ["asc", "desc"]: logger.warning(f"Invalid sort order '{sort_order}', using 'asc'") sort_order = "asc" - + # Map sort fields to actual monitor fields field_mapping = { "name": "name", "created_at": "createdAt", "modified_at": "modifiedAt", "type": "monitorType", - "status": "isDisabled" # We'll handle this specially + "status": "isDisabled", # We'll handle this specially } - + actual_field = field_mapping.get(sort_by, sort_by) - reverse_order = (sort_order == "desc") - + reverse_order = sort_order == "desc" + try: if sort_by == "status": # Special handling for status (enabled/disabled) sorted_monitors = sorted( monitors, key=lambda x: (x.get("isDisabled", False), x.get("name", "")), - reverse=reverse_order + reverse=reverse_order, ) else: sorted_monitors = sorted( monitors, key=lambda x: x.get(actual_field, ""), - reverse=reverse_order + reverse=reverse_order, ) - - logger.debug( - f"Sorted {len(monitors)} monitors by {sort_by} ({sort_order})" - ) - + + logger.debug(f"Sorted {len(monitors)} monitors by {sort_by} ({sort_order})") + return sorted_monitors - + except Exception as e: logger.warning( f"Failed to sort monitors by {sort_by}: {e}, returning original order" ) return monitors - - def _calculate_monitor_statistics(self, monitors: List[Dict[str, Any]]) -> Dict[str, Any]: + + def _calculate_monitor_statistics( + self, monitors: List[Dict[str, Any]] + ) -> Dict[str, Any]: """Calculate statistics for the monitor list. - + Args: monitors: List of monitor dictionaries - + Returns: Dictionary containing monitor statistics """ @@ -1178,21 +1210,21 @@ def _calculate_monitor_statistics(self, monitors: List[Dict[str, Any]]) -> Dict[ "enabled_monitors": 0, "disabled_monitors": 0, "monitors_by_type": {}, - "monitors_by_creator": {} + "monitors_by_creator": {}, } - + enabled_count = 0 disabled_count = 0 type_counts = {} creator_counts = {} - + for monitor in monitors: # Count enabled/disabled if monitor.get("isDisabled", False): disabled_count += 1 else: enabled_count += 1 - + # Count by type monitor_type = monitor.get("monitorType", "Unknown") # Convert API type to user-friendly format @@ -1202,53 +1234,55 @@ def _calculate_monitor_statistics(self, monitors: List[Dict[str, Any]]) -> Dict[ monitor_type = "metrics" elif monitor_type == "SliMonitor": monitor_type = "SLI" - + type_counts[monitor_type] = type_counts.get(monitor_type, 0) + 1 - + # Count by creator creator = monitor.get("createdBy", "Unknown") creator_counts[creator] = creator_counts.get(creator, 0) + 1 - + return { "total_monitors": len(monitors), "enabled_monitors": enabled_count, "disabled_monitors": disabled_count, "monitors_by_type": type_counts, - "monitors_by_creator": creator_counts + "monitors_by_creator": creator_counts, } - - def _format_trigger_conditions(self, triggers: List[Dict[str, Any]]) -> Dict[str, Any]: + + def _format_trigger_conditions( + self, triggers: List[Dict[str, Any]] + ) -> Dict[str, Any]: """Parse and format trigger conditions into readable format. - + Args: triggers: List of trigger condition dictionaries from API - + Returns: Dictionary with formatted trigger conditions by severity level """ formatted_triggers = {} - + for trigger in triggers: trigger_type = trigger.get("triggerType", "Unknown") - + # Extract trigger condition details threshold = trigger.get("threshold") threshold_type = trigger.get("thresholdType", "GreaterThan") time_range = trigger.get("timeRange", "-5m") occurrence_type = trigger.get("occurrenceType", "ResultCount") trigger_source = trigger.get("triggerSource", "AllResults") - + # Format threshold comparison for readability threshold_description = self._format_threshold_description( threshold, threshold_type, occurrence_type ) - + # Format time range for readability time_description = self._format_time_range_description(time_range) - + # Format trigger source description source_description = self._format_trigger_source_description(trigger_source) - + formatted_triggers[trigger_type] = { "threshold": threshold, "threshold_type": threshold_type, @@ -1258,44 +1292,47 @@ def _format_trigger_conditions(self, triggers: List[Dict[str, Any]]) -> Dict[str "occurrence_type": occurrence_type, "trigger_source": trigger_source, "source_description": source_description, - "summary": f"Trigger when {threshold_description} {time_description} ({source_description})" + "summary": f"Trigger when {threshold_description} {time_description} ({source_description})", } - + return formatted_triggers - - def _format_notification_configurations(self, notifications: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + + def _format_notification_configurations( + self, notifications: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: """Format notification configurations with destination details. - + Args: notifications: List of notification dictionaries from API - + Returns: List of formatted notification configurations """ formatted_notifications = [] - + for notification in notifications: action_type = notification.get("actionType", "Unknown") - + # Extract common notification fields subject = notification.get("subject", "") message_body = notification.get("messageBody", "") - + # Format based on notification type formatted_notification = { "type": action_type, - "friendly_type": self._convert_notification_type_to_friendly(action_type), + "friendly_type": self._convert_notification_type_to_friendly( + action_type + ), "subject": subject, "message_body": message_body, - "destinations": [] + "destinations": [], } - + # Extract type-specific destination information if action_type == "EmailAction": recipients = notification.get("emailRecipients", []) formatted_notification["destinations"] = [ - {"type": "email", "address": recipient} - for recipient in recipients + {"type": "email", "address": recipient} for recipient in recipients ] elif action_type == "WebhookAction": webhook_url = notification.get("webhookUrl", "") @@ -1313,25 +1350,30 @@ def _format_notification_configurations(self, notifications: List[Dict[str, Any] integration_key = notification.get("integrationKey", "") if integration_key: formatted_notification["destinations"] = [ - {"type": "pagerduty", "integration_key": integration_key[:8] + "..."} + { + "type": "pagerduty", + "integration_key": integration_key[:8] + "...", + } ] - + # Add summary description dest_count = len(formatted_notification["destinations"]) dest_summary = f"{dest_count} destination{'s' if dest_count != 1 else ''}" - formatted_notification["summary"] = f"{formatted_notification['friendly_type']} to {dest_summary}" - + formatted_notification["summary"] = ( + f"{formatted_notification['friendly_type']} to {dest_summary}" + ) + formatted_notifications.append(formatted_notification) - + return formatted_notifications - + def _format_monitor_query(self, query: str, monitor_type: str) -> Dict[str, Any]: """Extract and format monitor query with syntax highlighting hints. - + Args: query: Raw query string monitor_type: Type of monitor (affects query syntax) - + Returns: Dictionary with formatted query information """ @@ -1341,33 +1383,33 @@ def _format_monitor_query(self, query: str, monitor_type: str) -> Dict[str, Any] query_language = "metrics" elif monitor_type == "SliMonitor": query_language = "sli" - + # Extract key components from query query_components = self._extract_query_components(query, query_language) - + return { "raw": query, "language": query_language, "formatted": query.strip(), "components": query_components, "length": len(query), - "line_count": len(query.split('\n')), - "syntax_hints": self._generate_syntax_hints(query, query_language) + "line_count": len(query.split("\n")), + "syntax_hints": self._generate_syntax_hints(query, query_language), } - + def _generate_configuration_summary( self, monitor_data: Dict[str, Any], formatted_triggers: Dict[str, Any], - formatted_notifications: List[Dict[str, Any]] + formatted_notifications: List[Dict[str, Any]], ) -> Dict[str, Any]: """Generate monitor configuration summary and statistics. - + Args: monitor_data: Raw monitor data formatted_triggers: Formatted trigger conditions formatted_notifications: Formatted notifications - + Returns: Dictionary with configuration summary """ @@ -1376,14 +1418,14 @@ def _generate_configuration_summary( has_critical = "Critical" in formatted_triggers has_warning = "Warning" in formatted_triggers has_missing_data = "MissingData" in formatted_triggers - + # Count notifications by type notification_count = len(formatted_notifications) notification_types = {} for notification in formatted_notifications: notif_type = notification.get("friendly_type", "Unknown") notification_types[notif_type] = notification_types.get(notif_type, 0) + 1 - + # Calculate complexity score (simple heuristic) complexity_score = 0 complexity_score += trigger_count * 2 # Each trigger adds complexity @@ -1393,20 +1435,20 @@ def _generate_configuration_summary( complexity_score += 2 elif query_length > 200: complexity_score += 1 - + complexity_level = "Simple" if complexity_score > 8: complexity_level = "Complex" elif complexity_score > 4: complexity_level = "Moderate" - + return { "trigger_summary": { "total_conditions": trigger_count, "has_critical": has_critical, "has_warning": has_warning, "has_missing_data": has_missing_data, - "severity_levels": list(formatted_triggers.keys()) + "severity_levels": list(formatted_triggers.keys()), }, "notification_summary": { "total_notifications": notification_count, @@ -1414,7 +1456,7 @@ def _generate_configuration_summary( "total_destinations": sum( len(notif.get("destinations", [])) for notif in formatted_notifications - ) + ), }, "complexity": { "score": complexity_score, @@ -1422,22 +1464,24 @@ def _generate_configuration_summary( "factors": { "trigger_conditions": trigger_count, "notifications": notification_count, - "query_length": query_length - } + "query_length": query_length, + }, }, "configuration_health": self._assess_configuration_health( formatted_triggers, formatted_notifications, monitor_data - ) + ), } - - def _format_threshold_description(self, threshold: float, threshold_type: str, occurrence_type: str) -> str: + + def _format_threshold_description( + self, threshold: float, threshold_type: str, occurrence_type: str + ) -> str: """Format threshold comparison for human readability. - + Args: threshold: Threshold value threshold_type: Type of comparison (GreaterThan, LessThan, etc.) occurrence_type: Type of occurrence (ResultCount, AtLeastOnce, etc.) - + Returns: Human-readable threshold description """ @@ -1446,11 +1490,11 @@ def _format_threshold_description(self, threshold: float, threshold_type: str, o "GreaterThan": ">", "LessThan": "<", "GreaterThanOrEqual": ">=", - "LessThanOrEqual": "<=" + "LessThanOrEqual": "<=", } - + operator = operator_map.get(threshold_type, threshold_type) - + # Format based on occurrence type if occurrence_type == "ResultCount": return f"result count {operator} {threshold}" @@ -1460,13 +1504,13 @@ def _format_threshold_description(self, threshold: float, threshold_type: str, o return f"value {operator} {threshold} always" else: return f"value {operator} {threshold}" - + def _format_time_range_description(self, time_range: str) -> str: """Format time range for human readability. - + Args: time_range: Time range string (e.g., "-5m", "-1h") - + Returns: Human-readable time description """ @@ -1475,7 +1519,7 @@ def _format_time_range_description(self, time_range: str) -> str: time_value = time_range[1:] else: time_value = time_range - + # Convert to readable format if time_value.endswith("s"): unit = "second" @@ -1491,7 +1535,7 @@ def _format_time_range_description(self, time_range: str) -> str: value = time_value[:-1] else: return f"over {time_range}" - + # Add plural if needed try: num_value = int(value) @@ -1500,47 +1544,47 @@ def _format_time_range_description(self, time_range: str) -> str: return f"over the last {num_value} {unit}" except ValueError: return f"over {time_range}" - + def _format_trigger_source_description(self, trigger_source: str) -> str: """Format trigger source for human readability. - + Args: trigger_source: Trigger source type - + Returns: Human-readable source description """ source_map = { "AllResults": "all results", "AnyTimeSeries": "any time series", - "AllTimeSeries": "all time series" + "AllTimeSeries": "all time series", } - + return source_map.get(trigger_source, trigger_source.lower()) - + def _convert_monitor_type_to_friendly(self, monitor_type: str) -> str: """Convert API monitor type to user-friendly format. - + Args: monitor_type: API monitor type - + Returns: User-friendly monitor type """ type_map = { "MonitorsLibraryMonitor": "logs", "MetricsMonitor": "metrics", - "SliMonitor": "SLI" + "SliMonitor": "SLI", } - + return type_map.get(monitor_type, monitor_type) - + def _convert_notification_type_to_friendly(self, action_type: str) -> str: """Convert API notification type to user-friendly format. - + Args: action_type: API notification action type - + Returns: User-friendly notification type """ @@ -1548,119 +1592,130 @@ def _convert_notification_type_to_friendly(self, action_type: str) -> str: "EmailAction": "Email", "WebhookAction": "Webhook", "SlackAction": "Slack", - "PagerDutyAction": "PagerDuty" + "PagerDutyAction": "PagerDuty", } - + return type_map.get(action_type, action_type) - - def _extract_query_components(self, query: str, query_language: str) -> Dict[str, Any]: + + def _extract_query_components( + self, query: str, query_language: str + ) -> Dict[str, Any]: """Extract key components from monitor query. - + Args: query: Query string query_language: Query language type - + Returns: Dictionary with extracted query components """ - components = { - "keywords": [], - "operators": [], - "fields": [], - "functions": [] - } - + components = {"keywords": [], "operators": [], "fields": [], "functions": []} + # Basic keyword extraction (this could be enhanced with proper parsing) import re - + # Common SumoQL keywords if query_language == "sumoql": - keywords = re.findall(r'\b(where|and|or|by|count|sum|avg|max|min|parse|json|csv|timeslice)\b', query, re.IGNORECASE) + keywords = re.findall( + r"\b(where|and|or|by|count|sum|avg|max|min|parse|json|csv|timeslice)\b", + query, + re.IGNORECASE, + ) components["keywords"] = list(set(keywords)) - + # Extract field references (simple heuristic) - fields = re.findall(r'\b[a-zA-Z_][a-zA-Z0-9_]*\s*=', query) - components["fields"] = [field.replace('=', '').strip() for field in fields] - + fields = re.findall(r"\b[a-zA-Z_][a-zA-Z0-9_]*\s*=", query) + components["fields"] = [field.replace("=", "").strip() for field in fields] + return components - + def _generate_syntax_hints(self, query: str, query_language: str) -> List[str]: """Generate syntax highlighting hints for the query. - + Args: query: Query string query_language: Query language type - + Returns: List of syntax highlighting hints """ hints = [] - + # Add language-specific hints if query_language == "sumoql": hints.append("SumoQL query - supports where, parse, timeslice operators") elif query_language == "metrics": - hints.append("Metrics query - supports metric(), quantize(), and aggregation functions") + hints.append( + "Metrics query - supports metric(), quantize(), and aggregation functions" + ) elif query_language == "sli": hints.append("SLI query - supports success/total ratio calculations") - + # Add complexity hints - line_count = len(query.split('\n')) + line_count = len(query.split("\n")) if line_count > 10: hints.append("Multi-line query - consider breaking into smaller components") - + if len(query) > 1000: hints.append("Long query - consider optimization for better performance") - + return hints - + def _assess_configuration_health( self, formatted_triggers: Dict[str, Any], formatted_notifications: List[Dict[str, Any]], - monitor_data: Dict[str, Any] + monitor_data: Dict[str, Any], ) -> Dict[str, Any]: """Assess the health and completeness of monitor configuration. - + Args: formatted_triggers: Formatted trigger conditions formatted_notifications: Formatted notifications monitor_data: Raw monitor data - + Returns: Dictionary with configuration health assessment """ issues = [] warnings = [] recommendations = [] - + # Check for missing critical trigger if "Critical" not in formatted_triggers: warnings.append("No Critical trigger condition defined") - recommendations.append("Consider adding a Critical trigger for high-severity alerts") - + recommendations.append( + "Consider adding a Critical trigger for high-severity alerts" + ) + # Check for notifications if not formatted_notifications: issues.append("No notification actions configured") - recommendations.append("Add at least one notification action to receive alerts") - + recommendations.append( + "Add at least one notification action to receive alerts" + ) + # Check for missing description description = monitor_data.get("description", "") if not description or len(description.strip()) < 10: warnings.append("Monitor description is missing or too short") - recommendations.append("Add a detailed description explaining the monitor's purpose") - + recommendations.append( + "Add a detailed description explaining the monitor's purpose" + ) + # Check evaluation delay evaluation_delay = monitor_data.get("evaluationDelay", "0m") if evaluation_delay == "0m": - recommendations.append("Consider adding evaluation delay to reduce false positives") - + recommendations.append( + "Consider adding evaluation delay to reduce false positives" + ) + # Determine overall health score health_score = 100 health_score -= len(issues) * 20 # Issues are more serious health_score -= len(warnings) * 10 # Warnings are less serious health_score = max(0, health_score) # Don't go below 0 - + # Determine health level if health_score >= 90: health_level = "Excellent" @@ -1670,16 +1725,16 @@ def _assess_configuration_health( health_level = "Fair" else: health_level = "Poor" - + return { "score": health_score, "level": health_level, "issues": issues, "warnings": warnings, "recommendations": recommendations, - "summary": f"Configuration health: {health_level} ({health_score}/100)" + "summary": f"Configuration health: {health_level} ({health_score}/100)", } - + async def create_monitor( self, name: str, @@ -1690,10 +1745,10 @@ async def create_monitor( notifications: Optional[List[Dict[str, Any]]] = None, is_disabled: bool = False, evaluation_delay: str = "0m", - **kwargs + **kwargs, ) -> Dict[str, Any]: """Create new monitor with comprehensive validation and error handling. - + Args: name: Monitor name (required, max 255 characters) query: Monitor query string (required) @@ -1704,56 +1759,63 @@ async def create_monitor( is_disabled: Whether monitor should be created in disabled state evaluation_delay: Delay before evaluation (e.g., '5m') **kwargs: Additional monitor configuration parameters - + Returns: Dictionary containing created monitor information and validation results - + Raises: MonitorValidationError: If monitor configuration is invalid MonitorConfigurationError: If monitor configuration has issues MonitorPermissionError: If insufficient permissions MonitorError: If monitor creation fails """ + async def _create_monitor_impl(): # Initialize notifications if not provided if notifications is None: notifications_list = [] else: notifications_list = notifications - + # Step 1: Validate basic parameters if not name or not name.strip(): raise MonitorValidationError( "Monitor name is required and cannot be empty", field_name="name", field_value=name, - context=create_monitor_error_context("create_monitor") + context=create_monitor_error_context("create_monitor"), ) - + if len(name.strip()) > 255: raise MonitorValidationError( "Monitor name cannot exceed 255 characters", field_name="name", field_value=name, - context=create_monitor_error_context("create_monitor", name_length=len(name)) + context=create_monitor_error_context( + "create_monitor", name_length=len(name) + ), ) - + if not query or not query.strip(): raise MonitorValidationError( "Monitor query is required and cannot be empty", field_name="query", field_value=query, - context=create_monitor_error_context("create_monitor", monitor_name=name.strip()) + context=create_monitor_error_context( + "create_monitor", monitor_name=name.strip() + ), ) - + if not trigger_conditions: raise MonitorValidationError( "Trigger conditions are required", field_name="trigger_conditions", field_value=trigger_conditions, - context=create_monitor_error_context("create_monitor", monitor_name=name.strip()) + context=create_monitor_error_context( + "create_monitor", monitor_name=name.strip() + ), ) - + # Step 2: Validate monitor configuration using Pydantic models monitor_config_data = { "name": name.strip(), @@ -1763,17 +1825,18 @@ async def _create_monitor_impl(): "trigger_conditions": trigger_conditions, "notifications": notifications_list, "is_disabled": is_disabled, - "evaluation_delay": evaluation_delay + "evaluation_delay": evaluation_delay, } - + # Add any additional configuration from kwargs for key, value in kwargs.items(): if key not in monitor_config_data: monitor_config_data[key] = value - + # Validate using Pydantic model with enhanced error handling try: from ..models.monitor import MonitorConfig + validated_config = MonitorConfig(**monitor_config_data) except Exception as e: raise MonitorConfigurationError( @@ -1784,14 +1847,17 @@ async def _create_monitor_impl(): context=create_monitor_error_context( "create_monitor", monitor_name=name.strip(), - validation_error=str(e) - ) + validation_error=str(e), + ), ) from e - + # Step 3: Perform comprehensive validation checks try: validation_results = await self._perform_comprehensive_validation( - validated_config, query.strip(), trigger_conditions, notifications_list + validated_config, + query.strip(), + trigger_conditions, + notifications_list, ) except Exception as e: raise MonitorConfigurationError( @@ -1802,10 +1868,10 @@ async def _create_monitor_impl(): context=create_monitor_error_context( "create_monitor", monitor_name=name.strip(), - validation_stage="comprehensive" - ) + validation_stage="comprehensive", + ), ) from e - + if not validation_results["valid"]: # Collect all validation errors error_messages = validation_results["errors"] @@ -1817,13 +1883,15 @@ async def _create_monitor_impl(): context=create_monitor_error_context( "create_monitor", monitor_name=name.strip(), - validation_details=validation_results - ) + validation_details=validation_results, + ), ) - + # Step 4: Create the monitor using API client with rate limiting try: - created_monitor = await self.api_client.create_monitor(validated_config.dict()) + created_monitor = await self.api_client.create_monitor( + validated_config.dict() + ) except RateLimitError as e: raise RateLimitError( f"Rate limit exceeded while creating monitor '{name.strip()}': {e.message}", @@ -1832,10 +1900,13 @@ async def _create_monitor_impl(): context=create_monitor_error_context( "create_monitor", monitor_name=name.strip(), - rate_limit_info={"retry_after": e.retry_after, "limit_type": e.limit_type} - ) + rate_limit_info={ + "retry_after": e.retry_after, + "limit_type": e.limit_type, + }, + ), ) from e - + # Step 5: Format and enhance the response try: formatted_response = self._format_monitor_creation_response( @@ -1848,26 +1919,26 @@ async def _create_monitor_impl(): extra={ "monitor_id": created_monitor.get("id"), "monitor_name": created_monitor.get("name"), - "format_error": str(e) - } + "format_error": str(e), + }, ) formatted_response = { "success": True, "monitor": created_monitor, "validation_results": validation_results, - "formatting_warning": f"Response formatting failed: {str(e)}" + "formatting_warning": f"Response formatting failed: {str(e)}", } - + return formatted_response - + # Execute with comprehensive error handling return await self.error_handler.execute_with_error_handling( "create_monitor", _create_monitor_impl, monitor_name=name, - timeout_override=45.0 # Monitor creation may take longer + timeout_override=45.0, # Monitor creation may take longer ) - + async def _execute_monitor_operation_with_error_handling( self, operation: str, @@ -1875,13 +1946,13 @@ async def _execute_monitor_operation_with_error_handling( monitor_id: Optional[str] = None, monitor_name: Optional[str] = None, timeout_override: Optional[float] = None, - **kwargs + **kwargs, ) -> Any: """Execute monitor operation with comprehensive error handling and logging. - + This is a helper method that wraps monitor operations with consistent error handling, logging, and resilience patterns. - + Args: operation: Name of the monitor operation func: Function to execute @@ -1889,10 +1960,10 @@ async def _execute_monitor_operation_with_error_handling( monitor_name: Optional monitor name for context timeout_override: Optional timeout override **kwargs: Additional arguments for the function - + Returns: Function result with enhanced error context - + Raises: MonitorError: Enhanced monitor-specific error """ @@ -1902,12 +1973,12 @@ async def _execute_monitor_operation_with_error_handling( monitor_id=monitor_id, monitor_name=monitor_name, timeout_override=timeout_override, - **kwargs + **kwargs, ) - + def get_error_statistics(self) -> Dict[str, Any]: """Get comprehensive error statistics for monitoring and debugging. - + Returns: Dictionary containing error statistics, circuit breaker status, and operational health metrics @@ -1916,21 +1987,31 @@ def get_error_statistics(self) -> Dict[str, Any]: "monitor_tools_errors": self.error_handler.get_error_statistics(), "resilience_status": self.error_handler.resilient_client.get_health_status(), "timeout_configuration": { - "list_monitors": self.error_handler.timeout_manager.get_timeout("list_monitors"), - "get_monitor": self.error_handler.timeout_manager.get_timeout("get_monitor"), - "create_monitor": self.error_handler.timeout_manager.get_timeout("create_monitor"), - "update_monitor": self.error_handler.timeout_manager.get_timeout("update_monitor"), - "delete_monitor": self.error_handler.timeout_manager.get_timeout("delete_monitor") - } + "list_monitors": self.error_handler.timeout_manager.get_timeout( + "list_monitors" + ), + "get_monitor": self.error_handler.timeout_manager.get_timeout( + "get_monitor" + ), + "create_monitor": self.error_handler.timeout_manager.get_timeout( + "create_monitor" + ), + "update_monitor": self.error_handler.timeout_manager.get_timeout( + "update_monitor" + ), + "delete_monitor": self.error_handler.timeout_manager.get_timeout( + "delete_monitor" + ), + }, } - + def reset_error_statistics(self): """Reset error statistics for monitoring tools. - + This method is useful for testing or periodic cleanup of error metrics. """ self.error_handler.reset_error_statistics() - logger.info("Reset monitor tools error statistics") + logger.info("Reset monitor tools error statistics") async def update_monitor( self, @@ -1942,10 +2023,10 @@ async def update_monitor( notifications: Optional[List[Dict[str, Any]]] = None, is_disabled: Optional[bool] = None, evaluation_delay: Optional[str] = None, - **kwargs + **kwargs, ) -> Dict[str, Any]: """Update existing monitor configuration with comprehensive error handling. - + Args: monitor_id: Unique identifier for the monitor to update name: New monitor name (max 255 characters) @@ -1956,10 +2037,10 @@ async def update_monitor( is_disabled: Whether monitor should be disabled evaluation_delay: New evaluation delay (e.g., '5m') **kwargs: Additional monitor configuration parameters - + Returns: Dictionary containing updated monitor information and change confirmation - + Raises: MonitorValidationError: If monitor_id or update parameters are invalid MonitorNotFoundError: If monitor doesn't exist @@ -1968,31 +2049,38 @@ async def update_monitor( MonitorOperationError: If update conflicts exist MonitorError: If monitor update fails """ + async def _update_monitor_impl(): # Validate monitor_id with enhanced validation - validated_monitor_id = await validate_monitor_id(monitor_id, "update_monitor") - + validated_monitor_id = await validate_monitor_id( + monitor_id, "update_monitor" + ) + # Validate update parameters update_fields = { - "name": name, "description": description, "query": query, - "trigger_conditions": trigger_conditions, "notifications": notifications, - "is_disabled": is_disabled, "evaluation_delay": evaluation_delay + "name": name, + "description": description, + "query": query, + "trigger_conditions": trigger_conditions, + "notifications": notifications, + "is_disabled": is_disabled, + "evaluation_delay": evaluation_delay, } update_fields.update(kwargs) - + # Remove None values to get actual update fields actual_updates = {k: v for k, v in update_fields.items() if v is not None} - + if not actual_updates: raise MonitorValidationError( "No update parameters provided", context=create_monitor_error_context( "update_monitor", monitor_id=validated_monitor_id, - available_fields=list(update_fields.keys()) - ) + available_fields=list(update_fields.keys()), + ), ) - + # Validate name length if provided if name is not None and len(name.strip()) > 255: raise MonitorValidationError( @@ -2002,10 +2090,10 @@ async def _update_monitor_impl(): context=create_monitor_error_context( "update_monitor", monitor_id=validated_monitor_id, - name_length=len(name) - ) + name_length=len(name), + ), ) - + # Step 1: Get current monitor configuration for merging try: current_monitor = await self.get_monitor(validated_monitor_id) @@ -2020,10 +2108,10 @@ async def _update_monitor_impl(): context=create_monitor_error_context( "update_monitor", monitor_id=validated_monitor_id, - retrieval_error=str(e) - ) + retrieval_error=str(e), + ), ) from e - + # Step 2: Build update configuration with selective field modification try: update_config = await self._merge_monitor_configuration( @@ -2039,10 +2127,10 @@ async def _update_monitor_impl(): context=create_monitor_error_context( "update_monitor", monitor_id=validated_monitor_id, - merge_error=str(e) - ) + merge_error=str(e), + ), ) from e - + # Step 3: Validate updated configuration fields try: validation_results = await self._validate_monitor_updates( @@ -2058,10 +2146,10 @@ async def _update_monitor_impl(): context=create_monitor_error_context( "update_monitor", monitor_id=validated_monitor_id, - validation_error=str(e) - ) + validation_error=str(e), + ), ) from e - + if not validation_results["valid"]: error_messages = validation_results["errors"] raise MonitorConfigurationError( @@ -2073,10 +2161,10 @@ async def _update_monitor_impl(): context=create_monitor_error_context( "update_monitor", monitor_id=validated_monitor_id, - validation_details=validation_results - ) + validation_details=validation_results, + ), ) - + # Step 4: Handle monitor version conflicts and concurrent modifications try: version_check_result = await self._handle_version_conflicts( @@ -2091,10 +2179,10 @@ async def _update_monitor_impl(): context=create_monitor_error_context( "update_monitor", monitor_id=validated_monitor_id, - version_check_error=str(e) - ) + version_check_error=str(e), + ), ) from e - + if not version_check_result["can_update"]: raise MonitorOperationError( f"Monitor {validated_monitor_id} has been modified by another process", @@ -2105,10 +2193,10 @@ async def _update_monitor_impl(): "update_monitor", monitor_id=validated_monitor_id, conflict_type="version_mismatch", - current_version=version_check_result.get("current_version") - ) + current_version=version_check_result.get("current_version"), + ), ) - + # Step 5: Apply the update via API client try: updated_monitor = await self.api_client.update_monitor( @@ -2122,10 +2210,10 @@ async def _update_monitor_impl(): context=create_monitor_error_context( "update_monitor", monitor_id=validated_monitor_id, - monitor_name=current_config.get("name") - ) + monitor_name=current_config.get("name"), + ), ) from e - + # Step 6: Format and enhance the response try: formatted_response = await self._format_monitor_update_response( @@ -2138,64 +2226,74 @@ async def _update_monitor_impl(): extra={ "monitor_id": validated_monitor_id, "monitor_name": updated_monitor.get("name"), - "format_error": str(e) - } + "format_error": str(e), + }, ) formatted_response = { "success": True, "monitor": updated_monitor, "changes_applied": actual_updates, "validation_results": validation_results, - "formatting_warning": f"Response formatting failed: {str(e)}" + "formatting_warning": f"Response formatting failed: {str(e)}", } - + return formatted_response - + # Execute with comprehensive error handling return await self.error_handler.execute_with_error_handling( "update_monitor", _update_monitor_impl, monitor_id=monitor_id, - timeout_override=30.0 + timeout_override=30.0, ) logger.info( "Updating monitor with partial updates and validation", extra={ "monitor_id": monitor_id, - "update_fields": [k for k, v in { - "name": name, "description": description, "query": query, - "trigger_conditions": trigger_conditions, "notifications": notifications, - "is_disabled": is_disabled, "evaluation_delay": evaluation_delay - }.items() if v is not None] + list(kwargs.keys()) - } + "update_fields": [ + k + for k, v in { + "name": name, + "description": description, + "query": query, + "trigger_conditions": trigger_conditions, + "notifications": notifications, + "is_disabled": is_disabled, + "evaluation_delay": evaluation_delay, + }.items() + if v is not None + ] + + list(kwargs.keys()), + }, ) - + # Validate monitor_id if not monitor_id or not monitor_id.strip(): raise ValidationError( "Monitor ID cannot be empty", field_name="monitor_id", - field_value=monitor_id + field_value=monitor_id, ) - + monitor_id = monitor_id.strip() - + try: # Step 1: Get current monitor configuration for merging logger.debug(f"Retrieving current monitor configuration for {monitor_id}") current_monitor = await self.get_monitor(monitor_id) - + if not current_monitor.get("success", True): raise APIError( f"Failed to retrieve current monitor {monitor_id} for update", - context={"monitor_id": monitor_id, "operation": "update_monitor"} + context={"monitor_id": monitor_id, "operation": "update_monitor"}, ) - + current_config = current_monitor.get("monitor", current_monitor) - + # Step 2: Build update configuration with selective field modification update_config = await self._merge_monitor_configuration( - current_config, { + current_config, + { "name": name, "description": description, "query": query, @@ -2203,15 +2301,15 @@ async def _update_monitor_impl(): "notifications": notifications, "is_disabled": is_disabled, "evaluation_delay": evaluation_delay, - **kwargs - } + **kwargs, + }, ) - + # Step 3: Validate updated configuration fields validation_results = await self._validate_monitor_updates( current_config, update_config, monitor_id ) - + if not validation_results["valid"]: error_messages = validation_results["errors"] logger.error( @@ -2219,36 +2317,36 @@ async def _update_monitor_impl(): extra={ "monitor_id": monitor_id, "errors": error_messages, - "warnings": validation_results.get("warnings", []) - } + "warnings": validation_results.get("warnings", []), + }, ) raise ValidationError( f"Monitor update validation failed: {'; '.join(error_messages)}", validation_errors={ "configuration": error_messages, - "warnings": validation_results.get("warnings", []) + "warnings": validation_results.get("warnings", []), }, context={ "monitor_id": monitor_id, - "validation_details": validation_results - } + "validation_details": validation_results, + }, ) - + # Log any warnings if validation_results.get("warnings"): logger.warning( "Monitor update has warnings", extra={ "monitor_id": monitor_id, - "warnings": validation_results["warnings"] - } + "warnings": validation_results["warnings"], + }, ) - + # Step 4: Handle monitor version conflicts and concurrent modifications version_check_result = await self._handle_version_conflicts( current_config, update_config, monitor_id ) - + if not version_check_result["can_update"]: raise APIError( f"Monitor {monitor_id} has been modified by another process. Please refresh and try again.", @@ -2257,42 +2355,48 @@ async def _update_monitor_impl(): "monitor_id": monitor_id, "conflict_type": "version_mismatch", "current_version": version_check_result.get("current_version"), - "expected_version": version_check_result.get("expected_version") - } + "expected_version": version_check_result.get( + "expected_version" + ), + }, ) - + # Step 5: Apply the update via API client logger.info( f"Applying monitor update via API for {monitor_id}", extra={ "monitor_id": monitor_id, - "changed_fields": validation_results.get("changed_fields", []) - } + "changed_fields": validation_results.get("changed_fields", []), + }, + ) + + updated_monitor = await self.api_client.update_monitor( + monitor_id, update_config ) - - updated_monitor = await self.api_client.update_monitor(monitor_id, update_config) - + # Step 6: Return detailed change confirmation with applied modifications response = await self._format_monitor_update_response( current_config, updated_monitor, validation_results ) - + logger.info( "Monitor updated successfully", extra={ "monitor_id": monitor_id, "name": updated_monitor.get("name"), - "changed_fields": response.get("changes", {}).get("modified_fields", []), - "validation_warnings": len(validation_results.get("warnings", [])) - } + "changed_fields": response.get("changes", {}).get( + "modified_fields", [] + ), + "validation_warnings": len(validation_results.get("warnings", [])), + }, ) - + return response - + except ValidationError: # Re-raise validation errors as-is raise - + except APIError as e: # Handle API-specific errors with enhanced context logger.error( @@ -2300,18 +2404,18 @@ async def _update_monitor_impl(): extra={ "monitor_id": monitor_id, "error": str(e), - "status_code": getattr(e, 'status_code', None) - } + "status_code": getattr(e, "status_code", None), + }, ) - + # Provide more specific error messages based on status code - if hasattr(e, 'status_code'): + if hasattr(e, "status_code"): if e.status_code == 404: raise ValidationError( f"Monitor {monitor_id} not found", field_name="monitor_id", field_value=monitor_id, - context={"suggestion": "Verify the monitor ID is correct"} + context={"suggestion": "Verify the monitor ID is correct"}, ) from e elif e.status_code == 403: raise APIError( @@ -2320,8 +2424,8 @@ async def _update_monitor_impl(): context={ "operation": "update_monitor", "monitor_id": monitor_id, - "error_type": "permission_denied" - } + "error_type": "permission_denied", + }, ) from e elif e.status_code == 409: raise APIError( @@ -2330,56 +2434,51 @@ async def _update_monitor_impl(): context={ "monitor_id": monitor_id, "error_type": "concurrent_modification", - "suggestion": "Refresh the monitor and try the update again" - } + "suggestion": "Refresh the monitor and try the update again", + }, ) from e - + # Re-raise with enhanced context raise APIError( f"Failed to update monitor {monitor_id}: {e.message}", - status_code=getattr(e, 'status_code', None), - request_id=getattr(e, 'request_id', None), - context={ - "operation": "update_monitor", - "monitor_id": monitor_id - } + status_code=getattr(e, "status_code", None), + request_id=getattr(e, "request_id", None), + context={"operation": "update_monitor", "monitor_id": monitor_id}, ) from e - + except Exception as e: logger.error( "Unexpected error updating monitor", extra={ "monitor_id": monitor_id, "error": str(e), - "error_type": type(e).__name__ - } + "error_type": type(e).__name__, + }, ) raise APIError( f"Unexpected error updating monitor {monitor_id}: {str(e)}", context={ "operation": "update_monitor", "monitor_id": monitor_id, - "error_type": type(e).__name__ - } + "error_type": type(e).__name__, + }, ) from e async def _merge_monitor_configuration( - self, - current_config: Dict[str, Any], - updates: Dict[str, Any] + self, current_config: Dict[str, Any], updates: Dict[str, Any] ) -> Dict[str, Any]: """Implement configuration merging for partial updates. - + Args: current_config: Current monitor configuration updates: Update parameters (may contain None values) - + Returns: Merged configuration with updates applied """ # Start with current configuration merged_config = current_config.copy() - + # Apply non-None updates for key, value in updates.items(): if value is not None: @@ -2389,30 +2488,30 @@ async def _merge_monitor_configuration( else: # For simple fields, update directly merged_config[key] = value - + logger.debug( "Merged monitor configuration", extra={ "updated_fields": [k for k, v in updates.items() if v is not None], - "total_fields": len(merged_config) - } + "total_fields": len(merged_config), + }, ) - + return merged_config async def _validate_monitor_updates( self, current_config: Dict[str, Any], update_config: Dict[str, Any], - monitor_id: str + monitor_id: str, ) -> Dict[str, Any]: """Validate updated configuration fields before applying changes. - + Args: current_config: Current monitor configuration update_config: Updated configuration to validate monitor_id: Monitor ID for context - + Returns: Dictionary containing validation results """ @@ -2420,9 +2519,9 @@ async def _validate_monitor_updates( "valid": True, "errors": [], "warnings": [], - "changed_fields": [] + "changed_fields": [], } - + try: # Identify changed fields changed_fields = [] @@ -2430,61 +2529,79 @@ async def _validate_monitor_updates( current_value = current_config.get(key) if current_value != new_value: changed_fields.append(key) - + validation_result["changed_fields"] = changed_fields - + if not changed_fields: - validation_result["warnings"].append("No changes detected in update request") + validation_result["warnings"].append( + "No changes detected in update request" + ) return validation_result - + # Validate updated trigger conditions and thresholds if "trigger_conditions" in changed_fields: trigger_validation = await self._validate_updated_trigger_conditions( update_config.get("trigger_conditions", {}), - current_config.get("trigger_conditions", {}) + current_config.get("trigger_conditions", {}), ) validation_result["errors"].extend(trigger_validation.get("errors", [])) - validation_result["warnings"].extend(trigger_validation.get("warnings", [])) - + validation_result["warnings"].extend( + trigger_validation.get("warnings", []) + ) + # Handle notification configuration updates and validation if "notifications" in changed_fields: notification_validation = await self._validate_updated_notifications( update_config.get("notifications", []), - current_config.get("notifications", []) + current_config.get("notifications", []), + ) + validation_result["errors"].extend( + notification_validation.get("errors", []) ) - validation_result["errors"].extend(notification_validation.get("errors", [])) - validation_result["warnings"].extend(notification_validation.get("warnings", [])) - + validation_result["warnings"].extend( + notification_validation.get("warnings", []) + ) + # Add schedule modification with validation if "evaluation_delay" in changed_fields: schedule_validation = await self._validate_schedule_modification( update_config.get("evaluation_delay"), - current_config.get("evaluation_delay") + current_config.get("evaluation_delay"), + ) + validation_result["errors"].extend( + schedule_validation.get("errors", []) + ) + validation_result["warnings"].extend( + schedule_validation.get("warnings", []) ) - validation_result["errors"].extend(schedule_validation.get("errors", [])) - validation_result["warnings"].extend(schedule_validation.get("warnings", [])) - + # Validate query changes if present if "query" in changed_fields: query_validation = await self._validate_query_syntax( update_config.get("query", ""), - update_config.get("type", current_config.get("monitorType", "MonitorsLibraryMonitor")) + update_config.get( + "type", + current_config.get("monitorType", "MonitorsLibraryMonitor"), + ), ) validation_result["errors"].extend(query_validation.get("errors", [])) - validation_result["warnings"].extend(query_validation.get("warnings", [])) - + validation_result["warnings"].extend( + query_validation.get("warnings", []) + ) + # Validate name changes if "name" in changed_fields: name_validation = self._validate_monitor_name_update( - update_config.get("name"), - current_config.get("name") + update_config.get("name"), current_config.get("name") ) validation_result["errors"].extend(name_validation.get("errors", [])) - validation_result["warnings"].extend(name_validation.get("warnings", [])) - + validation_result["warnings"].extend( + name_validation.get("warnings", []) + ) + # Set overall validity validation_result["valid"] = len(validation_result["errors"]) == 0 - + logger.debug( "Monitor update validation completed", extra={ @@ -2492,44 +2609,39 @@ async def _validate_monitor_updates( "changed_fields": changed_fields, "valid": validation_result["valid"], "error_count": len(validation_result["errors"]), - "warning_count": len(validation_result["warnings"]) - } + "warning_count": len(validation_result["warnings"]), + }, ) - + return validation_result - + except Exception as e: logger.error( "Error during monitor update validation", extra={ "monitor_id": monitor_id, "error": str(e), - "error_type": type(e).__name__ - } + "error_type": type(e).__name__, + }, ) validation_result["valid"] = False validation_result["errors"].append(f"Validation error: {str(e)}") return validation_result async def _validate_updated_trigger_conditions( - self, - new_conditions: Dict[str, Any], - current_conditions: Dict[str, Any] + self, new_conditions: Dict[str, Any], current_conditions: Dict[str, Any] ) -> Dict[str, Any]: """Validate updated trigger conditions and thresholds. - + Args: new_conditions: New trigger conditions current_conditions: Current trigger conditions - + Returns: Dictionary containing validation results """ - validation_result = { - "errors": [], - "warnings": [] - } - + validation_result = {"errors": [], "warnings": []} + try: # Validate each trigger condition for trigger_type, condition in new_conditions.items(): @@ -2538,7 +2650,7 @@ async def _validate_updated_trigger_conditions( f"Trigger condition '{trigger_type}' must be a dictionary" ) continue - + # Validate threshold threshold = condition.get("threshold") if threshold is None: @@ -2549,16 +2661,21 @@ async def _validate_updated_trigger_conditions( validation_result["errors"].append( f"Trigger condition '{trigger_type}' threshold must be a number" ) - + # Validate threshold type threshold_type = condition.get("threshold_type") - valid_threshold_types = ["GreaterThan", "LessThan", "GreaterThanOrEqual", "LessThanOrEqual"] + valid_threshold_types = [ + "GreaterThan", + "LessThan", + "GreaterThanOrEqual", + "LessThanOrEqual", + ] if threshold_type not in valid_threshold_types: validation_result["errors"].append( f"Trigger condition '{trigger_type}' has invalid threshold_type. " f"Must be one of: {', '.join(valid_threshold_types)}" ) - + # Validate time range time_range = condition.get("time_range") if not time_range: @@ -2569,142 +2686,160 @@ async def _validate_updated_trigger_conditions( validation_result["errors"].append( f"Trigger condition '{trigger_type}' time_range must be a non-empty string" ) - + # Compare with current conditions for warnings current_condition = current_conditions.get(trigger_type, {}) if current_condition: current_threshold = current_condition.get("threshold") - if current_threshold and threshold and abs(threshold - current_threshold) > (current_threshold * 0.5): + if ( + current_threshold + and threshold + and abs(threshold - current_threshold) + > (current_threshold * 0.5) + ): validation_result["warnings"].append( f"Trigger condition '{trigger_type}' threshold changed significantly " f"from {current_threshold} to {threshold}" ) - + return validation_result - + except Exception as e: - validation_result["errors"].append(f"Error validating trigger conditions: {str(e)}") + validation_result["errors"].append( + f"Error validating trigger conditions: {str(e)}" + ) return validation_result async def _validate_updated_notifications( self, new_notifications: List[Dict[str, Any]], - current_notifications: List[Dict[str, Any]] + current_notifications: List[Dict[str, Any]], ) -> Dict[str, Any]: """Handle notification configuration updates and validation. - + Args: new_notifications: New notification configurations current_notifications: Current notification configurations - + Returns: Dictionary containing validation results """ - validation_result = { - "errors": [], - "warnings": [] - } - + validation_result = {"errors": [], "warnings": []} + try: if not isinstance(new_notifications, list): validation_result["errors"].append("Notifications must be a list") return validation_result - + # Validate each notification for i, notification in enumerate(new_notifications): if not isinstance(notification, dict): - validation_result["errors"].append(f"Notification {i} must be a dictionary") + validation_result["errors"].append( + f"Notification {i} must be a dictionary" + ) continue - + # Validate action type action_type = notification.get("action_type") - valid_action_types = ["EmailAction", "WebhookAction", "SlackAction", "PagerDutyAction"] + valid_action_types = [ + "EmailAction", + "WebhookAction", + "SlackAction", + "PagerDutyAction", + ] if action_type not in valid_action_types: validation_result["errors"].append( f"Notification {i} has invalid action_type. " f"Must be one of: {', '.join(valid_action_types)}" ) - + # Validate based on action type if action_type == "EmailAction": recipients = notification.get("recipients", []) if not recipients: - validation_result["errors"].append(f"Email notification {i} missing recipients") + validation_result["errors"].append( + f"Email notification {i} missing recipients" + ) elif not isinstance(recipients, list): - validation_result["errors"].append(f"Email notification {i} recipients must be a list") + validation_result["errors"].append( + f"Email notification {i} recipients must be a list" + ) else: # Validate email addresses import re - email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + + email_pattern = ( + r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" + ) for email in recipients: if not re.match(email_pattern, email): validation_result["errors"].append( f"Email notification {i} has invalid email address: {email}" ) - + elif action_type == "WebhookAction": webhook_url = notification.get("webhook_url") if not webhook_url: - validation_result["errors"].append(f"Webhook notification {i} missing webhook_url") - elif not webhook_url.startswith(('http://', 'https://')): + validation_result["errors"].append( + f"Webhook notification {i} missing webhook_url" + ) + elif not webhook_url.startswith(("http://", "https://")): validation_result["errors"].append( f"Webhook notification {i} URL must start with http:// or https://" ) - + # Compare with current notifications for warnings if len(new_notifications) != len(current_notifications): validation_result["warnings"].append( f"Number of notifications changed from {len(current_notifications)} to {len(new_notifications)}" ) - + return validation_result - + except Exception as e: - validation_result["errors"].append(f"Error validating notifications: {str(e)}") + validation_result["errors"].append( + f"Error validating notifications: {str(e)}" + ) return validation_result async def _validate_schedule_modification( - self, - new_delay: Optional[str], - current_delay: Optional[str] + self, new_delay: Optional[str], current_delay: Optional[str] ) -> Dict[str, Any]: """Add schedule modification with validation. - + Args: new_delay: New evaluation delay current_delay: Current evaluation delay - + Returns: Dictionary containing validation results """ - validation_result = { - "errors": [], - "warnings": [] - } - + validation_result = {"errors": [], "warnings": []} + try: if new_delay is not None: # Validate delay format import re - if not re.match(r'^\d+[smh]$', new_delay): + + if not re.match(r"^\d+[smh]$", new_delay): validation_result["errors"].append( "Evaluation delay must be in format like '5m', '1h', '30s'" ) else: # Parse delay value for warnings - delay_match = re.match(r'^(\d+)([smh])$', new_delay) + delay_match = re.match(r"^(\d+)([smh])$", new_delay) if delay_match: value, unit = delay_match.groups() value = int(value) - + # Convert to minutes for comparison - if unit == 's': + if unit == "s": delay_minutes = value / 60 - elif unit == 'm': + elif unit == "m": delay_minutes = value - elif unit == 'h': + elif unit == "h": delay_minutes = value * 60 - + if delay_minutes > 60: # More than 1 hour validation_result["warnings"].append( f"Evaluation delay of {new_delay} is quite long and may delay alert notifications" @@ -2713,151 +2848,151 @@ async def _validate_schedule_modification( validation_result["warnings"].append( f"Evaluation delay of {new_delay} is very short and may cause frequent evaluations" ) - + return validation_result - + except Exception as e: - validation_result["errors"].append(f"Error validating schedule modification: {str(e)}") + validation_result["errors"].append( + f"Error validating schedule modification: {str(e)}" + ) return validation_result def _validate_monitor_name_update( - self, - new_name: Optional[str], - current_name: Optional[str] + self, new_name: Optional[str], current_name: Optional[str] ) -> Dict[str, Any]: """Validate monitor name updates. - + Args: new_name: New monitor name current_name: Current monitor name - + Returns: Dictionary containing validation results """ - validation_result = { - "errors": [], - "warnings": [] - } - + validation_result = {"errors": [], "warnings": []} + try: if new_name is not None: # Validate name length and content if not new_name.strip(): validation_result["errors"].append("Monitor name cannot be empty") elif len(new_name) > 255: - validation_result["errors"].append("Monitor name cannot exceed 255 characters") + validation_result["errors"].append( + "Monitor name cannot exceed 255 characters" + ) else: # Check for invalid characters import re + if re.search(r'[<>:"/\\|?*]', new_name): - validation_result["errors"].append("Monitor name contains invalid characters") - + validation_result["errors"].append( + "Monitor name contains invalid characters" + ) + # Warning for significant name changes if current_name and new_name.lower() != current_name.lower(): validation_result["warnings"].append( f"Monitor name changing from '{current_name}' to '{new_name}'" ) - + return validation_result - + except Exception as e: - validation_result["errors"].append(f"Error validating name update: {str(e)}") + validation_result["errors"].append( + f"Error validating name update: {str(e)}" + ) return validation_result async def _handle_version_conflicts( self, current_config: Dict[str, Any], update_config: Dict[str, Any], - monitor_id: str + monitor_id: str, ) -> Dict[str, Any]: """Handle monitor version conflicts and concurrent modifications. - + Args: current_config: Current monitor configuration update_config: Updated configuration monitor_id: Monitor ID - + Returns: Dictionary indicating if update can proceed """ try: # Get fresh monitor data to check for concurrent modifications fresh_monitor = await self.api_client.get_monitor(monitor_id) - + current_version = current_config.get("version", 0) fresh_version = fresh_monitor.get("version", 0) - + if fresh_version > current_version: logger.warning( f"Monitor {monitor_id} version conflict detected", extra={ "current_version": current_version, "fresh_version": fresh_version, - "monitor_id": monitor_id - } + "monitor_id": monitor_id, + }, ) return { "can_update": False, "current_version": current_version, "expected_version": fresh_version, - "conflict_detected": True + "conflict_detected": True, } - + return { "can_update": True, "current_version": current_version, "expected_version": fresh_version, - "conflict_detected": False + "conflict_detected": False, } - + except Exception as e: logger.warning( f"Could not check version conflicts for monitor {monitor_id}: {str(e)}", - extra={"monitor_id": monitor_id, "error": str(e)} + extra={"monitor_id": monitor_id, "error": str(e)}, ) # Allow update to proceed if version check fails - return { - "can_update": True, - "version_check_failed": True, - "error": str(e) - } + return {"can_update": True, "version_check_failed": True, "error": str(e)} async def _format_monitor_update_response( self, original_config: Dict[str, Any], updated_monitor: Dict[str, Any], - validation_results: Dict[str, Any] + validation_results: Dict[str, Any], ) -> Dict[str, Any]: """Return detailed change confirmation with applied modifications. - + Args: original_config: Original monitor configuration updated_monitor: Updated monitor from API validation_results: Validation results - + Returns: Formatted response with change details """ try: # Identify what actually changed changed_fields = validation_results.get("changed_fields", []) - + # Build change summary changes = { "modified_fields": changed_fields, "field_changes": {}, - "summary": f"Updated {len(changed_fields)} field(s)" + "summary": f"Updated {len(changed_fields)} field(s)", } - + # Detail specific changes for field in changed_fields: original_value = original_config.get(field) new_value = updated_monitor.get(field) changes["field_changes"][field] = { "from": original_value, - "to": new_value + "to": new_value, } - + # Format the response response = { "success": True, @@ -2866,7 +3001,7 @@ async def _format_monitor_update_response( "changes": changes, "validation": { "warnings": validation_results.get("warnings", []), - "warnings_count": len(validation_results.get("warnings", [])) + "warnings_count": len(validation_results.get("warnings", [])), }, "metadata": { "monitor_id": updated_monitor.get("id"), @@ -2874,94 +3009,108 @@ async def _format_monitor_update_response( "type": updated_monitor.get("monitorType"), "version": updated_monitor.get("version"), "modified_at": updated_monitor.get("modifiedAt"), - "modified_by": updated_monitor.get("modifiedBy") - } + "modified_by": updated_monitor.get("modifiedBy"), + }, } - + # Add recommendations response["recommendations"] = self._generate_update_recommendations( changes, validation_results ) - + return response - + except Exception as e: logger.error( "Error formatting monitor update response", - extra={"error": str(e), "error_type": type(e).__name__} + extra={"error": str(e), "error_type": type(e).__name__}, ) # Return basic response if formatting fails return { "success": True, "message": "Monitor updated successfully", "monitor": updated_monitor, - "error": f"Response formatting error: {str(e)}" + "error": f"Response formatting error: {str(e)}", } def _generate_update_recommendations( - self, - changes: Dict[str, Any], - validation_results: Dict[str, Any] + self, changes: Dict[str, Any], validation_results: Dict[str, Any] ) -> List[str]: """Generate recommendations based on the update changes. - + Args: changes: Dictionary of changes made validation_results: Validation results - + Returns: List of recommendation strings """ recommendations = [] - + changed_fields = changes.get("modified_fields", []) - + # Recommendations based on changed fields if "trigger_conditions" in changed_fields: - recommendations.append("Test the updated trigger conditions to ensure they work as expected") - + recommendations.append( + "Test the updated trigger conditions to ensure they work as expected" + ) + if "notifications" in changed_fields: - recommendations.append("Verify notification destinations are working correctly") - + recommendations.append( + "Verify notification destinations are working correctly" + ) + if "query" in changed_fields: - recommendations.append("Monitor the updated query performance and result accuracy") - + recommendations.append( + "Monitor the updated query performance and result accuracy" + ) + if "is_disabled" in changed_fields: field_changes = changes.get("field_changes", {}) disabled_change = field_changes.get("is_disabled", {}) if disabled_change.get("to") is True: - recommendations.append("Monitor is now disabled - enable when ready to resume monitoring") + recommendations.append( + "Monitor is now disabled - enable when ready to resume monitoring" + ) elif disabled_change.get("to") is False: - recommendations.append("Monitor is now enabled and will start evaluating immediately") - + recommendations.append( + "Monitor is now enabled and will start evaluating immediately" + ) + # Recommendations based on warnings warnings = validation_results.get("warnings", []) if warnings: - recommendations.append(f"Review {len(warnings)} configuration warnings for optimization opportunities") - + recommendations.append( + f"Review {len(warnings)} configuration warnings for optimization opportunities" + ) + # General recommendations if len(changed_fields) > 3: - recommendations.append("Consider testing the monitor thoroughly due to multiple configuration changes") - - recommendations.append("Check monitor status and alert history to verify the update is working correctly") - + recommendations.append( + "Consider testing the monitor thoroughly due to multiple configuration changes" + ) + + recommendations.append( + "Check monitor status and alert history to verify the update is working correctly" + ) + return recommendations async def _perform_comprehensive_validation( self, - config: 'MonitorConfig', + config: "MonitorConfig", query: str, trigger_conditions: Dict[str, Any], - notifications: List[Dict[str, Any]] + notifications: List[Dict[str, Any]], ) -> Dict[str, Any]: """Perform comprehensive validation of monitor configuration. - + Args: config: Validated Pydantic monitor configuration query: Monitor query string trigger_conditions: Trigger conditions dictionary notifications: List of notification configurations - + Returns: Dictionary containing validation results with errors and warnings """ @@ -2971,81 +3120,89 @@ async def _perform_comprehensive_validation( "warnings": [], "query_syntax_valid": None, "trigger_conditions_valid": None, - "notifications_valid": None + "notifications_valid": None, } - + # Validate query syntax query_validation = await self._validate_query_syntax(query, config.type.value) validation_result["query_syntax_valid"] = query_validation["valid"] if not query_validation["valid"]: validation_result["errors"].extend(query_validation["errors"]) validation_result["warnings"].extend(query_validation.get("warnings", [])) - + # Validate trigger conditions trigger_validation = self._validate_trigger_conditions(trigger_conditions) validation_result["trigger_conditions_valid"] = trigger_validation["valid"] if not trigger_validation["valid"]: validation_result["errors"].extend(trigger_validation["errors"]) validation_result["warnings"].extend(trigger_validation.get("warnings", [])) - + # Validate notification configurations - notification_validation = await self._validate_notification_configurations(notifications) + notification_validation = await self._validate_notification_configurations( + notifications + ) validation_result["notifications_valid"] = notification_validation["valid"] if not notification_validation["valid"]: validation_result["errors"].extend(notification_validation["errors"]) - validation_result["warnings"].extend(notification_validation.get("warnings", [])) - + validation_result["warnings"].extend( + notification_validation.get("warnings", []) + ) + # Perform cross-validation checks - cross_validation = self._perform_cross_validation_checks(config, trigger_conditions, notifications) + cross_validation = self._perform_cross_validation_checks( + config, trigger_conditions, notifications + ) validation_result["warnings"].extend(cross_validation.get("warnings", [])) if cross_validation.get("errors"): validation_result["errors"].extend(cross_validation["errors"]) - + # Set overall validity validation_result["valid"] = len(validation_result["errors"]) == 0 - + logger.debug( "Comprehensive validation completed", extra={ "valid": validation_result["valid"], "error_count": len(validation_result["errors"]), - "warning_count": len(validation_result["warnings"]) - } + "warning_count": len(validation_result["warnings"]), + }, ) - + return validation_result - - async def _validate_query_syntax(self, query: str, monitor_type: str) -> Dict[str, Any]: + + async def _validate_query_syntax( + self, query: str, monitor_type: str + ) -> Dict[str, Any]: """Validate monitor query syntax and structure. - + Args: query: Monitor query string monitor_type: Type of monitor (affects query validation) - + Returns: Dictionary containing query validation results """ - validation_result = { - "valid": True, - "errors": [], - "warnings": [] - } - + validation_result = {"valid": True, "errors": [], "warnings": []} + # Basic query validation if not query or not query.strip(): validation_result["errors"].append("Query cannot be empty") validation_result["valid"] = False return validation_result - + query = query.strip() - + # Check query length if len(query) > 10000: # Reasonable limit for query length - validation_result["errors"].append("Query is too long (maximum 10,000 characters)") + validation_result["errors"].append( + "Query is too long (maximum 10,000 characters)" + ) validation_result["valid"] = False elif len(query) > 5000: - validation_result["warnings"].append("Query is very long, consider simplifying for better performance") - + validation_result["warnings"].append( + "Query is very long, consider simplifying for better performance" + ) + # Validate based on monitor type if monitor_type == "MonitorsLibraryMonitor": # SumoQL validation @@ -3054,7 +3211,7 @@ async def _validate_query_syntax(self, query: str, monitor_type: str) -> Dict[st validation_result["warnings"].extend(sumoql_validation.get("warnings", [])) if sumoql_validation.get("errors"): validation_result["valid"] = False - + elif monitor_type == "MetricsMonitor": # Metrics query validation metrics_validation = self._validate_metrics_query_syntax(query) @@ -3062,7 +3219,7 @@ async def _validate_query_syntax(self, query: str, monitor_type: str) -> Dict[st validation_result["warnings"].extend(metrics_validation.get("warnings", [])) if metrics_validation.get("errors"): validation_result["valid"] = False - + elif monitor_type == "SliMonitor": # SLI query validation sli_validation = self._validate_sli_query_syntax(query) @@ -3070,46 +3227,61 @@ async def _validate_query_syntax(self, query: str, monitor_type: str) -> Dict[st validation_result["warnings"].extend(sli_validation.get("warnings", [])) if sli_validation.get("errors"): validation_result["valid"] = False - + # Check for common query issues common_issues = self._check_common_query_issues(query) validation_result["warnings"].extend(common_issues) - + return validation_result - + def _validate_sumoql_syntax(self, query: str) -> Dict[str, Any]: """Validate SumoQL query syntax. - + Args: query: SumoQL query string - + Returns: Dictionary containing SumoQL validation results """ - validation_result = { - "errors": [], - "warnings": [] - } - + validation_result = {"errors": [], "warnings": []} + # Check for basic SumoQL structure query_lower = query.lower() - + # Check for required elements in log queries - if not any(keyword in query_lower for keyword in ['_sourcehost', '_sourcename', '_sourcecategory', '_index', '*']): - validation_result["warnings"].append("Query may be too restrictive - consider adding source filters") - + if not any( + keyword in query_lower + for keyword in [ + "_sourcehost", + "_sourcename", + "_sourcecategory", + "_index", + "*", + ] + ): + validation_result["warnings"].append( + "Query may be too restrictive - consider adding source filters" + ) + # Check for potentially expensive operations - if 'parse regex' in query_lower and 'where' not in query_lower: - validation_result["warnings"].append("Regex parsing without WHERE clause may be expensive") - + if "parse regex" in query_lower and "where" not in query_lower: + validation_result["warnings"].append( + "Regex parsing without WHERE clause may be expensive" + ) + # Check for proper aggregation in monitor queries - if not any(agg in query_lower for agg in ['count', 'sum', 'avg', 'max', 'min', 'timeslice']): - validation_result["warnings"].append("Monitor queries typically need aggregation functions") - + if not any( + agg in query_lower + for agg in ["count", "sum", "avg", "max", "min", "timeslice"] + ): + validation_result["warnings"].append( + "Monitor queries typically need aggregation functions" + ) + # Check for balanced parentheses - if query.count('(') != query.count(')'): + if query.count("(") != query.count(")"): validation_result["errors"].append("Unbalanced parentheses in query") - + # Check for balanced quotes single_quotes = query.count("'") - query.count("\\'") double_quotes = query.count('"') - query.count('\\"') @@ -3117,184 +3289,215 @@ def _validate_sumoql_syntax(self, query: str) -> Dict[str, Any]: validation_result["errors"].append("Unbalanced single quotes in query") if double_quotes % 2 != 0: validation_result["errors"].append("Unbalanced double quotes in query") - + return validation_result - + def _validate_metrics_query_syntax(self, query: str) -> Dict[str, Any]: """Validate metrics query syntax. - + Args: query: Metrics query string - + Returns: Dictionary containing metrics validation results """ - validation_result = { - "errors": [], - "warnings": [] - } - + validation_result = {"errors": [], "warnings": []} + query_lower = query.lower() - + # Check for metric() function - if 'metric=' not in query_lower and 'metric(' not in query_lower: - validation_result["errors"].append("Metrics queries must include metric specification") - + if "metric=" not in query_lower and "metric(" not in query_lower: + validation_result["errors"].append( + "Metrics queries must include metric specification" + ) + # Check for proper aggregation - if not any(agg in query_lower for agg in ['avg', 'sum', 'max', 'min', 'count', 'quantize']): - validation_result["warnings"].append("Metrics queries typically need aggregation functions") - + if not any( + agg in query_lower + for agg in ["avg", "sum", "max", "min", "count", "quantize"] + ): + validation_result["warnings"].append( + "Metrics queries typically need aggregation functions" + ) + # Check for time quantization - if 'quantize' not in query_lower: - validation_result["warnings"].append("Consider using quantize() for better performance in metrics queries") - + if "quantize" not in query_lower: + validation_result["warnings"].append( + "Consider using quantize() for better performance in metrics queries" + ) + return validation_result - + def _validate_sli_query_syntax(self, query: str) -> Dict[str, Any]: """Validate SLI query syntax. - + Args: query: SLI query string - + Returns: Dictionary containing SLI validation results """ - validation_result = { - "errors": [], - "warnings": [] - } - + validation_result = {"errors": [], "warnings": []} + query_lower = query.lower() - + # SLI queries should have success and total calculations - if 'success' not in query_lower and 'error' not in query_lower: - validation_result["warnings"].append("SLI queries typically define success or error conditions") - + if "success" not in query_lower and "error" not in query_lower: + validation_result["warnings"].append( + "SLI queries typically define success or error conditions" + ) + # Check for ratio calculations - if '/' not in query and 'ratio' not in query_lower: - validation_result["warnings"].append("SLI queries typically calculate success/total ratios") - + if "/" not in query and "ratio" not in query_lower: + validation_result["warnings"].append( + "SLI queries typically calculate success/total ratios" + ) + return validation_result - + def _check_common_query_issues(self, query: str) -> List[str]: """Check for common query issues that might affect performance or accuracy. - + Args: query: Query string to check - + Returns: List of warning messages for common issues """ warnings = [] query_lower = query.lower() - + # Check for wildcard usage - if query.count('*') > 5: + if query.count("*") > 5: warnings.append("Excessive wildcard usage may impact query performance") - + # Check for very broad time ranges in relative queries - if any(broad_range in query_lower for broad_range in ['-30d', '-60d', '-90d']): + if any(broad_range in query_lower for broad_range in ["-30d", "-60d", "-90d"]): warnings.append("Very broad time ranges may impact query performance") - + # Check for missing field extraction - if 'json' in query_lower and 'auto' not in query_lower and '|' not in query: + if "json" in query_lower and "auto" not in query_lower and "|" not in query: warnings.append("JSON parsing without field extraction may be inefficient") - + # Check for potential case sensitivity issues - if any(case_sensitive in query for case_sensitive in ['WHERE', 'AND', 'OR']): + if any(case_sensitive in query for case_sensitive in ["WHERE", "AND", "OR"]): warnings.append("Consider using lowercase operators for consistency") - + return warnings - - def _validate_trigger_conditions(self, trigger_conditions: Dict[str, Any]) -> Dict[str, Any]: + + def _validate_trigger_conditions( + self, trigger_conditions: Dict[str, Any] + ) -> Dict[str, Any]: """Validate trigger condition configurations. - + Args: trigger_conditions: Dictionary of trigger conditions by severity level - + Returns: Dictionary containing trigger validation results """ - validation_result = { - "valid": True, - "errors": [], - "warnings": [] - } - + validation_result = {"valid": True, "errors": [], "warnings": []} + if not trigger_conditions: - validation_result["errors"].append("At least one trigger condition must be specified") + validation_result["errors"].append( + "At least one trigger condition must be specified" + ) validation_result["valid"] = False return validation_result - + # Validate each trigger condition valid_trigger_types = ["Critical", "Warning", "MissingData"] - + for trigger_type, condition in trigger_conditions.items(): if trigger_type not in valid_trigger_types: - validation_result["errors"].append(f"Invalid trigger type: {trigger_type}") + validation_result["errors"].append( + f"Invalid trigger type: {trigger_type}" + ) validation_result["valid"] = False continue - + # Validate trigger condition structure - condition_validation = self._validate_single_trigger_condition(trigger_type, condition) + condition_validation = self._validate_single_trigger_condition( + trigger_type, condition + ) validation_result["errors"].extend(condition_validation.get("errors", [])) - validation_result["warnings"].extend(condition_validation.get("warnings", [])) + validation_result["warnings"].extend( + condition_validation.get("warnings", []) + ) if condition_validation.get("errors"): validation_result["valid"] = False - + # Cross-validate trigger conditions - cross_trigger_validation = self._cross_validate_trigger_conditions(trigger_conditions) - validation_result["warnings"].extend(cross_trigger_validation.get("warnings", [])) - + cross_trigger_validation = self._cross_validate_trigger_conditions( + trigger_conditions + ) + validation_result["warnings"].extend( + cross_trigger_validation.get("warnings", []) + ) + # Check for recommended trigger configurations if "Critical" not in trigger_conditions: - validation_result["warnings"].append("Consider adding a Critical trigger condition for high-severity alerts") - + validation_result["warnings"].append( + "Consider adding a Critical trigger condition for high-severity alerts" + ) + return validation_result - - def _validate_single_trigger_condition(self, trigger_type: str, condition: Dict[str, Any]) -> Dict[str, Any]: + + def _validate_single_trigger_condition( + self, trigger_type: str, condition: Dict[str, Any] + ) -> Dict[str, Any]: """Validate a single trigger condition. - + Args: trigger_type: Type of trigger (Critical, Warning, MissingData) condition: Trigger condition configuration - + Returns: Dictionary containing validation results for this trigger """ - validation_result = { - "errors": [], - "warnings": [] - } - + validation_result = {"errors": [], "warnings": []} + # Required fields required_fields = ["threshold", "threshold_type", "time_range"] for field in required_fields: if field not in condition: - validation_result["errors"].append(f"{trigger_type} trigger missing required field: {field}") - + validation_result["errors"].append( + f"{trigger_type} trigger missing required field: {field}" + ) + # Validate threshold if "threshold" in condition: threshold = condition["threshold"] if not isinstance(threshold, (int, float)): - validation_result["errors"].append(f"{trigger_type} trigger threshold must be a number") + validation_result["errors"].append( + f"{trigger_type} trigger threshold must be a number" + ) elif threshold < 0: - validation_result["warnings"].append(f"{trigger_type} trigger has negative threshold, ensure this is intended") - + validation_result["warnings"].append( + f"{trigger_type} trigger has negative threshold, ensure this is intended" + ) + # Validate threshold_type if "threshold_type" in condition: - valid_threshold_types = ["GreaterThan", "LessThan", "GreaterThanOrEqual", "LessThanOrEqual"] + valid_threshold_types = [ + "GreaterThan", + "LessThan", + "GreaterThanOrEqual", + "LessThanOrEqual", + ] if condition["threshold_type"] not in valid_threshold_types: validation_result["errors"].append( f"{trigger_type} trigger has invalid threshold_type. Must be one of: {', '.join(valid_threshold_types)}" ) - + # Validate time_range if "time_range" in condition: time_range = condition["time_range"] if not self._validate_time_range_format(time_range): - validation_result["errors"].append(f"{trigger_type} trigger has invalid time_range format: {time_range}") - + validation_result["errors"].append( + f"{trigger_type} trigger has invalid time_range format: {time_range}" + ) + # Validate optional fields if "occurrence_type" in condition: valid_occurrence_types = ["ResultCount", "AtLeastOnce", "Always"] @@ -3302,53 +3505,61 @@ def _validate_single_trigger_condition(self, trigger_type: str, condition: Dict[ validation_result["errors"].append( f"{trigger_type} trigger has invalid occurrence_type. Must be one of: {', '.join(valid_occurrence_types)}" ) - + if "trigger_source" in condition: valid_trigger_sources = ["AllResults", "AnyTimeSeries", "AllTimeSeries"] if condition["trigger_source"] not in valid_trigger_sources: validation_result["errors"].append( f"{trigger_type} trigger has invalid trigger_source. Must be one of: {', '.join(valid_trigger_sources)}" ) - + return validation_result - + def _validate_time_range_format(self, time_range: str) -> bool: """Validate time range format. - + Args: time_range: Time range string (e.g., '-5m', '-1h') - + Returns: True if format is valid, False otherwise """ import re + # Allow relative time expressions like -5m, -1h, -1d - pattern = r'^-?\d+[smhdw]$' + pattern = r"^-?\d+[smhdw]$" return bool(re.match(pattern, time_range)) - - def _cross_validate_trigger_conditions(self, trigger_conditions: Dict[str, Any]) -> Dict[str, Any]: + + def _cross_validate_trigger_conditions( + self, trigger_conditions: Dict[str, Any] + ) -> Dict[str, Any]: """Perform cross-validation between trigger conditions. - + Args: trigger_conditions: Dictionary of all trigger conditions - + Returns: Dictionary containing cross-validation warnings """ - validation_result = { - "warnings": [] - } - + validation_result = {"warnings": []} + # Check for logical threshold ordering if "Critical" in trigger_conditions and "Warning" in trigger_conditions: critical_threshold = trigger_conditions["Critical"].get("threshold") warning_threshold = trigger_conditions["Warning"].get("threshold") - critical_type = trigger_conditions["Critical"].get("threshold_type", "GreaterThan") - warning_type = trigger_conditions["Warning"].get("threshold_type", "GreaterThan") - - if (critical_threshold is not None and warning_threshold is not None and - critical_type == warning_type): - + critical_type = trigger_conditions["Critical"].get( + "threshold_type", "GreaterThan" + ) + warning_type = trigger_conditions["Warning"].get( + "threshold_type", "GreaterThan" + ) + + if ( + critical_threshold is not None + and warning_threshold is not None + and critical_type == warning_type + ): + if critical_type in ["GreaterThan", "GreaterThanOrEqual"]: if critical_threshold <= warning_threshold: validation_result["warnings"].append( @@ -3359,404 +3570,494 @@ def _cross_validate_trigger_conditions(self, trigger_conditions: Dict[str, Any]) validation_result["warnings"].append( "Critical threshold should be lower than Warning threshold for LessThan comparisons" ) - - return validation_result - - async def _validate_notification_configurations(self, notifications: List[Dict[str, Any]]) -> Dict[str, Any]: + + return validation_result + + async def _validate_notification_configurations( + self, notifications: List[Dict[str, Any]] + ) -> Dict[str, Any]: """Validate notification configuration and setup. - + Args: notifications: List of notification action configurations - + Returns: Dictionary containing notification validation results """ - validation_result = { - "valid": True, - "errors": [], - "warnings": [] - } - + validation_result = {"valid": True, "errors": [], "warnings": []} + if not notifications: - validation_result["warnings"].append("No notification actions configured - alerts will not be sent") + validation_result["warnings"].append( + "No notification actions configured - alerts will not be sent" + ) return validation_result - + # Validate each notification configuration for i, notification in enumerate(notifications): - notification_validation = await self._validate_single_notification(i, notification) - validation_result["errors"].extend(notification_validation.get("errors", [])) - validation_result["warnings"].extend(notification_validation.get("warnings", [])) + notification_validation = await self._validate_single_notification( + i, notification + ) + validation_result["errors"].extend( + notification_validation.get("errors", []) + ) + validation_result["warnings"].extend( + notification_validation.get("warnings", []) + ) if notification_validation.get("errors"): validation_result["valid"] = False - + # Perform cross-notification validation - cross_notification_validation = self._cross_validate_notifications(notifications) - validation_result["warnings"].extend(cross_notification_validation.get("warnings", [])) - + cross_notification_validation = self._cross_validate_notifications( + notifications + ) + validation_result["warnings"].extend( + cross_notification_validation.get("warnings", []) + ) + return validation_result - - async def _validate_single_notification(self, index: int, notification: Dict[str, Any]) -> Dict[str, Any]: + + async def _validate_single_notification( + self, index: int, notification: Dict[str, Any] + ) -> Dict[str, Any]: """Validate a single notification action configuration. - + Args: index: Index of notification in the list (for error reporting) notification: Notification configuration dictionary - + Returns: Dictionary containing validation results for this notification """ - validation_result = { - "errors": [], - "warnings": [] - } - + validation_result = {"errors": [], "warnings": []} + # Check for required action_type field if "action_type" not in notification: - validation_result["errors"].append(f"Notification {index + 1}: Missing required 'action_type' field") + validation_result["errors"].append( + f"Notification {index + 1}: Missing required 'action_type' field" + ) return validation_result - + action_type = notification["action_type"] - valid_action_types = ["EmailAction", "WebhookAction", "SlackAction", "PagerDutyAction"] - + valid_action_types = [ + "EmailAction", + "WebhookAction", + "SlackAction", + "PagerDutyAction", + ] + if action_type not in valid_action_types: validation_result["errors"].append( f"Notification {index + 1}: Invalid action_type '{action_type}'. Must be one of: {', '.join(valid_action_types)}" ) return validation_result - + # Validate based on notification type if action_type == "EmailAction": - email_validation = await self._validate_email_notification(index, notification) + email_validation = await self._validate_email_notification( + index, notification + ) validation_result["errors"].extend(email_validation.get("errors", [])) validation_result["warnings"].extend(email_validation.get("warnings", [])) - + elif action_type == "WebhookAction": - webhook_validation = await self._validate_webhook_notification(index, notification) + webhook_validation = await self._validate_webhook_notification( + index, notification + ) validation_result["errors"].extend(webhook_validation.get("errors", [])) validation_result["warnings"].extend(webhook_validation.get("warnings", [])) - + elif action_type == "SlackAction": - slack_validation = await self._validate_slack_notification(index, notification) + slack_validation = await self._validate_slack_notification( + index, notification + ) validation_result["errors"].extend(slack_validation.get("errors", [])) validation_result["warnings"].extend(slack_validation.get("warnings", [])) - + elif action_type == "PagerDutyAction": - pagerduty_validation = await self._validate_pagerduty_notification(index, notification) + pagerduty_validation = await self._validate_pagerduty_notification( + index, notification + ) validation_result["errors"].extend(pagerduty_validation.get("errors", [])) - validation_result["warnings"].extend(pagerduty_validation.get("warnings", [])) - + validation_result["warnings"].extend( + pagerduty_validation.get("warnings", []) + ) + # Validate common notification fields - common_validation = self._validate_common_notification_fields(index, notification) + common_validation = self._validate_common_notification_fields( + index, notification + ) validation_result["errors"].extend(common_validation.get("errors", [])) validation_result["warnings"].extend(common_validation.get("warnings", [])) - + return validation_result - - async def _validate_email_notification(self, index: int, notification: Dict[str, Any]) -> Dict[str, Any]: + + async def _validate_email_notification( + self, index: int, notification: Dict[str, Any] + ) -> Dict[str, Any]: """Validate email notification configuration. - + Args: index: Notification index for error reporting notification: Email notification configuration - + Returns: Dictionary containing email validation results """ - validation_result = { - "errors": [], - "warnings": [] - } - + validation_result = {"errors": [], "warnings": []} + # Check for recipients recipients = notification.get("recipients", []) if not recipients: - validation_result["errors"].append(f"Email notification {index + 1}: No recipients specified") + validation_result["errors"].append( + f"Email notification {index + 1}: No recipients specified" + ) return validation_result - + if not isinstance(recipients, list): - validation_result["errors"].append(f"Email notification {index + 1}: Recipients must be a list") + validation_result["errors"].append( + f"Email notification {index + 1}: Recipients must be a list" + ) return validation_result - + # Validate email addresses import re - email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' - + + email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" + for i, email in enumerate(recipients): if not isinstance(email, str): - validation_result["errors"].append(f"Email notification {index + 1}: Recipient {i + 1} must be a string") + validation_result["errors"].append( + f"Email notification {index + 1}: Recipient {i + 1} must be a string" + ) continue - + if not re.match(email_pattern, email.strip()): - validation_result["errors"].append(f"Email notification {index + 1}: Invalid email address '{email}'") - + validation_result["errors"].append( + f"Email notification {index + 1}: Invalid email address '{email}'" + ) + # Check for reasonable number of recipients if len(recipients) > 50: - validation_result["warnings"].append(f"Email notification {index + 1}: Large number of recipients ({len(recipients)}) may cause delivery issues") - + validation_result["warnings"].append( + f"Email notification {index + 1}: Large number of recipients ({len(recipients)}) may cause delivery issues" + ) + return validation_result - - async def _validate_webhook_notification(self, index: int, notification: Dict[str, Any]) -> Dict[str, Any]: + + async def _validate_webhook_notification( + self, index: int, notification: Dict[str, Any] + ) -> Dict[str, Any]: """Validate webhook notification configuration. - + Args: index: Notification index for error reporting notification: Webhook notification configuration - + Returns: Dictionary containing webhook validation results """ - validation_result = { - "errors": [], - "warnings": [] - } - + validation_result = {"errors": [], "warnings": []} + # Check for webhook URL webhook_url = notification.get("webhook_url") if not webhook_url: - validation_result["errors"].append(f"Webhook notification {index + 1}: Missing required 'webhook_url' field") + validation_result["errors"].append( + f"Webhook notification {index + 1}: Missing required 'webhook_url' field" + ) return validation_result - + if not isinstance(webhook_url, str): - validation_result["errors"].append(f"Webhook notification {index + 1}: webhook_url must be a string") + validation_result["errors"].append( + f"Webhook notification {index + 1}: webhook_url must be a string" + ) return validation_result - + webhook_url = webhook_url.strip() - + # Validate URL format - if not webhook_url.startswith(('http://', 'https://')): - validation_result["errors"].append(f"Webhook notification {index + 1}: webhook_url must start with http:// or https://") - + if not webhook_url.startswith(("http://", "https://")): + validation_result["errors"].append( + f"Webhook notification {index + 1}: webhook_url must start with http:// or https://" + ) + # Recommend HTTPS for security - if webhook_url.startswith('http://'): - validation_result["warnings"].append(f"Webhook notification {index + 1}: Consider using HTTPS for better security") - + if webhook_url.startswith("http://"): + validation_result["warnings"].append( + f"Webhook notification {index + 1}: Consider using HTTPS for better security" + ) + # Basic URL validation try: from urllib.parse import urlparse + parsed = urlparse(webhook_url) if not parsed.netloc: - validation_result["errors"].append(f"Webhook notification {index + 1}: Invalid webhook URL format") + validation_result["errors"].append( + f"Webhook notification {index + 1}: Invalid webhook URL format" + ) except Exception: - validation_result["errors"].append(f"Webhook notification {index + 1}: Invalid webhook URL format") - + validation_result["errors"].append( + f"Webhook notification {index + 1}: Invalid webhook URL format" + ) + # Test webhook connectivity (optional, can be disabled for performance) # This is commented out as it would make network calls during validation # webhook_test = await self._test_webhook_connectivity(webhook_url) # if not webhook_test["reachable"]: # validation_result["warnings"].append(f"Webhook notification {index + 1}: Webhook URL may not be reachable") - + return validation_result - - async def _validate_slack_notification(self, index: int, notification: Dict[str, Any]) -> Dict[str, Any]: + + async def _validate_slack_notification( + self, index: int, notification: Dict[str, Any] + ) -> Dict[str, Any]: """Validate Slack notification configuration. - + Args: index: Notification index for error reporting notification: Slack notification configuration - + Returns: Dictionary containing Slack validation results """ - validation_result = { - "errors": [], - "warnings": [] - } - + validation_result = {"errors": [], "warnings": []} + # For Slack notifications, we typically need either a webhook URL or channel configuration webhook_url = notification.get("webhook_url") channel = notification.get("channel") - + if not webhook_url and not channel: - validation_result["errors"].append(f"Slack notification {index + 1}: Must specify either 'webhook_url' or 'channel'") + validation_result["errors"].append( + f"Slack notification {index + 1}: Must specify either 'webhook_url' or 'channel'" + ) return validation_result - + # Validate webhook URL if provided if webhook_url: if not isinstance(webhook_url, str): - validation_result["errors"].append(f"Slack notification {index + 1}: webhook_url must be a string") - elif not webhook_url.strip().startswith('https://hooks.slack.com/'): - validation_result["warnings"].append(f"Slack notification {index + 1}: webhook_url should be a Slack webhook URL") - + validation_result["errors"].append( + f"Slack notification {index + 1}: webhook_url must be a string" + ) + elif not webhook_url.strip().startswith("https://hooks.slack.com/"): + validation_result["warnings"].append( + f"Slack notification {index + 1}: webhook_url should be a Slack webhook URL" + ) + # Validate channel if provided if channel: if not isinstance(channel, str): - validation_result["errors"].append(f"Slack notification {index + 1}: channel must be a string") + validation_result["errors"].append( + f"Slack notification {index + 1}: channel must be a string" + ) else: channel = channel.strip() - if not channel.startswith('#') and not channel.startswith('@'): - validation_result["warnings"].append(f"Slack notification {index + 1}: channel should start with # for channels or @ for users") - + if not channel.startswith("#") and not channel.startswith("@"): + validation_result["warnings"].append( + f"Slack notification {index + 1}: channel should start with # for channels or @ for users" + ) + return validation_result - - async def _validate_pagerduty_notification(self, index: int, notification: Dict[str, Any]) -> Dict[str, Any]: + + async def _validate_pagerduty_notification( + self, index: int, notification: Dict[str, Any] + ) -> Dict[str, Any]: """Validate PagerDuty notification configuration. - + Args: index: Notification index for error reporting notification: PagerDuty notification configuration - + Returns: Dictionary containing PagerDuty validation results """ - validation_result = { - "errors": [], - "warnings": [] - } - + validation_result = {"errors": [], "warnings": []} + # Check for integration key integration_key = notification.get("integration_key") if not integration_key: - validation_result["errors"].append(f"PagerDuty notification {index + 1}: Missing required 'integration_key' field") + validation_result["errors"].append( + f"PagerDuty notification {index + 1}: Missing required 'integration_key' field" + ) return validation_result - + if not isinstance(integration_key, str): - validation_result["errors"].append(f"PagerDuty notification {index + 1}: integration_key must be a string") + validation_result["errors"].append( + f"PagerDuty notification {index + 1}: integration_key must be a string" + ) return validation_result - + integration_key = integration_key.strip() - + # Validate integration key format (PagerDuty keys are typically 32 characters) if len(integration_key) != 32: - validation_result["warnings"].append(f"PagerDuty notification {index + 1}: integration_key should be 32 characters long") - + validation_result["warnings"].append( + f"PagerDuty notification {index + 1}: integration_key should be 32 characters long" + ) + # Check for valid characters (alphanumeric) - if not integration_key.replace('-', '').replace('_', '').isalnum(): - validation_result["warnings"].append(f"PagerDuty notification {index + 1}: integration_key contains unexpected characters") - + if not integration_key.replace("-", "").replace("_", "").isalnum(): + validation_result["warnings"].append( + f"PagerDuty notification {index + 1}: integration_key contains unexpected characters" + ) + return validation_result - - def _validate_common_notification_fields(self, index: int, notification: Dict[str, Any]) -> Dict[str, Any]: + + def _validate_common_notification_fields( + self, index: int, notification: Dict[str, Any] + ) -> Dict[str, Any]: """Validate common notification fields like subject and message body. - + Args: index: Notification index for error reporting notification: Notification configuration - + Returns: Dictionary containing common field validation results """ - validation_result = { - "errors": [], - "warnings": [] - } - + validation_result = {"errors": [], "warnings": []} + # Validate subject if provided subject = notification.get("subject") if subject is not None: if not isinstance(subject, str): - validation_result["errors"].append(f"Notification {index + 1}: subject must be a string") + validation_result["errors"].append( + f"Notification {index + 1}: subject must be a string" + ) elif len(subject.strip()) == 0: - validation_result["warnings"].append(f"Notification {index + 1}: subject is empty") + validation_result["warnings"].append( + f"Notification {index + 1}: subject is empty" + ) elif len(subject) > 255: - validation_result["errors"].append(f"Notification {index + 1}: subject is too long (maximum 255 characters)") - + validation_result["errors"].append( + f"Notification {index + 1}: subject is too long (maximum 255 characters)" + ) + # Validate message body if provided message_body = notification.get("message_body") if message_body is not None: if not isinstance(message_body, str): - validation_result["errors"].append(f"Notification {index + 1}: message_body must be a string") + validation_result["errors"].append( + f"Notification {index + 1}: message_body must be a string" + ) elif len(message_body) > 2000: - validation_result["errors"].append(f"Notification {index + 1}: message_body is too long (maximum 2000 characters)") + validation_result["errors"].append( + f"Notification {index + 1}: message_body is too long (maximum 2000 characters)" + ) elif len(message_body.strip()) == 0: - validation_result["warnings"].append(f"Notification {index + 1}: message_body is empty") - + validation_result["warnings"].append( + f"Notification {index + 1}: message_body is empty" + ) + return validation_result - - def _cross_validate_notifications(self, notifications: List[Dict[str, Any]]) -> Dict[str, Any]: + + def _cross_validate_notifications( + self, notifications: List[Dict[str, Any]] + ) -> Dict[str, Any]: """Perform cross-validation between notification configurations. - + Args: notifications: List of all notification configurations - + Returns: Dictionary containing cross-validation warnings """ - validation_result = { - "warnings": [] - } - + validation_result = {"warnings": []} + # Check for duplicate notification destinations seen_destinations = set() for i, notification in enumerate(notifications): action_type = notification.get("action_type") - + # Create a destination identifier based on type and key fields if action_type == "EmailAction": recipients = notification.get("recipients", []) for recipient in recipients: dest_id = f"email:{recipient}" if dest_id in seen_destinations: - validation_result["warnings"].append(f"Duplicate email recipient '{recipient}' found in multiple notifications") + validation_result["warnings"].append( + f"Duplicate email recipient '{recipient}' found in multiple notifications" + ) seen_destinations.add(dest_id) - + elif action_type == "WebhookAction": webhook_url = notification.get("webhook_url") if webhook_url: dest_id = f"webhook:{webhook_url}" if dest_id in seen_destinations: - validation_result["warnings"].append(f"Duplicate webhook URL found in multiple notifications") + validation_result["warnings"].append( + f"Duplicate webhook URL found in multiple notifications" + ) seen_destinations.add(dest_id) - + elif action_type == "SlackAction": webhook_url = notification.get("webhook_url") channel = notification.get("channel") if webhook_url: dest_id = f"slack_webhook:{webhook_url}" if dest_id in seen_destinations: - validation_result["warnings"].append(f"Duplicate Slack webhook URL found in multiple notifications") + validation_result["warnings"].append( + f"Duplicate Slack webhook URL found in multiple notifications" + ) seen_destinations.add(dest_id) if channel: dest_id = f"slack_channel:{channel}" if dest_id in seen_destinations: - validation_result["warnings"].append(f"Duplicate Slack channel '{channel}' found in multiple notifications") + validation_result["warnings"].append( + f"Duplicate Slack channel '{channel}' found in multiple notifications" + ) seen_destinations.add(dest_id) - + elif action_type == "PagerDutyAction": integration_key = notification.get("integration_key") if integration_key: dest_id = f"pagerduty:{integration_key}" if dest_id in seen_destinations: - validation_result["warnings"].append(f"Duplicate PagerDuty integration key found in multiple notifications") + validation_result["warnings"].append( + f"Duplicate PagerDuty integration key found in multiple notifications" + ) seen_destinations.add(dest_id) - + # Check for notification diversity action_types = [notif.get("action_type") for notif in notifications] unique_types = set(action_types) - + if len(notifications) > 3 and len(unique_types) == 1: - validation_result["warnings"].append("Consider using different notification types for better redundancy") - + validation_result["warnings"].append( + "Consider using different notification types for better redundancy" + ) + return validation_result - + def _perform_cross_validation_checks( self, - config: 'MonitorConfig', + config: "MonitorConfig", trigger_conditions: Dict[str, Any], - notifications: List[Dict[str, Any]] + notifications: List[Dict[str, Any]], ) -> Dict[str, Any]: """Perform cross-validation checks between different configuration components. - + Args: config: Monitor configuration trigger_conditions: Trigger conditions notifications: Notification configurations - + Returns: Dictionary containing cross-validation results """ - validation_result = { - "errors": [], - "warnings": [] - } - + validation_result = {"errors": [], "warnings": []} + # Check if critical triggers have appropriate notifications if "Critical" in trigger_conditions and notifications: # Recommend having immediate notification types for critical alerts immediate_types = ["PagerDutyAction", "SlackAction"] - has_immediate = any(notif.get("action_type") in immediate_types for notif in notifications) + has_immediate = any( + notif.get("action_type") in immediate_types for notif in notifications + ) if not has_immediate: - validation_result["warnings"].append("Critical triggers should have immediate notification types (PagerDuty, Slack)") - + validation_result["warnings"].append( + "Critical triggers should have immediate notification types (PagerDuty, Slack)" + ) + # Check evaluation delay vs trigger time ranges evaluation_delay = config.evaluation_delay if evaluation_delay and evaluation_delay != "0m": @@ -3765,8 +4066,8 @@ def _perform_cross_validation_checks( delay_minutes = self._parse_time_to_minutes(evaluation_delay) for trigger_type, condition in trigger_conditions.items(): time_range = condition.get("time_range", "-5m") - range_minutes = self._parse_time_to_minutes(time_range.lstrip('-')) - + range_minutes = self._parse_time_to_minutes(time_range.lstrip("-")) + if delay_minutes >= range_minutes: validation_result["warnings"].append( f"{trigger_type} trigger time range ({time_range}) should be longer than evaluation delay ({evaluation_delay})" @@ -3774,50 +4075,52 @@ def _perform_cross_validation_checks( except Exception: # If parsing fails, skip this validation pass - + # Check monitor type vs query compatibility monitor_type = config.type.value query_lower = config.query.lower() - + if monitor_type == "MetricsMonitor" and "metric" not in query_lower: - validation_result["warnings"].append("Metrics monitor should use metrics queries with metric() function") + validation_result["warnings"].append( + "Metrics monitor should use metrics queries with metric() function" + ) elif monitor_type == "MonitorsLibraryMonitor" and "metric" in query_lower: - validation_result["warnings"].append("Log monitor should not use metrics queries") - + validation_result["warnings"].append( + "Log monitor should not use metrics queries" + ) + return validation_result - + def _parse_time_to_minutes(self, time_str: str) -> int: """Parse time string to minutes. - + Args: time_str: Time string like '5m', '1h', '2d' - + Returns: Time in minutes """ time_str = time_str.strip().lower() - if time_str.endswith('s'): + if time_str.endswith("s"): return int(time_str[:-1]) / 60 - elif time_str.endswith('m'): + elif time_str.endswith("m"): return int(time_str[:-1]) - elif time_str.endswith('h'): + elif time_str.endswith("h"): return int(time_str[:-1]) * 60 - elif time_str.endswith('d'): + elif time_str.endswith("d"): return int(time_str[:-1]) * 24 * 60 else: raise ValueError(f"Invalid time format: {time_str}") - + def _format_monitor_creation_response( - self, - created_monitor: Dict[str, Any], - validation_results: Dict[str, Any] + self, created_monitor: Dict[str, Any], validation_results: Dict[str, Any] ) -> Dict[str, Any]: """Format the monitor creation response with validation details. - + Args: created_monitor: Monitor data returned from API validation_results: Validation results from comprehensive validation - + Returns: Formatted response dictionary """ @@ -3827,87 +4130,108 @@ def _format_monitor_creation_response( "id": created_monitor.get("id"), "name": created_monitor.get("name"), "description": created_monitor.get("description", ""), - "type": self._convert_monitor_type_to_friendly(created_monitor.get("monitorType")), + "type": self._convert_monitor_type_to_friendly( + created_monitor.get("monitorType") + ), "api_type": created_monitor.get("monitorType"), - "status": "disabled" if created_monitor.get("isDisabled") else "enabled", + "status": ( + "disabled" if created_monitor.get("isDisabled") else "enabled" + ), "created_at": created_monitor.get("createdAt"), "created_by": created_monitor.get("createdBy"), - "version": created_monitor.get("version", 1) + "version": created_monitor.get("version", 1), }, "validation": { "query_syntax_valid": validation_results.get("query_syntax_valid"), - "trigger_conditions_valid": validation_results.get("trigger_conditions_valid"), + "trigger_conditions_valid": validation_results.get( + "trigger_conditions_valid" + ), "notifications_valid": validation_results.get("notifications_valid"), "warnings": validation_results.get("warnings", []), - "warning_count": len(validation_results.get("warnings", [])) + "warning_count": len(validation_results.get("warnings", [])), }, "configuration_summary": { "trigger_count": len(created_monitor.get("triggers", [])), "notification_count": len(created_monitor.get("notifications", [])), "has_critical_trigger": any( - trigger.get("triggerType") == "Critical" + trigger.get("triggerType") == "Critical" for trigger in created_monitor.get("triggers", []) ), - "evaluation_delay": created_monitor.get("evaluationDelay", "0m") + "evaluation_delay": created_monitor.get("evaluationDelay", "0m"), }, - "next_steps": self._generate_next_steps_recommendations(created_monitor, validation_results) + "next_steps": self._generate_next_steps_recommendations( + created_monitor, validation_results + ), } - + def _generate_next_steps_recommendations( - self, - created_monitor: Dict[str, Any], - validation_results: Dict[str, Any] + self, created_monitor: Dict[str, Any], validation_results: Dict[str, Any] ) -> List[str]: """Generate next steps recommendations for the created monitor. - + Args: created_monitor: Created monitor data validation_results: Validation results - + Returns: List of recommendation strings """ recommendations = [] - + # Basic recommendations if created_monitor.get("isDisabled"): recommendations.append("Enable the monitor when ready to start monitoring") else: - recommendations.append("Monitor is active and will start evaluating immediately") - + recommendations.append( + "Monitor is active and will start evaluating immediately" + ) + # Validation-based recommendations warnings = validation_results.get("warnings", []) if warnings: - recommendations.append(f"Review {len(warnings)} configuration warnings for optimization opportunities") - + recommendations.append( + f"Review {len(warnings)} configuration warnings for optimization opportunities" + ) + # Configuration-based recommendations if not created_monitor.get("notifications"): - recommendations.append("Add notification actions to receive alerts when triggers fire") - - if not any(trigger.get("triggerType") == "Critical" for trigger in created_monitor.get("triggers", [])): - recommendations.append("Consider adding a Critical trigger condition for high-severity alerts") - + recommendations.append( + "Add notification actions to receive alerts when triggers fire" + ) + + if not any( + trigger.get("triggerType") == "Critical" + for trigger in created_monitor.get("triggers", []) + ): + recommendations.append( + "Consider adding a Critical trigger condition for high-severity alerts" + ) + # Monitoring recommendations - recommendations.append("Test the monitor by checking its status and alert history") - recommendations.append("Document the monitor's purpose and expected behavior for your team") - + recommendations.append( + "Test the monitor by checking its status and alert history" + ) + recommendations.append( + "Document the monitor's purpose and expected behavior for your team" + ) + return recommendations async def delete_monitor(self, monitor_id: str) -> Dict[str, Any]: """Delete specified monitor permanently with safety checks and audit trail. - + This method implements monitor deletion with comprehensive safety checks including: - Retrieving monitor information before deletion for audit trail - Handling deletion errors and monitor not found cases - Cascade deletion for associated notifications and history - Detailed confirmation with deleted monitor information - + Args: monitor_id: Unique identifier for the monitor to delete - + Returns: Dictionary containing deletion confirmation and deleted monitor details - + Raises: MonitorValidationError: If monitor_id is invalid or empty MonitorNotFoundError: If monitor doesn't exist @@ -3915,26 +4239,31 @@ async def delete_monitor(self, monitor_id: str) -> Dict[str, Any]: MonitorOperationError: If deletion conflicts exist MonitorError: If monitor deletion fails """ + async def _delete_monitor_impl(): # Validate monitor_id with enhanced validation - validated_monitor_id = await validate_monitor_id(monitor_id, "delete_monitor") - + validated_monitor_id = await validate_monitor_id( + monitor_id, "delete_monitor" + ) + # Step 1: Retrieve monitor information before deletion for audit trail monitor_info = None try: monitor_info_response = await self.get_monitor(validated_monitor_id) - monitor_info = monitor_info_response.get("monitor", monitor_info_response) - + monitor_info = monitor_info_response.get( + "monitor", monitor_info_response + ) + logger.info( "Retrieved monitor information for deletion audit trail", extra={ "monitor_id": validated_monitor_id, "monitor_name": monitor_info.get("name"), "monitor_type": monitor_info.get("type"), - "is_disabled": monitor_info.get("status") == "disabled" - } + "is_disabled": monitor_info.get("status") == "disabled", + }, ) - + except MonitorNotFoundError: # Monitor doesn't exist - we'll still attempt deletion to get proper API response logger.warning( @@ -3945,24 +4274,26 @@ async def _delete_monitor_impl(): "id": validated_monitor_id, "name": "Unknown (monitor not found)", "type": "Unknown", - "status": "unknown" + "status": "unknown", } except Exception as e: # For other errors during retrieval, we should still attempt deletion logger.warning( f"Failed to retrieve monitor {validated_monitor_id} before deletion: {str(e)}", - extra={"error_type": type(e).__name__} + extra={"error_type": type(e).__name__}, ) monitor_info = { "id": validated_monitor_id, "name": "Unknown (retrieval failed)", "type": "Unknown", - "status": "unknown" + "status": "unknown", } - + # Step 2: Perform the deletion via API client with error handling try: - deletion_result = await self.api_client.delete_monitor(validated_monitor_id) + deletion_result = await self.api_client.delete_monitor( + validated_monitor_id + ) except APIError as e: # The error handler will convert this to appropriate monitor error raise e @@ -3974,24 +4305,24 @@ async def _delete_monitor_impl(): context=create_monitor_error_context( "delete_monitor", monitor_id=validated_monitor_id, - monitor_name=monitor_info.get("name") if monitor_info else None - ) + monitor_name=monitor_info.get("name") if monitor_info else None, + ), ) from e - + # Step 3: Handle cascade deletion information try: cascade_info = await self._handle_cascade_deletion_info(monitor_info) except Exception as e: logger.warning( "Failed to gather cascade deletion information", - extra={"monitor_id": validated_monitor_id, "error": str(e)} + extra={"monitor_id": validated_monitor_id, "error": str(e)}, ) cascade_info = { "removed_items": [], "cascade_status": "information_unavailable", - "error": str(e) + "error": str(e), } - + # Step 4: Format comprehensive deletion confirmation response try: response = { @@ -4002,47 +4333,47 @@ async def _delete_monitor_impl(): "name": monitor_info.get("name", "Unknown"), "type": monitor_info.get("type", "Unknown"), "was_disabled": monitor_info.get("status") == "disabled", - "deletion_timestamp": datetime.now().isoformat() + "deletion_timestamp": datetime.now().isoformat(), }, "audit_trail": { "deleted_by": "MCP Monitor Tools", "deletion_method": "API", - "pre_deletion_status": monitor_info.get("status", "unknown") + "pre_deletion_status": monitor_info.get("status", "unknown"), }, "cascade_deletion": cascade_info, "confirmation": { "monitor_permanently_deleted": True, "associated_data_removed": True, - "action_irreversible": True - } + "action_irreversible": True, + }, } except Exception as e: # If response formatting fails, return basic confirmation logger.warning( "Monitor deleted successfully but response formatting failed", - extra={"monitor_id": validated_monitor_id, "format_error": str(e)} + extra={"monitor_id": validated_monitor_id, "format_error": str(e)}, ) response = { "success": True, "message": f"Monitor {validated_monitor_id} deleted successfully", "deleted_monitor": {"id": validated_monitor_id}, - "formatting_warning": f"Response formatting failed: {str(e)}" + "formatting_warning": f"Response formatting failed: {str(e)}", } - + return response - + # Execute with comprehensive error handling return await self.error_handler.execute_with_error_handling( "delete_monitor", _delete_monitor_impl, monitor_id=monitor_id, - timeout_override=20.0 + timeout_override=20.0, ) logger.info( "Deleting monitor with safety checks and audit trail", - extra={"monitor_id": monitor_id, "operation": "delete_monitor"} + extra={"monitor_id": monitor_id, "operation": "delete_monitor"}, ) - + # Validate monitor_id parameter if not monitor_id or not monitor_id.strip(): raise ValidationError( @@ -4051,27 +4382,34 @@ async def _delete_monitor_impl(): field_value=monitor_id, context={ "operation": "delete_monitor", - "suggestion": "Provide a valid monitor ID to delete" - } + "suggestion": "Provide a valid monitor ID to delete", + }, ) - + monitor_id = monitor_id.strip() - + try: # Step 1: Retrieve monitor information before deletion for audit trail - logger.debug(f"Retrieving monitor information before deletion: {monitor_id}") - + logger.debug( + f"Retrieving monitor information before deletion: {monitor_id}" + ) + try: monitor_info_response = await self.get_monitor(monitor_id) - + if not monitor_info_response.get("success", True): raise APIError( f"Failed to retrieve monitor {monitor_id} before deletion", - context={"monitor_id": monitor_id, "operation": "pre_delete_retrieval"} + context={ + "monitor_id": monitor_id, + "operation": "pre_delete_retrieval", + }, ) - - monitor_info = monitor_info_response.get("monitor", monitor_info_response) - + + monitor_info = monitor_info_response.get( + "monitor", monitor_info_response + ) + logger.info( "Retrieved monitor information for deletion audit trail", extra={ @@ -4080,13 +4418,15 @@ async def _delete_monitor_impl(): "monitor_type": monitor_info.get("monitorType"), "is_disabled": monitor_info.get("isDisabled"), "trigger_count": len(monitor_info.get("triggers", [])), - "notification_count": len(monitor_info.get("notifications", [])) - } + "notification_count": len( + monitor_info.get("notifications", []) + ), + }, ) - + except APIError as e: # If monitor doesn't exist, we'll still attempt deletion to get proper API error - if hasattr(e, 'status_code') and e.status_code == 404: + if hasattr(e, "status_code") and e.status_code == 404: logger.warning( f"Monitor {monitor_id} not found during pre-deletion check, proceeding with deletion attempt" ) @@ -4096,29 +4436,32 @@ async def _delete_monitor_impl(): "monitorType": "Unknown", "isDisabled": None, "triggers": [], - "notifications": [] + "notifications": [], } else: # For other API errors, re-raise as they indicate a more serious issue logger.error( f"Failed to retrieve monitor {monitor_id} before deletion", - extra={"error": str(e), "status_code": getattr(e, 'status_code', None)} + extra={ + "error": str(e), + "status_code": getattr(e, "status_code", None), + }, ) raise APIError( f"Cannot delete monitor {monitor_id}: failed to retrieve monitor information: {e.message}", - status_code=getattr(e, 'status_code', None), + status_code=getattr(e, "status_code", None), context={ "monitor_id": monitor_id, "operation": "pre_delete_retrieval", - "error_type": "retrieval_failed" - } + "error_type": "retrieval_failed", + }, ) from e - + # Step 2: Perform the deletion via API client logger.info(f"Proceeding with monitor deletion: {monitor_id}") - + deletion_result = await self.api_client.delete_monitor(monitor_id) - + # Step 3: Handle deletion errors and monitor not found cases if not deletion_result.get("success", False): error_message = deletion_result.get("message", "Unknown deletion error") @@ -4126,21 +4469,21 @@ async def _delete_monitor_impl(): f"Monitor deletion failed: {monitor_id}", extra={ "error_message": error_message, - "deletion_result": deletion_result - } + "deletion_result": deletion_result, + }, ) raise APIError( f"Failed to delete monitor {monitor_id}: {error_message}", context={ "monitor_id": monitor_id, "operation": "delete_monitor", - "deletion_result": deletion_result - } + "deletion_result": deletion_result, + }, ) - + # Step 4: Add cascade deletion information for associated notifications and history cascade_info = await self._handle_cascade_deletion_info(monitor_info) - + # Step 5: Format comprehensive deletion confirmation response response = { "success": True, @@ -4148,43 +4491,53 @@ async def _delete_monitor_impl(): "deleted_monitor": { "id": monitor_id, "name": monitor_info.get("name", "Unknown"), - "type": self._convert_monitor_type_to_friendly(monitor_info.get("monitorType", "Unknown")), + "type": self._convert_monitor_type_to_friendly( + monitor_info.get("monitorType", "Unknown") + ), "was_disabled": monitor_info.get("isDisabled", False), "deletion_timestamp": datetime.now().isoformat(), "trigger_conditions_count": len(monitor_info.get("triggers", [])), - "notification_actions_count": len(monitor_info.get("notifications", [])) + "notification_actions_count": len( + monitor_info.get("notifications", []) + ), }, "audit_trail": { "deleted_by": "MCP Monitor Tools", "deletion_method": "API", - "pre_deletion_status": "enabled" if not monitor_info.get("isDisabled", False) else "disabled", + "pre_deletion_status": ( + "enabled" + if not monitor_info.get("isDisabled", False) + else "disabled" + ), "had_active_triggers": len(monitor_info.get("triggers", [])) > 0, - "had_notifications": len(monitor_info.get("notifications", [])) > 0 + "had_notifications": len(monitor_info.get("notifications", [])) > 0, }, "cascade_deletion": cascade_info, "confirmation": { "monitor_permanently_deleted": True, "associated_data_removed": True, - "action_irreversible": True - } + "action_irreversible": True, + }, } - + logger.info( "Monitor deleted successfully with complete audit trail", extra={ "monitor_id": monitor_id, "monitor_name": monitor_info.get("name"), "cascade_items": len(cascade_info.get("removed_items", [])), - "deletion_timestamp": response["deleted_monitor"]["deletion_timestamp"] - } + "deletion_timestamp": response["deleted_monitor"][ + "deletion_timestamp" + ], + }, ) - + return response - + except ValidationError: # Re-raise validation errors as-is raise - + except APIError as e: # Handle API-specific errors with enhanced context logger.error( @@ -4192,12 +4545,12 @@ async def _delete_monitor_impl(): extra={ "monitor_id": monitor_id, "error": str(e), - "status_code": getattr(e, 'status_code', None) - } + "status_code": getattr(e, "status_code", None), + }, ) - + # Provide more specific error messages based on status code - if hasattr(e, 'status_code'): + if hasattr(e, "status_code"): if e.status_code == 404: raise ValidationError( f"Monitor {monitor_id} not found - it may have already been deleted", @@ -4205,8 +4558,8 @@ async def _delete_monitor_impl(): field_value=monitor_id, context={ "suggestion": "Verify the monitor ID is correct or check if it was already deleted", - "operation": "delete_monitor" - } + "operation": "delete_monitor", + }, ) from e elif e.status_code == 403: raise APIError( @@ -4216,8 +4569,8 @@ async def _delete_monitor_impl(): "operation": "delete_monitor", "monitor_id": monitor_id, "error_type": "permission_denied", - "suggestion": "Ensure you have the necessary permissions to delete monitors" - } + "suggestion": "Ensure you have the necessary permissions to delete monitors", + }, ) from e elif e.status_code == 409: raise APIError( @@ -4226,49 +4579,51 @@ async def _delete_monitor_impl(): context={ "monitor_id": monitor_id, "error_type": "deletion_conflict", - "suggestion": "The monitor may be referenced by other resources or currently active" - } + "suggestion": "The monitor may be referenced by other resources or currently active", + }, ) from e - + # Re-raise with enhanced context for other API errors raise APIError( f"Failed to delete monitor {monitor_id}: {e.message}", - status_code=getattr(e, 'status_code', None), - request_id=getattr(e, 'request_id', None), + status_code=getattr(e, "status_code", None), + request_id=getattr(e, "request_id", None), context={ "operation": "delete_monitor", "monitor_id": monitor_id, - "error_type": "api_failure" - } + "error_type": "api_failure", + }, ) from e - + except Exception as e: logger.error( "Unexpected error deleting monitor", extra={ "monitor_id": monitor_id, "error": str(e), - "error_type": type(e).__name__ - } + "error_type": type(e).__name__, + }, ) raise APIError( f"Unexpected error deleting monitor {monitor_id}: {str(e)}", context={ "operation": "delete_monitor", "monitor_id": monitor_id, - "error_type": type(e).__name__ - } + "error_type": type(e).__name__, + }, ) from e - async def _handle_cascade_deletion_info(self, monitor_info: Dict[str, Any]) -> Dict[str, Any]: + async def _handle_cascade_deletion_info( + self, monitor_info: Dict[str, Any] + ) -> Dict[str, Any]: """Handle cascade deletion information for associated notifications and history. - + This method provides information about what associated data will be removed when a monitor is deleted, including notifications and historical data. - + Args: monitor_info: Monitor information retrieved before deletion - + Returns: Dictionary containing cascade deletion information """ @@ -4278,82 +4633,89 @@ async def _handle_cascade_deletion_info(self, monitor_info: Dict[str, Any]) -> D "categories": { "notifications": 0, "triggers": 0, - "history": "all_historical_data" - } + "history": "all_historical_data", + }, } - + try: # Count notification actions that will be removed notifications = monitor_info.get("notifications", []) if notifications: cascade_info["categories"]["notifications"] = len(notifications) - cascade_info["removed_items"].extend([ - f"Notification action: {notif.get('notification', {}).get('actionType', 'Unknown')}" - for notif in notifications - ]) - + cascade_info["removed_items"].extend( + [ + f"Notification action: {notif.get('notification', {}).get('actionType', 'Unknown')}" + for notif in notifications + ] + ) + # Count trigger conditions that will be removed triggers = monitor_info.get("triggers", []) if triggers: cascade_info["categories"]["triggers"] = len(triggers) - cascade_info["removed_items"].extend([ - f"Trigger condition: {trigger.get('triggerType', 'Unknown')}" - for trigger in triggers - ]) - + cascade_info["removed_items"].extend( + [ + f"Trigger condition: {trigger.get('triggerType', 'Unknown')}" + for trigger in triggers + ] + ) + # Add information about historical data removal - cascade_info["removed_items"].append("All historical execution data and alert history") - cascade_info["removed_items"].append("All associated alert instances and status history") - + cascade_info["removed_items"].append( + "All historical execution data and alert history" + ) + cascade_info["removed_items"].append( + "All associated alert instances and status history" + ) + cascade_info["item_count"] = len(cascade_info["removed_items"]) - + logger.debug( "Prepared cascade deletion information", extra={ "monitor_id": monitor_info.get("id"), "total_items": cascade_info["item_count"], "notifications": cascade_info["categories"]["notifications"], - "triggers": cascade_info["categories"]["triggers"] - } + "triggers": cascade_info["categories"]["triggers"], + }, ) - + except Exception as e: logger.warning( "Error preparing cascade deletion information", - extra={"error": str(e), "monitor_id": monitor_info.get("id")} + extra={"error": str(e), "monitor_id": monitor_info.get("id")}, ) # Return basic cascade info even if detailed analysis fails cascade_info = { "removed_items": ["All associated monitor data and history"], "item_count": 1, - "categories": {"note": "Detailed cascade analysis unavailable"} + "categories": {"note": "Detailed cascade analysis unavailable"}, } - + return cascade_info - + async def get_monitor_status( - self, - monitor_id: Optional[str] = None, - filter_status: Optional[str] = None + self, monitor_id: Optional[str] = None, filter_status: Optional[str] = None ) -> Dict[str, Any]: """Get current status of monitors and active alerts with comprehensive error handling. - + This method retrieves monitor status information for individual or all monitors, with optional filtering by status (triggered, normal, disabled, unknown). - + Args: monitor_id: Optional monitor ID to get status for specific monitor filter_status: Optional status filter (triggered, normal, disabled, unknown) - + Returns: Dictionary containing monitor status information with trigger timestamps and severity - + Raises: MonitorValidationError: If parameters are invalid MonitorNotFoundError: If monitor doesn't exist MonitorError: If status retrieval fails RateLimitError: If rate limit is exceeded """ + async def _get_monitor_status_impl(): # Validate filter_status if provided if filter_status: @@ -4364,21 +4726,21 @@ async def _get_monitor_status_impl(): field_name="filter_status", field_value=filter_status, context=create_monitor_error_context( - "get_monitor_status", - valid_statuses=valid_statuses - ) + "get_monitor_status", valid_statuses=valid_statuses + ), ) - + # Validate monitor_id if provided validated_monitor_id = None if monitor_id: - validated_monitor_id = await validate_monitor_id(monitor_id, "get_monitor_status") - + validated_monitor_id = await validate_monitor_id( + monitor_id, "get_monitor_status" + ) + # Call API client to get monitor status with error handling try: api_response = await self.api_client.get_monitor_status( - monitor_id=validated_monitor_id, - filter_status=filter_status + monitor_id=validated_monitor_id, filter_status=filter_status ) except RateLimitError as e: raise RateLimitError( @@ -4388,25 +4750,27 @@ async def _get_monitor_status_impl(): context=create_monitor_error_context( "get_monitor_status", monitor_id=validated_monitor_id, - filter_status=filter_status - ) + filter_status=filter_status, + ), ) from e - + # Handle single monitor vs multiple monitors response if validated_monitor_id: # Single monitor status try: status_data = api_response.get("data", {}) - formatted_status = self._format_single_monitor_status(status_data, validated_monitor_id) - + formatted_status = self._format_single_monitor_status( + status_data, validated_monitor_id + ) + response = { "success": True, "monitor_status": formatted_status, "metadata": { "monitor_id": validated_monitor_id, "retrieved_at": datetime.now().isoformat(), - "status_type": "single_monitor" - } + "status_type": "single_monitor", + }, } except Exception as e: raise MonitorError( @@ -4416,18 +4780,20 @@ async def _get_monitor_status_impl(): context=create_monitor_error_context( "get_monitor_status", monitor_id=validated_monitor_id, - format_error=str(e) - ) + format_error=str(e), + ), ) from e else: # Multiple monitors status try: status_list = api_response.get("data", []) - + # Apply client-side filtering if needed if filter_status: - status_list = self._filter_status_by_condition(status_list, filter_status) - + status_list = self._filter_status_by_condition( + status_list, filter_status + ) + # Format status information formatted_statuses = [] for status in status_list: @@ -4439,21 +4805,26 @@ async def _get_monitor_status_impl(): except Exception as e: logger.warning( f"Failed to format status for monitor {status.get('monitorId', 'unknown')}: {str(e)}", - extra={"monitor_id": status.get("monitorId"), "error": str(e)} + extra={ + "monitor_id": status.get("monitorId"), + "error": str(e), + }, ) # Continue with other monitors continue - + # Calculate status statistics try: - status_stats = self._calculate_status_statistics(formatted_statuses) + status_stats = self._calculate_status_statistics( + formatted_statuses + ) except Exception as e: logger.warning( f"Failed to calculate status statistics: {str(e)}", - extra={"status_count": len(formatted_statuses)} + extra={"status_count": len(formatted_statuses)}, ) status_stats = {"total": len(formatted_statuses)} - + response = { "success": True, "monitor_statuses": formatted_statuses, @@ -4461,9 +4832,9 @@ async def _get_monitor_status_impl(): "total_monitors": len(formatted_statuses), "filter_applied": filter_status, "retrieved_at": datetime.now().isoformat(), - "status_type": "multiple_monitors" + "status_type": "multiple_monitors", }, - "statistics": status_stats + "statistics": status_stats, } except Exception as e: raise MonitorError( @@ -4472,27 +4843,29 @@ async def _get_monitor_status_impl(): context=create_monitor_error_context( "get_monitor_status", filter_status=filter_status, - processing_error=str(e) - ) + processing_error=str(e), + ), ) from e - + return response - + # Execute with comprehensive error handling return await self.error_handler.execute_with_error_handling( "get_monitor_status", _get_monitor_status_impl, monitor_id=monitor_id, - timeout_override=25.0 + timeout_override=25.0, ) - - def _format_single_monitor_status(self, status_data: Dict[str, Any], monitor_id: str) -> Dict[str, Any]: + + def _format_single_monitor_status( + self, status_data: Dict[str, Any], monitor_id: str + ) -> Dict[str, Any]: """Format single monitor status information with trigger timestamps and severity. - + Args: status_data: Raw status data from API monitor_id: Monitor ID for context - + Returns: Formatted monitor status information """ @@ -4500,52 +4873,65 @@ def _format_single_monitor_status(self, status_data: Dict[str, Any], monitor_id: # Extract basic status information monitor_name = status_data.get("monitorName", "Unknown") current_status = status_data.get("status", "Unknown") - + # Map API status to our enum values status_mapping = { "Normal": MonitorStatus.NORMAL, "Triggered": MonitorStatus.TRIGGERED, "Disabled": MonitorStatus.DISABLED, - "Unknown": MonitorStatus.UNKNOWN + "Unknown": MonitorStatus.UNKNOWN, } - - normalized_status = status_mapping.get(current_status, MonitorStatus.UNKNOWN) - + + normalized_status = status_mapping.get( + current_status, MonitorStatus.UNKNOWN + ) + # Extract trigger information last_triggered = status_data.get("lastTriggered") current_trigger_severity = status_data.get("currentTriggerSeverity") trigger_count_24h = status_data.get("triggerCount24h", 0) - + # Extract evaluation timing last_evaluation = status_data.get("lastEvaluation") next_evaluation = status_data.get("nextEvaluation") - + # Format timestamps for better readability formatted_status = { "monitor_id": monitor_id, "monitor_name": monitor_name, "status": normalized_status.value, - "status_description": self._get_status_description(normalized_status, current_trigger_severity), + "status_description": self._get_status_description( + normalized_status, current_trigger_severity + ), "last_triggered": self._format_timestamp_with_relative(last_triggered), "trigger_count_24h": trigger_count_24h, "current_trigger_severity": current_trigger_severity, - "last_evaluation": self._format_timestamp_with_relative(last_evaluation), - "next_evaluation": self._format_timestamp_with_relative(next_evaluation), + "last_evaluation": self._format_timestamp_with_relative( + last_evaluation + ), + "next_evaluation": self._format_timestamp_with_relative( + next_evaluation + ), "health_indicators": self._generate_status_health_indicators( normalized_status, trigger_count_24h, last_triggered - ) + ), } - + # Add trigger details if currently triggered - if normalized_status == MonitorStatus.TRIGGERED and current_trigger_severity: - formatted_status["trigger_details"] = self._extract_trigger_details(status_data) - + if ( + normalized_status == MonitorStatus.TRIGGERED + and current_trigger_severity + ): + formatted_status["trigger_details"] = self._extract_trigger_details( + status_data + ) + return formatted_status - + except Exception as e: logger.warning( "Error formatting monitor status", - extra={"error": str(e), "monitor_id": monitor_id} + extra={"error": str(e), "monitor_id": monitor_id}, ) # Return basic status information if formatting fails return { @@ -4553,16 +4939,18 @@ def _format_single_monitor_status(self, status_data: Dict[str, Any], monitor_id: "monitor_name": status_data.get("monitorName", "Unknown"), "status": status_data.get("status", "Unknown"), "status_description": "Status information partially unavailable", - "error": f"Formatting error: {str(e)}" + "error": f"Formatting error: {str(e)}", } - - def _filter_status_by_condition(self, status_list: List[Dict[str, Any]], filter_status: str) -> List[Dict[str, Any]]: + + def _filter_status_by_condition( + self, status_list: List[Dict[str, Any]], filter_status: str + ) -> List[Dict[str, Any]]: """Filter status list by status condition. - + Args: status_list: List of status dictionaries filter_status: Status filter to apply - + Returns: Filtered list of status dictionaries """ @@ -4570,26 +4958,28 @@ def _filter_status_by_condition(self, status_list: List[Dict[str, Any]], filter_ "triggered": lambda s: s.get("status") == "Triggered", "normal": lambda s: s.get("status") == "Normal", "disabled": lambda s: s.get("status") == "Disabled", - "unknown": lambda s: s.get("status") == "Unknown" + "unknown": lambda s: s.get("status") == "Unknown", } - + filter_func = status_filter_map.get(filter_status) if filter_func: return [status for status in status_list if filter_func(status)] - + return status_list - - def _calculate_status_statistics(self, formatted_statuses: List[Dict[str, Any]]) -> Dict[str, Any]: + + def _calculate_status_statistics( + self, formatted_statuses: List[Dict[str, Any]] + ) -> Dict[str, Any]: """Calculate statistics for monitor status information. - + Args: formatted_statuses: List of formatted status dictionaries - + Returns: Dictionary containing status statistics """ total_monitors = len(formatted_statuses) - + if total_monitors == 0: return { "total_monitors": 0, @@ -4597,120 +4987,129 @@ def _calculate_status_statistics(self, formatted_statuses: List[Dict[str, Any]]) "alert_summary": { "total_triggers_24h": 0, "monitors_with_alerts": 0, - "severity_breakdown": {} - } + "severity_breakdown": {}, + }, } - + # Count by status status_counts = {} total_triggers_24h = 0 monitors_with_alerts = 0 severity_counts = {} - + for status in formatted_statuses: # Count status types status_type = status.get("status", "Unknown") status_counts[status_type] = status_counts.get(status_type, 0) + 1 - + # Count triggers trigger_count = status.get("trigger_count_24h", 0) total_triggers_24h += trigger_count if trigger_count > 0: monitors_with_alerts += 1 - + # Count severity levels for triggered monitors if status_type == "Triggered": severity = status.get("current_trigger_severity", "Unknown") severity_counts[severity] = severity_counts.get(severity, 0) + 1 - + return { "total_monitors": total_monitors, "status_breakdown": status_counts, "alert_summary": { "total_triggers_24h": total_triggers_24h, "monitors_with_alerts": monitors_with_alerts, - "average_triggers_per_monitor": round(total_triggers_24h / total_monitors, 2), - "severity_breakdown": severity_counts + "average_triggers_per_monitor": round( + total_triggers_24h / total_monitors, 2 + ), + "severity_breakdown": severity_counts, }, - "health_score": self._calculate_overall_health_score(status_counts, total_monitors) + "health_score": self._calculate_overall_health_score( + status_counts, total_monitors + ), } - - def _get_status_description(self, status: MonitorStatus, severity: Optional[str] = None) -> str: + + def _get_status_description( + self, status: MonitorStatus, severity: Optional[str] = None + ) -> str: """Get human-readable status description. - + Args: status: Monitor status enum severity: Current trigger severity if applicable - + Returns: Human-readable status description """ descriptions = { MonitorStatus.NORMAL: "Monitor is operating normally with no active alerts", MonitorStatus.DISABLED: "Monitor is disabled and not evaluating conditions", - MonitorStatus.UNKNOWN: "Monitor status could not be determined" + MonitorStatus.UNKNOWN: "Monitor status could not be determined", } - + if status == MonitorStatus.TRIGGERED: if severity: return f"Monitor is currently triggered with {severity} severity alert" else: return "Monitor is currently triggered with active alert" - + return descriptions.get(status, "Status information unavailable") - - def _format_timestamp_with_relative(self, timestamp: Optional[str]) -> Optional[Dict[str, Any]]: + + def _format_timestamp_with_relative( + self, timestamp: Optional[str] + ) -> Optional[Dict[str, Any]]: """Format timestamp with both absolute and relative time information. - + Args: timestamp: ISO timestamp string - + Returns: Dictionary with formatted timestamp information or None """ if not timestamp: return None - + try: from datetime import datetime, timezone + import dateutil.parser - + # Parse the timestamp dt = dateutil.parser.parse(timestamp) - + # Calculate relative time now = datetime.now(timezone.utc) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) - + time_diff = now - dt relative_time = self._format_relative_time(time_diff) - + return { "absolute": timestamp, "relative": relative_time, - "formatted": dt.strftime("%Y-%m-%d %H:%M:%S UTC") + "formatted": dt.strftime("%Y-%m-%d %H:%M:%S UTC"), } - + except Exception as e: logger.warning(f"Error formatting timestamp {timestamp}: {e}") return { "absolute": timestamp, "relative": "unknown", - "formatted": timestamp + "formatted": timestamp, } - + def _format_relative_time(self, time_diff) -> str: """Format time difference as relative time string. - + Args: time_diff: timedelta object - + Returns: Human-readable relative time string """ total_seconds = int(time_diff.total_seconds()) - + if total_seconds < 60: return f"{total_seconds} seconds ago" elif total_seconds < 3600: @@ -4722,20 +5121,20 @@ def _format_relative_time(self, time_diff) -> str: else: days = total_seconds // 86400 return f"{days} day{'s' if days != 1 else ''} ago" - + def _generate_status_health_indicators( self, status: MonitorStatus, trigger_count_24h: int, - last_triggered: Optional[Dict[str, Any]] + last_triggered: Optional[Dict[str, Any]], ) -> Dict[str, Any]: """Generate health indicators for monitor status. - + Args: status: Monitor status trigger_count_24h: Number of triggers in last 24 hours last_triggered: Last trigger timestamp information - + Returns: Dictionary containing health indicators """ @@ -4743,9 +5142,9 @@ def _generate_status_health_indicators( "overall_health": "unknown", "stability": "unknown", "alert_frequency": "unknown", - "recommendations": [] + "recommendations": [], } - + try: # Determine overall health if status == MonitorStatus.NORMAL: @@ -4761,7 +5160,7 @@ def _generate_status_health_indicators( indicators["overall_health"] = "disabled" else: indicators["overall_health"] = "unknown" - + # Determine stability if trigger_count_24h == 0: indicators["stability"] = "stable" @@ -4771,7 +5170,7 @@ def _generate_status_health_indicators( indicators["stability"] = "unstable" else: indicators["stability"] = "very_unstable" - + # Determine alert frequency if trigger_count_24h == 0: indicators["alert_frequency"] = "none" @@ -4781,32 +5180,40 @@ def _generate_status_health_indicators( indicators["alert_frequency"] = "moderate" else: indicators["alert_frequency"] = "high" - + # Generate recommendations if status == MonitorStatus.TRIGGERED: - indicators["recommendations"].append("Investigate current alert and resolve underlying issue") - + indicators["recommendations"].append( + "Investigate current alert and resolve underlying issue" + ) + if trigger_count_24h > 5: - indicators["recommendations"].append("Consider adjusting trigger thresholds to reduce alert noise") - + indicators["recommendations"].append( + "Consider adjusting trigger thresholds to reduce alert noise" + ) + if status == MonitorStatus.DISABLED: - indicators["recommendations"].append("Review if monitor should be re-enabled") - + indicators["recommendations"].append( + "Review if monitor should be re-enabled" + ) + if trigger_count_24h == 0 and last_triggered: - indicators["recommendations"].append("Monitor appears stable - consider if thresholds are appropriate") - + indicators["recommendations"].append( + "Monitor appears stable - consider if thresholds are appropriate" + ) + except Exception as e: logger.warning(f"Error generating health indicators: {e}") indicators["error"] = str(e) - + return indicators - + def _extract_trigger_details(self, status_data: Dict[str, Any]) -> Dict[str, Any]: """Extract detailed trigger information for currently triggered monitors. - + Args: status_data: Raw status data from API - + Returns: Dictionary containing trigger details """ @@ -4817,60 +5224,69 @@ def _extract_trigger_details(self, status_data: Dict[str, Any]) -> Dict[str, Any "trigger_value": status_data.get("currentTriggerValue"), "threshold": status_data.get("triggerThreshold"), "condition": status_data.get("triggerCondition"), - "duration": None + "duration": None, } - + # Calculate trigger duration if we have the timestamp if trigger_details["triggered_at"]: try: from datetime import datetime, timezone + import dateutil.parser - - triggered_dt = dateutil.parser.parse(trigger_details["triggered_at"]) + + triggered_dt = dateutil.parser.parse( + trigger_details["triggered_at"] + ) now = datetime.now(timezone.utc) if triggered_dt.tzinfo is None: triggered_dt = triggered_dt.replace(tzinfo=timezone.utc) - + duration = now - triggered_dt trigger_details["duration"] = self._format_relative_time(duration) - + except Exception as e: logger.warning(f"Error calculating trigger duration: {e}") - + return trigger_details - + except Exception as e: logger.warning(f"Error extracting trigger details: {e}") return {"error": f"Could not extract trigger details: {str(e)}"} - - def _calculate_overall_health_score(self, status_counts: Dict[str, int], total_monitors: int) -> Dict[str, Any]: + + def _calculate_overall_health_score( + self, status_counts: Dict[str, int], total_monitors: int + ) -> Dict[str, Any]: """Calculate overall health score for all monitors. - + Args: status_counts: Count of monitors by status total_monitors: Total number of monitors - + Returns: Dictionary containing health score information """ if total_monitors == 0: - return {"score": 0, "grade": "N/A", "description": "No monitors to evaluate"} - + return { + "score": 0, + "grade": "N/A", + "description": "No monitors to evaluate", + } + # Calculate weighted score weights = { "Normal": 100, "Disabled": 50, # Neutral - not good or bad "Triggered": 0, - "Unknown": 25 + "Unknown": 25, } - + total_score = 0 for status, count in status_counts.items(): weight = weights.get(status, 0) total_score += weight * count - + health_score = total_score / total_monitors - + # Determine grade if health_score >= 90: grade = "A" @@ -4887,35 +5303,34 @@ def _calculate_overall_health_score(self, status_counts: Dict[str, int], total_m else: grade = "F" description = "Critical - Significant monitoring problems" - + return { "score": round(health_score, 1), "grade": grade, - "description": description + "description": description, } - + async def get_active_alerts( - self, - severity: Optional[str] = None, - limit: int = 100 + self, severity: Optional[str] = None, limit: int = 100 ) -> Dict[str, Any]: """Get all currently active alerts with comprehensive error handling. - + This method retrieves all active alerts across monitors with optional filtering by severity level. Results are sorted by severity and trigger time for priority handling. - + Args: severity: Optional severity filter (Critical, Warning, MissingData) limit: Maximum number of alerts to return (1-1000) - + Returns: Dictionary containing active alerts with trigger details and context - + Raises: MonitorValidationError: If parameters are invalid MonitorError: If alert retrieval fails RateLimitError: If rate limit is exceeded """ + async def _get_active_alerts_impl(): # Validate limit parameter if limit < 1 or limit > 1000: @@ -4923,9 +5338,9 @@ async def _get_active_alerts_impl(): "Limit must be between 1 and 1000", field_name="limit", field_value=limit, - context=create_monitor_error_context("get_active_alerts") + context=create_monitor_error_context("get_active_alerts"), ) - + # Validate severity if provided if severity: valid_severities = ["Critical", "Warning", "MissingData"] @@ -4935,16 +5350,14 @@ async def _get_active_alerts_impl(): field_name="severity", field_value=severity, context=create_monitor_error_context( - "get_active_alerts", - valid_severities=valid_severities - ) + "get_active_alerts", valid_severities=valid_severities + ), ) - + # Call API client to get active alerts with error handling try: api_response = await self.api_client.get_active_alerts( - severity=severity, - limit=limit + severity=severity, limit=limit ) except RateLimitError as e: raise RateLimitError( @@ -4952,41 +5365,41 @@ async def _get_active_alerts_impl(): retry_after=e.retry_after, limit_type=e.limit_type, context=create_monitor_error_context( - "get_active_alerts", - severity=severity, - limit=limit - ) + "get_active_alerts", severity=severity, limit=limit + ), ) from e - + # Extract alerts from response alerts_data = api_response.get("data", []) total_count = api_response.get("total", len(alerts_data)) - + # Handle case with no active alerts if not alerts_data: return self._format_no_alerts_response(severity, total_count) - + # Format alerts with error handling for individual alerts formatted_alerts = [] formatting_errors = [] - + for i, alert_data in enumerate(alerts_data): try: formatted_alert = self._format_single_alert(alert_data) formatted_alerts.append(formatted_alert) except Exception as e: - formatting_errors.append({ - "index": i, - "alert_id": alert_data.get("alertId", "unknown"), - "error": str(e) - }) + formatting_errors.append( + { + "index": i, + "alert_id": alert_data.get("alertId", "unknown"), + "error": str(e), + } + ) logger.warning( f"Failed to format alert {alert_data.get('alertId', 'unknown')}: {str(e)}", - extra={"alert_data": alert_data, "error": str(e)} + extra={"alert_data": alert_data, "error": str(e)}, ) # Continue with other alerts continue - + if not formatted_alerts and formatting_errors: raise MonitorError( f"Failed to format any alerts: {len(formatting_errors)} formatting errors", @@ -4994,30 +5407,30 @@ async def _get_active_alerts_impl(): context=create_monitor_error_context( "get_active_alerts", formatting_errors=formatting_errors, - total_alerts=len(alerts_data) - ) + total_alerts=len(alerts_data), + ), ) - + # Sort alerts by severity and trigger time try: sorted_alerts = self._sort_alerts_by_priority(formatted_alerts) except Exception as e: logger.warning( f"Failed to sort alerts by priority: {str(e)}", - extra={"alert_count": len(formatted_alerts)} + extra={"alert_count": len(formatted_alerts)}, ) sorted_alerts = formatted_alerts # Use unsorted alerts - + # Calculate alert statistics try: alert_stats = self._calculate_alert_statistics(sorted_alerts) except Exception as e: logger.warning( f"Failed to calculate alert statistics: {str(e)}", - extra={"alert_count": len(sorted_alerts)} + extra={"alert_count": len(sorted_alerts)}, ) alert_stats = {"total": len(sorted_alerts)} - + response = { "success": True, "active_alerts": sorted_alerts, @@ -5026,42 +5439,36 @@ async def _get_active_alerts_impl(): "returned_count": len(sorted_alerts), "severity_filter": severity, "limit": limit, - "retrieved_at": datetime.now().isoformat() + "retrieved_at": datetime.now().isoformat(), }, - "statistics": alert_stats + "statistics": alert_stats, } - + # Add formatting warnings if any if formatting_errors: response["warnings"] = { "formatting_errors": len(formatting_errors), - "message": f"{len(formatting_errors)} alerts could not be formatted and were skipped" + "message": f"{len(formatting_errors)} alerts could not be formatted and were skipped", } - + return response - + # Execute with comprehensive error handling return await self.error_handler.execute_with_error_handling( - "get_active_alerts", - _get_active_alerts_impl, - timeout_override=20.0 + "get_active_alerts", _get_active_alerts_impl, timeout_override=20.0 ) logger.info( - "Getting active alerts", - extra={ - "severity": severity, - "limit": limit - } + "Getting active alerts", extra={"severity": severity, "limit": limit} ) - + # Validate parameters if limit < 1 or limit > 1000: raise ValidationError( "Limit must be between 1 and 1000", field_name="limit", - field_value=limit + field_value=limit, ) - + # Validate severity if provided if severity: valid_severities = ["Critical", "Warning", "MissingData"] @@ -5069,36 +5476,34 @@ async def _get_active_alerts_impl(): raise ValidationError( f"Invalid severity. Must be one of: {', '.join(valid_severities)}", field_name="severity", - field_value=severity + field_value=severity, ) - + try: # Call API client to get active alerts api_response = await self.api_client.get_active_alerts( - severity=severity, - limit=limit + severity=severity, limit=limit ) - + # Extract alerts from response alerts_data = api_response.get("data", []) total_count = api_response.get("total", len(alerts_data)) - + # Handle case with no active alerts if not alerts_data: return self._format_no_alerts_response(severity, total_count) - + # Format and sort alerts formatted_alerts = [ - self._format_single_alert(alert_data) - for alert_data in alerts_data + self._format_single_alert(alert_data) for alert_data in alerts_data ] - + # Sort alerts by severity and trigger time sorted_alerts = self._sort_alerts_by_priority(formatted_alerts) - + # Calculate alert statistics alert_stats = self._calculate_alert_statistics(sorted_alerts) - + response = { "success": True, "active_alerts": sorted_alerts, @@ -5107,79 +5512,71 @@ async def _get_active_alerts_impl(): "returned_count": len(sorted_alerts), "severity_filter": severity, "retrieved_at": datetime.now().isoformat(), - "has_more": len(sorted_alerts) < total_count + "has_more": len(sorted_alerts) < total_count, }, "statistics": alert_stats, - "summary": self._generate_alert_summary(sorted_alerts, alert_stats) + "summary": self._generate_alert_summary(sorted_alerts, alert_stats), } - + logger.info( "Successfully retrieved active alerts", extra={ "total_alerts": total_count, "returned_count": len(sorted_alerts), - "severity_filter": severity - } + "severity_filter": severity, + }, ) - + return response - + except APIError as e: logger.error( "Failed to get active alerts", - extra={ - "error": str(e), - "severity": severity, - "limit": limit - } + extra={"error": str(e), "severity": severity, "limit": limit}, ) - + # Handle specific error cases if e.status_code == 403: raise APIError( "Insufficient permissions to access active alerts", status_code=e.status_code, request_id=e.request_id, - context={ - "operation": "get_active_alerts", - "severity": severity - } + context={"operation": "get_active_alerts", "severity": severity}, ) from e else: raise APIError( f"Failed to get active alerts: {e.message}", status_code=e.status_code, request_id=e.request_id, - context={ - "operation": "get_active_alerts", - "severity": severity - } + context={"operation": "get_active_alerts", "severity": severity}, ) from e - + except Exception as e: logger.error( "Unexpected error getting active alerts", - extra={"error": str(e), "error_type": type(e).__name__} + extra={"error": str(e), "error_type": type(e).__name__}, ) raise APIError( f"Unexpected error getting active alerts: {str(e)}", - context={"operation": "get_active_alerts"} + context={"operation": "get_active_alerts"}, ) from e - - def _format_no_alerts_response(self, severity: Optional[str], total_count: int) -> Dict[str, Any]: + + def _format_no_alerts_response( + self, severity: Optional[str], total_count: int + ) -> Dict[str, Any]: """Format response when no active alerts are found. - + Args: severity: Severity filter that was applied total_count: Total count from API response - + Returns: Formatted response for no alerts case """ message = "No active alerts found" if severity: message += f" with {severity} severity" - + return { "success": True, "active_alerts": [], @@ -5188,29 +5585,33 @@ def _format_no_alerts_response(self, severity: Optional[str], total_count: int) "returned_count": 0, "severity_filter": severity, "retrieved_at": datetime.now().isoformat(), - "has_more": False + "has_more": False, }, "statistics": { "total_alerts": 0, "severity_breakdown": {}, "monitors_with_alerts": 0, - "average_alert_age": 0 + "average_alert_age": 0, }, "summary": { "message": message, "status": "healthy" if not severity else "filtered_healthy", "recommendations": [ - "All monitors are operating normally" if not severity else f"No {severity} alerts currently active" - ] - } + ( + "All monitors are operating normally" + if not severity + else f"No {severity} alerts currently active" + ) + ], + }, } - + def _format_single_alert(self, alert_data: Dict[str, Any]) -> Dict[str, Any]: """Format single alert information with trigger details and context. - + Args: alert_data: Raw alert data from API - + Returns: Formatted alert information """ @@ -5220,13 +5621,13 @@ def _format_single_alert(self, alert_data: Dict[str, Any]) -> Dict[str, Any]: monitor_id = alert_data.get("monitorId", "unknown") monitor_name = alert_data.get("monitorName", "Unknown Monitor") severity = alert_data.get("severity", "Unknown") - + # Extract trigger information triggered_at = alert_data.get("triggeredAt") trigger_value = alert_data.get("triggerValue") threshold = alert_data.get("threshold") query = alert_data.get("query", "") - + # Format the alert formatted_alert = { "alert_id": alert_id, @@ -5239,23 +5640,29 @@ def _format_single_alert(self, alert_data: Dict[str, Any]) -> Dict[str, Any]: "trigger_details": { "value": trigger_value, "threshold": threshold, - "comparison": self._format_threshold_comparison(trigger_value, threshold), - "query": query[:200] + "..." if len(query) > 200 else query + "comparison": self._format_threshold_comparison( + trigger_value, threshold + ), + "query": query[:200] + "..." if len(query) > 200 else query, }, "alert_context": self._generate_alert_context(alert_data), - "urgency_indicators": self._generate_urgency_indicators(severity, triggered_at, trigger_value, threshold) + "urgency_indicators": self._generate_urgency_indicators( + severity, triggered_at, trigger_value, threshold + ), } - + # Add duration information if triggered_at: - formatted_alert["duration"] = self._calculate_alert_duration(triggered_at) - + formatted_alert["duration"] = self._calculate_alert_duration( + triggered_at + ) + return formatted_alert - + except Exception as e: logger.warning( "Error formatting alert", - extra={"error": str(e), "alert_id": alert_data.get("alertId")} + extra={"error": str(e), "alert_id": alert_data.get("alertId")}, ) # Return basic alert information if formatting fails return { @@ -5264,27 +5671,31 @@ def _format_single_alert(self, alert_data: Dict[str, Any]) -> Dict[str, Any]: "monitor_name": alert_data.get("monitorName", "Unknown Monitor"), "severity": alert_data.get("severity", "Unknown"), "status": "Active", - "error": f"Formatting error: {str(e)}" + "error": f"Formatting error: {str(e)}", } - - def _sort_alerts_by_priority(self, alerts: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + + def _sort_alerts_by_priority( + self, alerts: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: """Sort alerts by severity and trigger time for priority handling. - + Args: alerts: List of formatted alert dictionaries - + Returns: Sorted list of alerts (highest priority first) """ + def sort_key(alert): # Primary sort: severity priority (lower number = higher priority) severity_priority = alert.get("severity_priority", 999) - + # Secondary sort: trigger time (newer alerts first) triggered_at = alert.get("triggered_at", {}) if isinstance(triggered_at, dict) and "absolute" in triggered_at: try: import dateutil.parser + dt = dateutil.parser.parse(triggered_at["absolute"]) # Negative timestamp for reverse chronological order time_priority = -dt.timestamp() @@ -5292,40 +5703,38 @@ def sort_key(alert): time_priority = 0 else: time_priority = 0 - + return (severity_priority, time_priority) - + return sorted(alerts, key=sort_key) - + def _get_severity_priority(self, severity: str) -> int: """Get numeric priority for severity level (lower = higher priority). - + Args: severity: Severity level string - + Returns: Numeric priority value """ - priority_map = { - "Critical": 1, - "Warning": 2, - "MissingData": 3 - } + priority_map = {"Critical": 1, "Warning": 2, "MissingData": 3} return priority_map.get(severity, 999) - - def _format_threshold_comparison(self, trigger_value: Optional[float], threshold: Optional[float]) -> str: + + def _format_threshold_comparison( + self, trigger_value: Optional[float], threshold: Optional[float] + ) -> str: """Format threshold comparison for human readability. - + Args: trigger_value: Value that triggered the alert threshold: Threshold that was exceeded - + Returns: Human-readable comparison string """ if trigger_value is None or threshold is None: return "Comparison data unavailable" - + try: if trigger_value > threshold: percentage = ((trigger_value - threshold) / threshold) * 100 @@ -5337,13 +5746,13 @@ def _format_threshold_comparison(self, trigger_value: Optional[float], threshold return f"{trigger_value} equals threshold {threshold}" except (ZeroDivisionError, TypeError): return f"{trigger_value} vs threshold {threshold}" - + def _generate_alert_context(self, alert_data: Dict[str, Any]) -> Dict[str, Any]: """Generate contextual information for the alert. - + Args: alert_data: Raw alert data from API - + Returns: Dictionary containing alert context information """ @@ -5352,33 +5761,33 @@ def _generate_alert_context(self, alert_data: Dict[str, Any]) -> Dict[str, Any]: "trigger_condition": alert_data.get("triggerCondition", {}), "notification_sent": alert_data.get("notificationSent", False), "escalation_level": alert_data.get("escalationLevel", 1), - "related_alerts": alert_data.get("relatedAlerts", []) + "related_alerts": alert_data.get("relatedAlerts", []), } - + # Add business impact assessment context["business_impact"] = self._assess_business_impact( alert_data.get("severity"), alert_data.get("monitorName", ""), - alert_data.get("triggerValue") + alert_data.get("triggerValue"), ) - + return context - + def _generate_urgency_indicators( self, severity: str, triggered_at: Optional[str], trigger_value: Optional[float], - threshold: Optional[float] + threshold: Optional[float], ) -> Dict[str, Any]: """Generate urgency indicators for the alert. - + Args: severity: Alert severity level triggered_at: When the alert was triggered trigger_value: Value that triggered the alert threshold: Threshold that was exceeded - + Returns: Dictionary containing urgency indicators """ @@ -5386,9 +5795,9 @@ def _generate_urgency_indicators( "urgency_level": "unknown", "response_time_recommendation": "unknown", "escalation_recommended": False, - "factors": [] + "factors": [], } - + try: # Base urgency on severity if severity == "Critical": @@ -5400,63 +5809,72 @@ def _generate_urgency_indicators( else: indicators["urgency_level"] = "low" indicators["response_time_recommendation"] = "within 2 hours" - + # Adjust based on duration if triggered_at: duration_minutes = self._get_alert_duration_minutes(triggered_at) if duration_minutes > 60: # Alert active for over 1 hour indicators["escalation_recommended"] = True - indicators["factors"].append("Alert has been active for over 1 hour") - + indicators["factors"].append( + "Alert has been active for over 1 hour" + ) + if duration_minutes > 240: # Alert active for over 4 hours indicators["urgency_level"] = "critical" - indicators["factors"].append("Alert has been active for over 4 hours") - + indicators["factors"].append( + "Alert has been active for over 4 hours" + ) + # Adjust based on threshold breach severity if trigger_value is not None and threshold is not None and threshold != 0: breach_percentage = abs((trigger_value - threshold) / threshold) * 100 if breach_percentage > 200: # More than 200% breach indicators["urgency_level"] = "critical" - indicators["factors"].append(f"Threshold breached by {breach_percentage:.0f}%") + indicators["factors"].append( + f"Threshold breached by {breach_percentage:.0f}%" + ) elif breach_percentage > 100: # More than 100% breach if indicators["urgency_level"] == "low": indicators["urgency_level"] = "medium" - indicators["factors"].append(f"Significant threshold breach ({breach_percentage:.0f}%)") - + indicators["factors"].append( + f"Significant threshold breach ({breach_percentage:.0f}%)" + ) + except Exception as e: logger.warning(f"Error generating urgency indicators: {e}") indicators["error"] = str(e) - + return indicators - + def _calculate_alert_duration(self, triggered_at: str) -> Dict[str, Any]: """Calculate how long the alert has been active. - + Args: triggered_at: ISO timestamp when alert was triggered - + Returns: Dictionary containing duration information """ try: from datetime import datetime, timezone + import dateutil.parser - + triggered_dt = dateutil.parser.parse(triggered_at) now = datetime.now(timezone.utc) if triggered_dt.tzinfo is None: triggered_dt = triggered_dt.replace(tzinfo=timezone.utc) - + duration = now - triggered_dt duration_minutes = int(duration.total_seconds() / 60) - + return { "total_minutes": duration_minutes, "human_readable": self._format_relative_time(duration), "is_stale": duration_minutes > 240, # Over 4 hours - "requires_attention": duration_minutes > 60 # Over 1 hour + "requires_attention": duration_minutes > 60, # Over 1 hour } - + except Exception as e: logger.warning(f"Error calculating alert duration: {e}") return { @@ -5464,79 +5882,86 @@ def _calculate_alert_duration(self, triggered_at: str) -> Dict[str, Any]: "human_readable": "unknown", "is_stale": False, "requires_attention": False, - "error": str(e) + "error": str(e), } - + def _get_alert_duration_minutes(self, triggered_at: str) -> int: """Get alert duration in minutes. - + Args: triggered_at: ISO timestamp when alert was triggered - + Returns: Duration in minutes """ try: from datetime import datetime, timezone + import dateutil.parser - + triggered_dt = dateutil.parser.parse(triggered_at) now = datetime.now(timezone.utc) if triggered_dt.tzinfo is None: triggered_dt = triggered_dt.replace(tzinfo=timezone.utc) - + duration = now - triggered_dt return int(duration.total_seconds() / 60) - + except Exception: return 0 - - def _calculate_alert_statistics(self, alerts: List[Dict[str, Any]]) -> Dict[str, Any]: + + def _calculate_alert_statistics( + self, alerts: List[Dict[str, Any]] + ) -> Dict[str, Any]: """Calculate statistics for active alerts. - + Args: alerts: List of formatted alert dictionaries - + Returns: Dictionary containing alert statistics """ total_alerts = len(alerts) - + if total_alerts == 0: return { "total_alerts": 0, "severity_breakdown": {}, "monitors_with_alerts": 0, "average_alert_age": 0, - "urgency_breakdown": {} + "urgency_breakdown": {}, } - + # Count by severity severity_counts = {} urgency_counts = {} monitor_ids = set() total_duration_minutes = 0 - + for alert in alerts: # Count severity severity = alert.get("severity", "Unknown") severity_counts[severity] = severity_counts.get(severity, 0) + 1 - + # Count urgency - urgency = alert.get("urgency_indicators", {}).get("urgency_level", "unknown") + urgency = alert.get("urgency_indicators", {}).get( + "urgency_level", "unknown" + ) urgency_counts[urgency] = urgency_counts.get(urgency, 0) + 1 - + # Track unique monitors monitor_id = alert.get("monitor_id") if monitor_id: monitor_ids.add(monitor_id) - + # Sum duration for average duration = alert.get("duration", {}).get("total_minutes", 0) total_duration_minutes += duration - - average_age_minutes = total_duration_minutes / total_alerts if total_alerts > 0 else 0 - + + average_age_minutes = ( + total_duration_minutes / total_alerts if total_alerts > 0 else 0 + ) + return { "total_alerts": total_alerts, "severity_breakdown": severity_counts, @@ -5544,26 +5969,30 @@ def _calculate_alert_statistics(self, alerts: List[Dict[str, Any]]) -> Dict[str, "monitors_with_alerts": len(monitor_ids), "average_alert_age": { "minutes": round(average_age_minutes, 1), - "human_readable": self._format_minutes_to_human(average_age_minutes) + "human_readable": self._format_minutes_to_human(average_age_minutes), }, - "stale_alerts": len([a for a in alerts if a.get("duration", {}).get("is_stale", False)]), - "critical_alerts": severity_counts.get("Critical", 0) + "stale_alerts": len( + [a for a in alerts if a.get("duration", {}).get("is_stale", False)] + ), + "critical_alerts": severity_counts.get("Critical", 0), } - - def _generate_alert_summary(self, alerts: List[Dict[str, Any]], stats: Dict[str, Any]) -> Dict[str, Any]: + + def _generate_alert_summary( + self, alerts: List[Dict[str, Any]], stats: Dict[str, Any] + ) -> Dict[str, Any]: """Generate summary information for active alerts. - + Args: alerts: List of formatted alert dictionaries stats: Alert statistics - + Returns: Dictionary containing alert summary """ total_alerts = stats["total_alerts"] critical_count = stats.get("critical_alerts", 0) stale_count = stats.get("stale_alerts", 0) - + # Determine overall status if total_alerts == 0: status = "healthy" @@ -5577,36 +6006,44 @@ def _generate_alert_summary(self, alerts: List[Dict[str, Any]], stats: Dict[str, else: status = "warning" message = f"{total_alerts} active alert{'s' if total_alerts != 1 else ''}" - + # Generate recommendations recommendations = [] if critical_count > 0: - recommendations.append(f"Immediately investigate {critical_count} critical alert{'s' if critical_count != 1 else ''}") - + recommendations.append( + f"Immediately investigate {critical_count} critical alert{'s' if critical_count != 1 else ''}" + ) + if stale_count > 0: - recommendations.append(f"Review {stale_count} stale alert{'s' if stale_count != 1 else ''} - consider escalation or threshold adjustment") - + recommendations.append( + f"Review {stale_count} stale alert{'s' if stale_count != 1 else ''} - consider escalation or threshold adjustment" + ) + if total_alerts > 10: - recommendations.append("High alert volume detected - consider reviewing monitor thresholds") - + recommendations.append( + "High alert volume detected - consider reviewing monitor thresholds" + ) + if not recommendations and total_alerts > 0: recommendations.append("Monitor alerts and investigate root causes") - + return { "status": status, "message": message, "recommendations": recommendations, - "priority_actions": self._generate_priority_actions(alerts) + "priority_actions": self._generate_priority_actions(alerts), } - - def _assess_business_impact(self, severity: Optional[str], monitor_name: str, trigger_value: Optional[float]) -> str: + + def _assess_business_impact( + self, severity: Optional[str], monitor_name: str, trigger_value: Optional[float] + ) -> str: """Assess potential business impact of the alert. - + Args: severity: Alert severity level monitor_name: Name of the monitor trigger_value: Value that triggered the alert - + Returns: Business impact assessment string """ @@ -5616,13 +6053,13 @@ def _assess_business_impact(self, severity: Optional[str], monitor_name: str, tr return "Medium - Performance degradation possible" else: return "Low - Monitoring issue detected" - + def _format_minutes_to_human(self, minutes: float) -> str: """Format minutes to human-readable string. - + Args: minutes: Number of minutes - + Returns: Human-readable time string """ @@ -5636,66 +6073,79 @@ def _format_minutes_to_human(self, minutes: float) -> str: else: days = int(minutes / 1440) return f"{days} day{'s' if days != 1 else ''}" - - def _generate_priority_actions(self, alerts: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + + def _generate_priority_actions( + self, alerts: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: """Generate priority actions based on active alerts. - + Args: alerts: List of formatted alert dictionaries - + Returns: List of priority action dictionaries """ actions = [] - + # Find critical alerts critical_alerts = [a for a in alerts if a.get("severity") == "Critical"] if critical_alerts: - actions.append({ - "priority": 1, - "action": f"Investigate {len(critical_alerts)} critical alert{'s' if len(critical_alerts) != 1 else ''}", - "alert_ids": [a["alert_id"] for a in critical_alerts[:3]], # Top 3 - "urgency": "immediate" - }) - + actions.append( + { + "priority": 1, + "action": f"Investigate {len(critical_alerts)} critical alert{'s' if len(critical_alerts) != 1 else ''}", + "alert_ids": [a["alert_id"] for a in critical_alerts[:3]], # Top 3 + "urgency": "immediate", + } + ) + # Find stale alerts - stale_alerts = [a for a in alerts if a.get("duration", {}).get("is_stale", False)] + stale_alerts = [ + a for a in alerts if a.get("duration", {}).get("is_stale", False) + ] if stale_alerts: - actions.append({ - "priority": 2, - "action": f"Review {len(stale_alerts)} stale alert{'s' if len(stale_alerts) != 1 else ''} (active >4h)", - "alert_ids": [a["alert_id"] for a in stale_alerts[:3]], # Top 3 - "urgency": "high" - }) - + actions.append( + { + "priority": 2, + "action": f"Review {len(stale_alerts)} stale alert{'s' if len(stale_alerts) != 1 else ''} (active >4h)", + "alert_ids": [a["alert_id"] for a in stale_alerts[:3]], # Top 3 + "urgency": "high", + } + ) + # Find alerts requiring escalation escalation_alerts = [ - a for a in alerts + a + for a in alerts if a.get("urgency_indicators", {}).get("escalation_recommended", False) ] if escalation_alerts: - actions.append({ - "priority": 3, - "action": f"Consider escalating {len(escalation_alerts)} alert{'s' if len(escalation_alerts) != 1 else ''}", - "alert_ids": [a["alert_id"] for a in escalation_alerts[:3]], # Top 3 - "urgency": "medium" - }) - + actions.append( + { + "priority": 3, + "action": f"Consider escalating {len(escalation_alerts)} alert{'s' if len(escalation_alerts) != 1 else ''}", + "alert_ids": [ + a["alert_id"] for a in escalation_alerts[:3] + ], # Top 3 + "urgency": "medium", + } + ) + return actions - + async def enable_monitor(self, monitor_id: str) -> Dict[str, Any]: """Enable specified monitor with comprehensive error handling. - + This method enables a disabled monitor, allowing it to resume normal monitoring operations and trigger conditions evaluation. The operation includes status change confirmation and validation. - + Args: monitor_id: Unique identifier for the monitor to enable - + Returns: Dictionary containing enable operation result with status confirmation - + Raises: MonitorValidationError: If monitor_id is invalid MonitorNotFoundError: If monitor doesn't exist @@ -5703,19 +6153,28 @@ async def enable_monitor(self, monitor_id: str) -> Dict[str, Any]: MonitorOperationError: If enable operation fails MonitorError: If monitor operation fails """ + async def _enable_monitor_impl(): # Validate monitor_id with enhanced validation - validated_monitor_id = await validate_monitor_id(monitor_id, "enable_monitor") - + validated_monitor_id = await validate_monitor_id( + monitor_id, "enable_monitor" + ) + # Get current monitor status before enabling try: - current_monitor = await self.api_client.get_monitor(validated_monitor_id) - current_status = "disabled" if current_monitor.get("isDisabled", False) else "enabled" + current_monitor = await self.api_client.get_monitor( + validated_monitor_id + ) + current_status = ( + "disabled" + if current_monitor.get("isDisabled", False) + else "enabled" + ) monitor_name = current_monitor.get("name", "Unknown") except APIError as e: # The error handler will convert this to appropriate monitor error raise e - + # Check if monitor is already enabled if current_status == "enabled": return { @@ -5726,15 +6185,17 @@ async def _enable_monitor_impl(): "status_change": { "previous_status": "enabled", "new_status": "enabled", - "changed": False + "changed": False, }, "timestamp": datetime.now().isoformat(), - "no_action_required": True + "no_action_required": True, } - + # Enable the monitor via API try: - enable_result = await self.api_client.enable_monitor(validated_monitor_id) + enable_result = await self.api_client.enable_monitor( + validated_monitor_id + ) except RateLimitError as e: raise RateLimitError( f"Rate limit exceeded while enabling monitor '{validated_monitor_id}': {e.message}", @@ -5743,21 +6204,27 @@ async def _enable_monitor_impl(): context=create_monitor_error_context( "enable_monitor", monitor_id=validated_monitor_id, - monitor_name=monitor_name - ) + monitor_name=monitor_name, + ), ) from e - + # Verify the status change try: - updated_monitor = await self.api_client.get_monitor(validated_monitor_id) - new_status = "disabled" if updated_monitor.get("isDisabled", False) else "enabled" + updated_monitor = await self.api_client.get_monitor( + validated_monitor_id + ) + new_status = ( + "disabled" + if updated_monitor.get("isDisabled", False) + else "enabled" + ) except Exception as e: logger.warning( "Failed to verify monitor status after enable operation", - extra={"monitor_id": validated_monitor_id, "error": str(e)} + extra={"monitor_id": validated_monitor_id, "error": str(e)}, ) new_status = "unknown" - + return { "success": True, "message": f"Monitor '{monitor_name}' enabled successfully", @@ -5767,38 +6234,39 @@ async def _enable_monitor_impl(): "previous_status": current_status, "new_status": new_status, "changed": True, - "change_timestamp": datetime.now().isoformat() + "change_timestamp": datetime.now().isoformat(), }, - "operation_result": enable_result + "operation_result": enable_result, } - + # Execute with comprehensive error handling return await self.error_handler.execute_with_error_handling( "enable_monitor", _enable_monitor_impl, monitor_id=monitor_id, - timeout_override=15.0 - ) - logger.info( - "Enabling monitor", - extra={"monitor_id": monitor_id} + timeout_override=15.0, ) - + logger.info("Enabling monitor", extra={"monitor_id": monitor_id}) + # Validate monitor_id if not monitor_id or not monitor_id.strip(): raise ValidationError( "Monitor ID cannot be empty", field_name="monitor_id", - field_value=monitor_id + field_value=monitor_id, ) - + monitor_id = monitor_id.strip() - + try: # Get current monitor status before enabling try: current_monitor = await self.api_client.get_monitor(monitor_id) - current_status = "disabled" if current_monitor.get("isDisabled", False) else "enabled" + current_status = ( + "disabled" + if current_monitor.get("isDisabled", False) + else "enabled" + ) monitor_name = current_monitor.get("name", "Unknown") except APIError as e: if e.status_code == 404: @@ -5808,17 +6276,17 @@ async def _enable_monitor_impl(): request_id=e.request_id, context={ "operation": "enable_monitor", - "monitor_id": monitor_id - } + "monitor_id": monitor_id, + }, ) from e else: raise - + # Check if monitor is already enabled if current_status == "enabled": logger.info( "Monitor is already enabled", - extra={"monitor_id": monitor_id, "monitor_name": monitor_name} + extra={"monitor_id": monitor_id, "monitor_name": monitor_name}, ) return { "success": True, @@ -5828,15 +6296,15 @@ async def _enable_monitor_impl(): "status_change": { "previous_status": "enabled", "new_status": "enabled", - "changed": False + "changed": False, }, "timestamp": datetime.now().isoformat(), - "operation": "enable_monitor" + "operation": "enable_monitor", } - + # Call API client to enable monitor api_response = await self.api_client.enable_monitor(monitor_id) - + # Format successful response with status change confirmation response = { "success": True, @@ -5847,48 +6315,47 @@ async def _enable_monitor_impl(): "previous_status": current_status, "new_status": "enabled", "changed": True, - "change_timestamp": api_response.get("timestamp", datetime.now().isoformat()) + "change_timestamp": api_response.get( + "timestamp", datetime.now().isoformat() + ), }, "timestamp": datetime.now().isoformat(), "operation": "enable_monitor", "details": { "monitor_will_resume": "Monitor will resume evaluating trigger conditions", "notifications_active": "Notifications will be sent when conditions are met", - "next_evaluation": "Monitor will be evaluated according to its schedule" - } + "next_evaluation": "Monitor will be evaluated according to its schedule", + }, } - + logger.info( "Monitor enabled successfully", extra={ "monitor_id": monitor_id, "monitor_name": monitor_name, - "previous_status": current_status - } + "previous_status": current_status, + }, ) - + return response - + except APIError as e: logger.error( "Failed to enable monitor", extra={ "error": str(e), "monitor_id": monitor_id, - "status_code": getattr(e, 'status_code', None) - } + "status_code": getattr(e, "status_code", None), + }, ) - + # Handle specific error cases if e.status_code == 403: raise APIError( f"Insufficient permissions to enable monitor '{monitor_id}'", status_code=e.status_code, request_id=e.request_id, - context={ - "operation": "enable_monitor", - "monitor_id": monitor_id - } + context={"operation": "enable_monitor", "monitor_id": monitor_id}, ) from e elif e.status_code == 404: # Already handled above, but include for completeness @@ -5898,42 +6365,36 @@ async def _enable_monitor_impl(): f"Failed to enable monitor '{monitor_id}': {e.message}", status_code=e.status_code, request_id=e.request_id, - context={ - "operation": "enable_monitor", - "monitor_id": monitor_id - } + context={"operation": "enable_monitor", "monitor_id": monitor_id}, ) from e - + except Exception as e: logger.error( "Unexpected error enabling monitor", extra={ "error": str(e), "error_type": type(e).__name__, - "monitor_id": monitor_id - } + "monitor_id": monitor_id, + }, ) raise APIError( f"Unexpected error enabling monitor '{monitor_id}': {str(e)}", - context={ - "operation": "enable_monitor", - "monitor_id": monitor_id - } + context={"operation": "enable_monitor", "monitor_id": monitor_id}, ) from e - + async def disable_monitor(self, monitor_id: str) -> Dict[str, Any]: """Disable specified monitor with comprehensive error handling. - + This method disables an enabled monitor, stopping trigger condition evaluation while preserving the monitor configuration. The operation includes status change confirmation and validation. - + Args: monitor_id: Unique identifier for the monitor to disable - + Returns: Dictionary containing disable operation result with status confirmation - + Raises: MonitorValidationError: If monitor_id is invalid MonitorNotFoundError: If monitor doesn't exist @@ -5941,19 +6402,28 @@ async def disable_monitor(self, monitor_id: str) -> Dict[str, Any]: MonitorOperationError: If disable operation fails MonitorError: If monitor operation fails """ + async def _disable_monitor_impl(): # Validate monitor_id with enhanced validation - validated_monitor_id = await validate_monitor_id(monitor_id, "disable_monitor") - + validated_monitor_id = await validate_monitor_id( + monitor_id, "disable_monitor" + ) + # Get current monitor status before disabling try: - current_monitor = await self.api_client.get_monitor(validated_monitor_id) - current_status = "disabled" if current_monitor.get("isDisabled", False) else "enabled" + current_monitor = await self.api_client.get_monitor( + validated_monitor_id + ) + current_status = ( + "disabled" + if current_monitor.get("isDisabled", False) + else "enabled" + ) monitor_name = current_monitor.get("name", "Unknown") except APIError as e: # The error handler will convert this to appropriate monitor error raise e - + # Check if monitor is already disabled if current_status == "disabled": return { @@ -5964,15 +6434,17 @@ async def _disable_monitor_impl(): "status_change": { "previous_status": "disabled", "new_status": "disabled", - "changed": False + "changed": False, }, "timestamp": datetime.now().isoformat(), - "no_action_required": True + "no_action_required": True, } - + # Disable the monitor via API try: - disable_result = await self.api_client.disable_monitor(validated_monitor_id) + disable_result = await self.api_client.disable_monitor( + validated_monitor_id + ) except RateLimitError as e: raise RateLimitError( f"Rate limit exceeded while disabling monitor '{validated_monitor_id}': {e.message}", @@ -5981,21 +6453,27 @@ async def _disable_monitor_impl(): context=create_monitor_error_context( "disable_monitor", monitor_id=validated_monitor_id, - monitor_name=monitor_name - ) + monitor_name=monitor_name, + ), ) from e - + # Verify the status change try: - updated_monitor = await self.api_client.get_monitor(validated_monitor_id) - new_status = "disabled" if updated_monitor.get("isDisabled", False) else "enabled" + updated_monitor = await self.api_client.get_monitor( + validated_monitor_id + ) + new_status = ( + "disabled" + if updated_monitor.get("isDisabled", False) + else "enabled" + ) except Exception as e: logger.warning( "Failed to verify monitor status after disable operation", - extra={"monitor_id": validated_monitor_id, "error": str(e)} + extra={"monitor_id": validated_monitor_id, "error": str(e)}, ) new_status = "unknown" - + return { "success": True, "message": f"Monitor '{monitor_name}' disabled successfully", @@ -6005,43 +6483,44 @@ async def _disable_monitor_impl(): "previous_status": current_status, "new_status": new_status, "changed": True, - "change_timestamp": datetime.now().isoformat() + "change_timestamp": datetime.now().isoformat(), }, "operation_result": disable_result, "details": { "monitor_stopped": "Monitor will stop evaluating trigger conditions", "configuration_preserved": "Monitor configuration is preserved", - "can_be_re_enabled": "Monitor can be re-enabled at any time" - } + "can_be_re_enabled": "Monitor can be re-enabled at any time", + }, } - + # Execute with comprehensive error handling return await self.error_handler.execute_with_error_handling( "disable_monitor", _disable_monitor_impl, monitor_id=monitor_id, - timeout_override=15.0 - ) - logger.info( - "Disabling monitor", - extra={"monitor_id": monitor_id} + timeout_override=15.0, ) - + logger.info("Disabling monitor", extra={"monitor_id": monitor_id}) + # Validate monitor_id if not monitor_id or not monitor_id.strip(): raise ValidationError( "Monitor ID cannot be empty", field_name="monitor_id", - field_value=monitor_id + field_value=monitor_id, ) - + monitor_id = monitor_id.strip() - + try: # Get current monitor status before disabling try: current_monitor = await self.api_client.get_monitor(monitor_id) - current_status = "disabled" if current_monitor.get("isDisabled", False) else "enabled" + current_status = ( + "disabled" + if current_monitor.get("isDisabled", False) + else "enabled" + ) monitor_name = current_monitor.get("name", "Unknown") except APIError as e: if e.status_code == 404: @@ -6051,17 +6530,17 @@ async def _disable_monitor_impl(): request_id=e.request_id, context={ "operation": "disable_monitor", - "monitor_id": monitor_id - } + "monitor_id": monitor_id, + }, ) from e else: raise - + # Check if monitor is already disabled if current_status == "disabled": logger.info( "Monitor is already disabled", - extra={"monitor_id": monitor_id, "monitor_name": monitor_name} + extra={"monitor_id": monitor_id, "monitor_name": monitor_name}, ) return { "success": True, @@ -6071,15 +6550,15 @@ async def _disable_monitor_impl(): "status_change": { "previous_status": "disabled", "new_status": "disabled", - "changed": False + "changed": False, }, "timestamp": datetime.now().isoformat(), - "operation": "disable_monitor" + "operation": "disable_monitor", } - + # Call API client to disable monitor api_response = await self.api_client.disable_monitor(monitor_id) - + # Format successful response with status change confirmation response = { "success": True, @@ -6090,48 +6569,47 @@ async def _disable_monitor_impl(): "previous_status": current_status, "new_status": "disabled", "changed": True, - "change_timestamp": api_response.get("timestamp", datetime.now().isoformat()) + "change_timestamp": api_response.get( + "timestamp", datetime.now().isoformat() + ), }, "timestamp": datetime.now().isoformat(), "operation": "disable_monitor", "details": { "monitor_stopped": "Monitor will stop evaluating trigger conditions", "notifications_paused": "No notifications will be sent while disabled", - "configuration_preserved": "Monitor configuration is preserved and can be re-enabled" - } + "configuration_preserved": "Monitor configuration is preserved and can be re-enabled", + }, } - + logger.info( "Monitor disabled successfully", extra={ "monitor_id": monitor_id, "monitor_name": monitor_name, - "previous_status": current_status - } + "previous_status": current_status, + }, ) - + return response - + except APIError as e: logger.error( "Failed to disable monitor", extra={ "error": str(e), "monitor_id": monitor_id, - "status_code": getattr(e, 'status_code', None) - } + "status_code": getattr(e, "status_code", None), + }, ) - + # Handle specific error cases if e.status_code == 403: raise APIError( f"Insufficient permissions to disable monitor '{monitor_id}'", status_code=e.status_code, request_id=e.request_id, - context={ - "operation": "disable_monitor", - "monitor_id": monitor_id - } + context={"operation": "disable_monitor", "monitor_id": monitor_id}, ) from e elif e.status_code == 404: # Already handled above, but include for completeness @@ -6141,47 +6619,42 @@ async def _disable_monitor_impl(): f"Failed to disable monitor '{monitor_id}': {e.message}", status_code=e.status_code, request_id=e.request_id, - context={ - "operation": "disable_monitor", - "monitor_id": monitor_id - } + context={"operation": "disable_monitor", "monitor_id": monitor_id}, ) from e - + except Exception as e: logger.error( "Unexpected error disabling monitor", extra={ "error": str(e), "error_type": type(e).__name__, - "monitor_id": monitor_id - } + "monitor_id": monitor_id, + }, ) raise APIError( f"Unexpected error disabling monitor '{monitor_id}': {str(e)}", - context={ - "operation": "disable_monitor", - "monitor_id": monitor_id - } + context={"operation": "disable_monitor", "monitor_id": monitor_id}, ) from e async def validate_monitor_config(self, config: Dict[str, Any]) -> Dict[str, Any]: """Validate monitor configuration with comprehensive error handling. - + This method performs comprehensive validation of monitor configuration including query syntax validation using dry-run approach, trigger condition validation, and notification configuration validation. - + Args: config: Monitor configuration dictionary to validate - + Returns: Dictionary containing detailed validation results with warnings and errors - + Raises: MonitorValidationError: If config parameter is invalid MonitorConfigurationError: If configuration validation fails MonitorError: If validation process fails """ + async def _validate_monitor_config_impl(): # Validate input parameter if not config or not isinstance(config, dict): @@ -6189,9 +6662,9 @@ async def _validate_monitor_config_impl(): "Configuration must be a non-empty dictionary", field_name="config", field_value=config, - context=create_monitor_error_context("validate_monitor_config") + context=create_monitor_error_context("validate_monitor_config"), ) - + # Initialize validation result validation_result = { "valid": True, @@ -6204,45 +6677,54 @@ async def _validate_monitor_config_impl(): "config_structure": None, "query_validation": None, "trigger_validation": None, - "notification_validation": None - } + "notification_validation": None, + }, } - + # Step 1: Validate configuration structure using Pydantic model try: monitor_config = MonitorConfig(**config) validation_result["validation_details"]["config_structure"] = { "valid": True, - "message": "Configuration structure is valid" + "message": "Configuration structure is valid", } except Exception as e: validation_result["valid"] = False - validation_result["errors"].append(f"Configuration structure error: {str(e)}") + validation_result["errors"].append( + f"Configuration structure error: {str(e)}" + ) validation_result["validation_details"]["config_structure"] = { "valid": False, - "error": str(e) + "error": str(e), } # Continue with other validations even if structure is invalid - + # Step 2: Validate query syntax using dry-run approach query = config.get("query", "") monitor_type = config.get("type", "MonitorsLibraryMonitor") - + if query: try: query_validation = await self.api_client.validate_monitor_query( - query=query, - monitor_type=monitor_type + query=query, monitor_type=monitor_type ) - - validation_result["query_syntax_valid"] = query_validation.get("valid", False) - validation_result["validation_details"]["query_validation"] = query_validation - + + validation_result["query_syntax_valid"] = query_validation.get( + "valid", False + ) + validation_result["validation_details"][ + "query_validation" + ] = query_validation + if not query_validation.get("valid", False): validation_result["valid"] = False - error_msg = query_validation.get("error", "Query syntax is invalid") - validation_result["errors"].append(f"Query validation error: {error_msg}") - + error_msg = query_validation.get( + "error", "Query syntax is invalid" + ) + validation_result["errors"].append( + f"Query validation error: {error_msg}" + ) + except RateLimitError as e: raise RateLimitError( f"Rate limit exceeded during query validation: {e.message}", @@ -6251,8 +6733,8 @@ async def _validate_monitor_config_impl(): context=create_monitor_error_context( "validate_monitor_config", query_length=len(query), - monitor_type=monitor_type - ) + monitor_type=monitor_type, + ), ) from e except Exception as e: # If query validation API fails, log warning but don't fail entire validation @@ -6262,30 +6744,40 @@ async def _validate_monitor_config_impl(): validation_result["validation_details"]["query_validation"] = { "valid": None, "error": str(e), - "message": "Query validation skipped due to API error" + "message": "Query validation skipped due to API error", } else: validation_result["errors"].append("Query is required but not provided") validation_result["valid"] = False - + # Step 3: Validate trigger conditions trigger_conditions = config.get("trigger_conditions", {}) if trigger_conditions: try: - trigger_validation = await self._validate_trigger_conditions_comprehensive( - trigger_conditions + trigger_validation = ( + await self._validate_trigger_conditions_comprehensive( + trigger_conditions + ) + ) + + validation_result["trigger_conditions_valid"] = ( + trigger_validation.get("valid", False) ) - - validation_result["trigger_conditions_valid"] = trigger_validation.get("valid", False) - validation_result["validation_details"]["trigger_validation"] = trigger_validation - + validation_result["validation_details"][ + "trigger_validation" + ] = trigger_validation + if not trigger_validation.get("valid", False): validation_result["valid"] = False - validation_result["errors"].extend(trigger_validation.get("errors", [])) - + validation_result["errors"].extend( + trigger_validation.get("errors", []) + ) + # Add warnings from trigger validation - validation_result["warnings"].extend(trigger_validation.get("warnings", [])) - + validation_result["warnings"].extend( + trigger_validation.get("warnings", []) + ) + except Exception as e: validation_result["warnings"].append( f"Could not validate trigger conditions: {str(e)}" @@ -6293,63 +6785,78 @@ async def _validate_monitor_config_impl(): validation_result["validation_details"]["trigger_validation"] = { "valid": None, "error": str(e), - "message": "Trigger validation skipped due to error" + "message": "Trigger validation skipped due to error", } else: - validation_result["errors"].append("Trigger conditions are required but not provided") + validation_result["errors"].append( + "Trigger conditions are required but not provided" + ) validation_result["valid"] = False - + # Step 4: Validate notification configurations notifications = config.get("notifications", []) if notifications: try: - notification_validation = await self._validate_notification_configurations(notifications) - - validation_result["notifications_valid"] = notification_validation.get("valid", False) - validation_result["validation_details"]["notification_validation"] = notification_validation - + notification_validation = ( + await self._validate_notification_configurations(notifications) + ) + + validation_result["notifications_valid"] = ( + notification_validation.get("valid", False) + ) + validation_result["validation_details"][ + "notification_validation" + ] = notification_validation + if not notification_validation.get("valid", False): validation_result["valid"] = False - validation_result["errors"].extend(notification_validation.get("errors", [])) - + validation_result["errors"].extend( + notification_validation.get("errors", []) + ) + # Add warnings from notification validation - validation_result["warnings"].extend(notification_validation.get("warnings", [])) - + validation_result["warnings"].extend( + notification_validation.get("warnings", []) + ) + except Exception as e: validation_result["warnings"].append( f"Could not validate notification configurations: {str(e)}" ) - validation_result["validation_details"]["notification_validation"] = { + validation_result["validation_details"][ + "notification_validation" + ] = { "valid": None, "error": str(e), - "message": "Notification validation skipped due to error" + "message": "Notification validation skipped due to error", } - + # Step 5: Add overall validation summary validation_result["summary"] = { "total_errors": len(validation_result["errors"]), "total_warnings": len(validation_result["warnings"]), "validation_passed": validation_result["valid"], - "config_ready_for_creation": validation_result["valid"] and len(validation_result["errors"]) == 0 + "config_ready_for_creation": validation_result["valid"] + and len(validation_result["errors"]) == 0, } - + return validation_result - + # Execute with comprehensive error handling return await self.error_handler.execute_with_error_handling( "validate_monitor_config", _validate_monitor_config_impl, - timeout_override=10.0 + timeout_override=10.0, ) logger.info("Starting monitor configuration validation") - + if not config or not isinstance(config, dict): raise ValidationError( "Configuration must be a non-empty dictionary", field_name="config", - field_value=config + field_value=config, ) - + # Initialize validation result validation_result = { "valid": True, @@ -6362,10 +6869,10 @@ async def _validate_monitor_config_impl(): "config_structure": None, "query_validation": None, "trigger_validation": None, - "notification_validation": None - } + "notification_validation": None, + }, } - + try: # Step 1: Validate configuration structure using Pydantic model logger.debug("Validating configuration structure") @@ -6373,42 +6880,51 @@ async def _validate_monitor_config_impl(): monitor_config = MonitorConfig(**config) validation_result["validation_details"]["config_structure"] = { "valid": True, - "message": "Configuration structure is valid" + "message": "Configuration structure is valid", } logger.debug("Configuration structure validation passed") except Exception as e: validation_result["valid"] = False - validation_result["errors"].append(f"Configuration structure error: {str(e)}") + validation_result["errors"].append( + f"Configuration structure error: {str(e)}" + ) validation_result["validation_details"]["config_structure"] = { "valid": False, - "error": str(e) + "error": str(e), } logger.warning(f"Configuration structure validation failed: {str(e)}") # Continue with other validations even if structure is invalid - + # Step 2: Validate query syntax using dry-run approach logger.debug("Validating query syntax") query = config.get("query", "") monitor_type = config.get("type", "MonitorsLibraryMonitor") - + if query: try: query_validation = await self.api_client.validate_monitor_query( - query=query, - monitor_type=monitor_type + query=query, monitor_type=monitor_type + ) + + validation_result["query_syntax_valid"] = query_validation.get( + "valid", False ) - - validation_result["query_syntax_valid"] = query_validation.get("valid", False) - validation_result["validation_details"]["query_validation"] = query_validation - + validation_result["validation_details"][ + "query_validation" + ] = query_validation + if not query_validation.get("valid", False): validation_result["valid"] = False - error_msg = query_validation.get("error", "Query syntax is invalid") - validation_result["errors"].append(f"Query validation error: {error_msg}") + error_msg = query_validation.get( + "error", "Query syntax is invalid" + ) + validation_result["errors"].append( + f"Query validation error: {error_msg}" + ) logger.warning(f"Query validation failed: {error_msg}") else: logger.debug("Query syntax validation passed") - + except APIError as e: # If query validation API fails, log warning but don't fail entire validation validation_result["warnings"].append( @@ -6417,117 +6933,146 @@ async def _validate_monitor_config_impl(): validation_result["validation_details"]["query_validation"] = { "valid": None, "error": f"API validation failed: {str(e)}", - "fallback_used": True + "fallback_used": True, } logger.warning(f"Query API validation failed, continuing: {str(e)}") else: validation_result["valid"] = False validation_result["errors"].append("Query is required but not provided") validation_result["query_syntax_valid"] = False - + # Step 3: Validate trigger conditions logger.debug("Validating trigger conditions") trigger_conditions = config.get("trigger_conditions", {}) - trigger_validation_result = self._validate_trigger_conditions(trigger_conditions) - - validation_result["trigger_conditions_valid"] = trigger_validation_result["valid"] - validation_result["validation_details"]["trigger_validation"] = trigger_validation_result - + trigger_validation_result = self._validate_trigger_conditions( + trigger_conditions + ) + + validation_result["trigger_conditions_valid"] = trigger_validation_result[ + "valid" + ] + validation_result["validation_details"][ + "trigger_validation" + ] = trigger_validation_result + if not trigger_validation_result["valid"]: validation_result["valid"] = False validation_result["errors"].extend(trigger_validation_result["errors"]) - - validation_result["warnings"].extend(trigger_validation_result.get("warnings", [])) - + + validation_result["warnings"].extend( + trigger_validation_result.get("warnings", []) + ) + # Step 4: Validate notification configurations logger.debug("Validating notification configurations") notifications = config.get("notifications", []) - notification_validation_result = self._validate_notification_configurations(notifications) - - validation_result["notifications_valid"] = notification_validation_result["valid"] - validation_result["validation_details"]["notification_validation"] = notification_validation_result - + notification_validation_result = self._validate_notification_configurations( + notifications + ) + + validation_result["notifications_valid"] = notification_validation_result[ + "valid" + ] + validation_result["validation_details"][ + "notification_validation" + ] = notification_validation_result + if not notification_validation_result["valid"]: validation_result["valid"] = False - validation_result["errors"].extend(notification_validation_result["errors"]) - - validation_result["warnings"].extend(notification_validation_result.get("warnings", [])) - + validation_result["errors"].extend( + notification_validation_result["errors"] + ) + + validation_result["warnings"].extend( + notification_validation_result.get("warnings", []) + ) + # Step 5: Additional validation checks self._perform_additional_validation_checks(config, validation_result) - + # Final validation summary validation_result["summary"] = { "total_errors": len(validation_result["errors"]), "total_warnings": len(validation_result["warnings"]), - "config_structure_valid": validation_result["validation_details"]["config_structure"]["valid"] if validation_result["validation_details"]["config_structure"] else False, + "config_structure_valid": ( + validation_result["validation_details"]["config_structure"]["valid"] + if validation_result["validation_details"]["config_structure"] + else False + ), "query_syntax_valid": validation_result["query_syntax_valid"], - "trigger_conditions_valid": validation_result["trigger_conditions_valid"], - "notifications_valid": validation_result["notifications_valid"] + "trigger_conditions_valid": validation_result[ + "trigger_conditions_valid" + ], + "notifications_valid": validation_result["notifications_valid"], } - + logger.info( "Monitor configuration validation completed", extra={ "valid": validation_result["valid"], "errors": len(validation_result["errors"]), - "warnings": len(validation_result["warnings"]) - } + "warnings": len(validation_result["warnings"]), + }, ) - + return validation_result - + except Exception as e: logger.error( "Unexpected error during monitor configuration validation", - extra={"error": str(e), "error_type": type(e).__name__} + extra={"error": str(e), "error_type": type(e).__name__}, ) raise APIError( f"Unexpected error during validation: {str(e)}", - context={"operation": "validate_monitor_config"} + context={"operation": "validate_monitor_config"}, ) from e - - def _validate_trigger_conditions(self, trigger_conditions: Dict[str, Any]) -> Dict[str, Any]: + + def _validate_trigger_conditions( + self, trigger_conditions: Dict[str, Any] + ) -> Dict[str, Any]: """Validate trigger conditions configuration. - + Args: trigger_conditions: Dictionary of trigger conditions - + Returns: Dictionary containing validation results for trigger conditions """ - result = { - "valid": True, - "errors": [], - "warnings": [], - "conditions_checked": [] - } - + result = {"valid": True, "errors": [], "warnings": [], "conditions_checked": []} + if not trigger_conditions: result["valid"] = False result["errors"].append("At least one trigger condition must be specified") return result - - valid_trigger_types = [TriggerType.CRITICAL, TriggerType.WARNING, TriggerType.MISSING_DATA] - + + valid_trigger_types = [ + TriggerType.CRITICAL, + TriggerType.WARNING, + TriggerType.MISSING_DATA, + ] + for trigger_type, condition in trigger_conditions.items(): condition_result = { "trigger_type": trigger_type, "valid": True, "errors": [], - "warnings": [] + "warnings": [], } - + # Validate trigger type if trigger_type not in [t.value for t in valid_trigger_types]: condition_result["valid"] = False - condition_result["errors"].append(f"Invalid trigger type: {trigger_type}") + condition_result["errors"].append( + f"Invalid trigger type: {trigger_type}" + ) result["valid"] = False - + # Validate condition structure if not isinstance(condition, dict): condition_result["valid"] = False - condition_result["errors"].append("Trigger condition must be a dictionary") + condition_result["errors"].append( + "Trigger condition must be a dictionary" + ) result["valid"] = False else: # Validate required fields @@ -6535,9 +7080,11 @@ def _validate_trigger_conditions(self, trigger_conditions: Dict[str, Any]) -> Di for field in required_fields: if field not in condition: condition_result["valid"] = False - condition_result["errors"].append(f"Missing required field: {field}") + condition_result["errors"].append( + f"Missing required field: {field}" + ) result["valid"] = False - + # Validate threshold threshold = condition.get("threshold") if threshold is not None: @@ -6545,9 +7092,11 @@ def _validate_trigger_conditions(self, trigger_conditions: Dict[str, Any]) -> Di float(threshold) except (ValueError, TypeError): condition_result["valid"] = False - condition_result["errors"].append("Threshold must be a valid number") + condition_result["errors"].append( + "Threshold must be a valid number" + ) result["valid"] = False - + # Validate threshold_type threshold_type = condition.get("threshold_type") valid_threshold_types = [t.value for t in ThresholdType] @@ -6557,41 +7106,49 @@ def _validate_trigger_conditions(self, trigger_conditions: Dict[str, Any]) -> Di f"Invalid threshold_type: {threshold_type}. Must be one of: {', '.join(valid_threshold_types)}" ) result["valid"] = False - + # Validate time_range format time_range = condition.get("time_range") if time_range: import re - if not re.match(r'^-?\d+[smhdw]$', time_range): + + if not re.match(r"^-?\d+[smhdw]$", time_range): condition_result["valid"] = False condition_result["errors"].append( f"Invalid time_range format: {time_range}. Must be like '-5m', '-1h', '-1d'" ) result["valid"] = False - + # Add warnings for best practices if threshold is not None and threshold == 0: condition_result["warnings"].append( "Threshold of 0 may cause frequent triggering" ) - - if time_range and time_range.startswith('-') and int(time_range[1:-1]) > 60 and time_range.endswith('m'): + + if ( + time_range + and time_range.startswith("-") + and int(time_range[1:-1]) > 60 + and time_range.endswith("m") + ): condition_result["warnings"].append( "Time range longer than 60 minutes may cause delayed alerting" ) - + result["conditions_checked"].append(condition_result) result["errors"].extend(condition_result["errors"]) result["warnings"].extend(condition_result["warnings"]) - + return result - - def _validate_notification_configurations(self, notifications: List[Dict[str, Any]]) -> Dict[str, Any]: + + def _validate_notification_configurations( + self, notifications: List[Dict[str, Any]] + ) -> Dict[str, Any]: """Validate notification configurations. - + Args: notifications: List of notification configuration dictionaries - + Returns: Dictionary containing validation results for notifications """ @@ -6599,31 +7156,35 @@ def _validate_notification_configurations(self, notifications: List[Dict[str, An "valid": True, "errors": [], "warnings": [], - "notifications_checked": [] + "notifications_checked": [], } - + if not notifications: - result["warnings"].append("No notifications configured - monitor will not send alerts") + result["warnings"].append( + "No notifications configured - monitor will not send alerts" + ) return result - + if not isinstance(notifications, list): result["valid"] = False result["errors"].append("Notifications must be a list") return result - + valid_action_types = [t.value for t in NotificationType] - + for i, notification in enumerate(notifications): notification_result = { "index": i, "valid": True, "errors": [], - "warnings": [] + "warnings": [], } - + if not isinstance(notification, dict): notification_result["valid"] = False - notification_result["errors"].append("Notification must be a dictionary") + notification_result["errors"].append( + "Notification must be a dictionary" + ) result["valid"] = False else: # Validate action_type @@ -6638,53 +7199,70 @@ def _validate_notification_configurations(self, notifications: List[Dict[str, An f"Invalid action_type: {action_type}. Must be one of: {', '.join(valid_action_types)}" ) result["valid"] = False - + # Validate type-specific requirements if action_type == NotificationType.EMAIL: recipients = notification.get("recipients", []) if not recipients: notification_result["valid"] = False - notification_result["errors"].append("Email notifications require recipients") + notification_result["errors"].append( + "Email notifications require recipients" + ) result["valid"] = False else: # Validate email addresses import re - email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + + email_pattern = ( + r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" + ) for email in recipients: if not re.match(email_pattern, email): notification_result["valid"] = False - notification_result["errors"].append(f"Invalid email address: {email}") + notification_result["errors"].append( + f"Invalid email address: {email}" + ) result["valid"] = False - + elif action_type == NotificationType.WEBHOOK: webhook_url = notification.get("webhook_url") if not webhook_url: notification_result["valid"] = False - notification_result["errors"].append("Webhook notifications require webhook_url") + notification_result["errors"].append( + "Webhook notifications require webhook_url" + ) result["valid"] = False - elif not webhook_url.startswith(('http://', 'https://')): + elif not webhook_url.startswith(("http://", "https://")): notification_result["valid"] = False - notification_result["errors"].append("Webhook URL must start with http:// or https://") + notification_result["errors"].append( + "Webhook URL must start with http:// or https://" + ) result["valid"] = False - + # Validate optional fields subject = notification.get("subject") if subject and len(subject) > 255: - notification_result["warnings"].append("Subject is longer than 255 characters") - + notification_result["warnings"].append( + "Subject is longer than 255 characters" + ) + message_body = notification.get("message_body") if message_body and len(message_body) > 2000: - notification_result["warnings"].append("Message body is longer than 2000 characters") - + notification_result["warnings"].append( + "Message body is longer than 2000 characters" + ) + result["notifications_checked"].append(notification_result) result["errors"].extend(notification_result["errors"]) result["warnings"].extend(notification_result["warnings"]) - + return result - - def _perform_additional_validation_checks(self, config: Dict[str, Any], validation_result: Dict[str, Any]) -> None: + + def _perform_additional_validation_checks( + self, config: Dict[str, Any], validation_result: Dict[str, Any] + ) -> None: """Perform additional validation checks and add warnings. - + Args: config: Monitor configuration dictionary validation_result: Validation result dictionary to update @@ -6694,54 +7272,56 @@ def _perform_additional_validation_checks(self, config: Dict[str, Any], validati if query: # Warn about potentially expensive queries expensive_patterns = [ - r'\*\s*\|\s*count', # * | count without filters - r'_raw\s*matches', # _raw matches (can be slow) - r'join\s+', # joins can be expensive + r"\*\s*\|\s*count", # * | count without filters + r"_raw\s*matches", # _raw matches (can be slow) + r"join\s+", # joins can be expensive ] - + import re + for pattern in expensive_patterns: if re.search(pattern, query, re.IGNORECASE): validation_result["warnings"].append( f"Query contains potentially expensive operation: {pattern}" ) - + # Check evaluation delay evaluation_delay = config.get("evaluation_delay", "0m") if evaluation_delay and evaluation_delay != "0m": import re - if re.match(r'^\d+[smh]$', evaluation_delay): - delay_match = re.match(r'^(\d+)([smh])$', evaluation_delay) + + if re.match(r"^\d+[smh]$", evaluation_delay): + delay_match = re.match(r"^(\d+)([smh])$", evaluation_delay) if delay_match: value, unit = delay_match.groups() value = int(value) - + # Convert to minutes for comparison - if unit == 's': + if unit == "s": delay_minutes = value / 60 - elif unit == 'm': + elif unit == "m": delay_minutes = value - elif unit == 'h': + elif unit == "h": delay_minutes = value * 60 - + if delay_minutes > 60: validation_result["warnings"].append( "Evaluation delay longer than 1 hour may significantly delay alerting" ) - + # Check for missing description if not config.get("description", "").strip(): validation_result["warnings"].append( "Monitor description is empty - consider adding a description for better documentation" ) - + # Check monitor name length and characters name = config.get("name", "") if len(name) > 200: validation_result["warnings"].append( "Monitor name is very long - consider using a shorter, more concise name" ) - + # Check if monitor is disabled by default if config.get("is_disabled", False): validation_result["warnings"].append( @@ -6749,41 +7329,40 @@ def _perform_additional_validation_checks(self, config: Dict[str, Any], validati ) async def get_monitor_history( - self, - monitor_id: str, - from_time: str, - to_time: str, - limit: int = 100 + self, monitor_id: str, from_time: str, to_time: str, limit: int = 100 ) -> Dict[str, Any]: """Get monitor execution history with comprehensive error handling. - + This method retrieves the execution history for a specific monitor, including performance metrics, execution statistics, and trigger patterns over the specified time range. - + Args: monitor_id: Unique identifier for the monitor from_time: Start time for history range (ISO format or relative like '-1h') to_time: End time for history range (ISO format or relative like 'now') limit: Maximum number of history entries to return (1-1000) - + Returns: Dictionary containing monitor execution history with: - execution_history: List of execution entries with timestamps and results - performance_metrics: Aggregated performance statistics - trigger_patterns: Analysis of trigger frequency and patterns - metadata: Request metadata and pagination info - + Raises: MonitorValidationError: If parameters are invalid MonitorNotFoundError: If monitor doesn't exist MonitorError: If history retrieval fails RateLimitError: If rate limit is exceeded """ + async def _get_monitor_history_impl(): # Validate monitor_id - validated_monitor_id = await validate_monitor_id(monitor_id, "get_monitor_history") - + validated_monitor_id = await validate_monitor_id( + monitor_id, "get_monitor_history" + ) + # Validate time parameters if not from_time or not isinstance(from_time, str): raise MonitorValidationError( @@ -6791,22 +7370,20 @@ async def _get_monitor_history_impl(): field_name="from_time", field_value=from_time, context=create_monitor_error_context( - "get_monitor_history", - monitor_id=validated_monitor_id - ) + "get_monitor_history", monitor_id=validated_monitor_id + ), ) - + if not to_time or not isinstance(to_time, str): raise MonitorValidationError( "to_time must be a non-empty string", field_name="to_time", field_value=to_time, context=create_monitor_error_context( - "get_monitor_history", - monitor_id=validated_monitor_id - ) + "get_monitor_history", monitor_id=validated_monitor_id + ), ) - + # Validate limit parameter if not isinstance(limit, int) or limit < 1 or limit > 1000: raise MonitorValidationError( @@ -6814,18 +7391,17 @@ async def _get_monitor_history_impl(): field_name="limit", field_value=limit, context=create_monitor_error_context( - "get_monitor_history", - monitor_id=validated_monitor_id - ) + "get_monitor_history", monitor_id=validated_monitor_id + ), ) - + # Get monitor history from API with error handling try: api_response = await self.api_client.get_monitor_history( monitor_id=validated_monitor_id, from_time=from_time, to_time=to_time, - limit=limit + limit=limit, ) except RateLimitError as e: raise RateLimitError( @@ -6835,14 +7411,14 @@ async def _get_monitor_history_impl(): context=create_monitor_error_context( "get_monitor_history", monitor_id=validated_monitor_id, - time_range=f"{from_time} to {to_time}" - ) + time_range=f"{from_time} to {to_time}", + ), ) from e - + # Extract history data from response history_data = api_response.get("data", []) total_count = api_response.get("total", len(history_data)) - + # Handle case with no history data if not history_data: return { @@ -6852,12 +7428,12 @@ async def _get_monitor_history_impl(): "total_executions": 0, "average_execution_time": 0, "success_rate": 0, - "trigger_rate": 0 + "trigger_rate": 0, }, "trigger_patterns": { "total_triggers": 0, "trigger_frequency": {}, - "severity_distribution": {} + "severity_distribution": {}, }, "metadata": { "monitor_id": validated_monitor_id, @@ -6865,32 +7441,34 @@ async def _get_monitor_history_impl(): "total_entries": 0, "returned_entries": 0, "limit": limit, - "retrieved_at": datetime.now().isoformat() + "retrieved_at": datetime.now().isoformat(), }, - "message": "No execution history found for the specified time range" + "message": "No execution history found for the specified time range", } - + # Format execution history with error handling formatted_history = [] formatting_errors = [] - + for i, entry in enumerate(history_data): try: formatted_entry = self._format_history_entry(entry) formatted_history.append(formatted_entry) except Exception as e: - formatting_errors.append({ - "index": i, - "entry_id": entry.get("executionId", "unknown"), - "error": str(e) - }) + formatting_errors.append( + { + "index": i, + "entry_id": entry.get("executionId", "unknown"), + "error": str(e), + } + ) logger.warning( f"Failed to format history entry {entry.get('executionId', 'unknown')}: {str(e)}", - extra={"entry": entry, "error": str(e)} + extra={"entry": entry, "error": str(e)}, ) # Continue with other entries continue - + if not formatted_history and formatting_errors: raise MonitorError( f"Failed to format any history entries: {len(formatting_errors)} formatting errors", @@ -6900,35 +7478,41 @@ async def _get_monitor_history_impl(): "get_monitor_history", monitor_id=validated_monitor_id, formatting_errors=formatting_errors, - total_entries=len(history_data) - ) + total_entries=len(history_data), + ), ) - + # Calculate performance metrics try: - performance_metrics = self._calculate_performance_metrics(formatted_history) + performance_metrics = self._calculate_performance_metrics( + formatted_history + ) except Exception as e: logger.warning( f"Failed to calculate performance metrics: {str(e)}", - extra={"monitor_id": validated_monitor_id, "history_count": len(formatted_history)} + extra={ + "monitor_id": validated_monitor_id, + "history_count": len(formatted_history), + }, ) performance_metrics = { "total_executions": len(formatted_history), - "calculation_error": str(e) + "calculation_error": str(e), } - + # Analyze trigger patterns try: trigger_patterns = self._analyze_trigger_patterns(formatted_history) except Exception as e: logger.warning( f"Failed to analyze trigger patterns: {str(e)}", - extra={"monitor_id": validated_monitor_id, "history_count": len(formatted_history)} + extra={ + "monitor_id": validated_monitor_id, + "history_count": len(formatted_history), + }, ) - trigger_patterns = { - "analysis_error": str(e) - } - + trigger_patterns = {"analysis_error": str(e)} + response = { "success": True, "execution_history": formatted_history, @@ -6940,25 +7524,25 @@ async def _get_monitor_history_impl(): "total_entries": total_count, "returned_entries": len(formatted_history), "limit": limit, - "retrieved_at": datetime.now().isoformat() - } + "retrieved_at": datetime.now().isoformat(), + }, } - + # Add formatting warnings if any if formatting_errors: response["warnings"] = { "formatting_errors": len(formatting_errors), - "message": f"{len(formatting_errors)} history entries could not be formatted and were skipped" + "message": f"{len(formatting_errors)} history entries could not be formatted and were skipped", } - + return response - + # Execute with comprehensive error handling return await self.error_handler.execute_with_error_handling( "get_monitor_history", _get_monitor_history_impl, monitor_id=monitor_id, - timeout_override=60.0 # History operations may take longer + timeout_override=60.0, # History operations may take longer ) logger.info( "Getting monitor execution history", @@ -6966,60 +7550,57 @@ async def _get_monitor_history_impl(): "monitor_id": monitor_id, "from_time": from_time, "to_time": to_time, - "limit": limit - } + "limit": limit, + }, ) - + # Validate parameters if not monitor_id or not isinstance(monitor_id, str): raise ValidationError( "Monitor ID must be a non-empty string", field_name="monitor_id", - field_value=monitor_id + field_value=monitor_id, ) - + if not from_time or not isinstance(from_time, str): raise ValidationError( "from_time must be a non-empty string", field_name="from_time", - field_value=from_time + field_value=from_time, ) - + if not to_time or not isinstance(to_time, str): raise ValidationError( "to_time must be a non-empty string", field_name="to_time", - field_value=to_time + field_value=to_time, ) - + if not isinstance(limit, int) or limit < 1 or limit > 1000: raise ValidationError( "Limit must be an integer between 1 and 1000", field_name="limit", - field_value=limit + field_value=limit, ) - + try: # Get monitor history from API api_response = await self.api_client.get_monitor_history( - monitor_id=monitor_id, - from_time=from_time, - to_time=to_time, - limit=limit + monitor_id=monitor_id, from_time=from_time, to_time=to_time, limit=limit ) - + # Extract history data from API response history_data = api_response.get("data", []) - + # Process and format history entries formatted_history = self._format_history_entries(history_data) - + # Calculate performance metrics performance_metrics = self._calculate_performance_metrics(history_data) - + # Analyze trigger patterns trigger_patterns = self._analyze_trigger_patterns(history_data) - + # Format response with comprehensive information response = { "success": True, @@ -7028,101 +7609,99 @@ async def _get_monitor_history_impl(): "performance_metrics": performance_metrics, "trigger_patterns": trigger_patterns, "metadata": { - "time_range": { - "from": from_time, - "to": to_time - }, + "time_range": {"from": from_time, "to": to_time}, "total_entries": len(formatted_history), "limit": limit, "has_more": len(history_data) >= limit, - "generated_at": datetime.utcnow().isoformat() + "Z" - } + "generated_at": datetime.utcnow().isoformat() + "Z", + }, } - + logger.info( "Successfully retrieved monitor execution history", extra={ "monitor_id": monitor_id, "entries_returned": len(formatted_history), - "triggers_found": len([h for h in formatted_history if h.get("triggered", False)]) - } + "triggers_found": len( + [h for h in formatted_history if h.get("triggered", False)] + ), + }, ) - + return response - + except APIError as e: logger.error( "Failed to get monitor history", extra={ "monitor_id": monitor_id, "error": str(e), - "status_code": getattr(e, 'status_code', None) - } + "status_code": getattr(e, "status_code", None), + }, ) - + # Provide more specific error messages - if hasattr(e, 'status_code'): + if hasattr(e, "status_code"): if e.status_code == 404: raise APIError( f"Monitor not found: {monitor_id}. Please verify the monitor ID is correct.", status_code=404, - request_id=getattr(e, 'request_id', None), + request_id=getattr(e, "request_id", None), context={ "operation": "get_monitor_history", - "monitor_id": monitor_id - } + "monitor_id": monitor_id, + }, ) from e elif e.status_code == 403: raise APIError( f"Insufficient permissions to access monitor history for monitor: {monitor_id}", status_code=403, - request_id=getattr(e, 'request_id', None), + request_id=getattr(e, "request_id", None), context={ "operation": "get_monitor_history", - "monitor_id": monitor_id - } + "monitor_id": monitor_id, + }, ) from e - + raise APIError( f"Failed to get monitor history: {e.message}", - status_code=getattr(e, 'status_code', None), - request_id=getattr(e, 'request_id', None), + status_code=getattr(e, "status_code", None), + request_id=getattr(e, "request_id", None), context={ "operation": "get_monitor_history", "monitor_id": monitor_id, "from_time": from_time, - "to_time": to_time - } + "to_time": to_time, + }, ) from e - + except Exception as e: logger.error( "Unexpected error getting monitor history", extra={ "monitor_id": monitor_id, "error": str(e), - "error_type": type(e).__name__ - } + "error_type": type(e).__name__, + }, ) raise APIError( f"Unexpected error getting monitor history: {str(e)}", - context={ - "operation": "get_monitor_history", - "monitor_id": monitor_id - } + context={"operation": "get_monitor_history", "monitor_id": monitor_id}, ) from e - def _format_history_entries(self, history_data: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + def _format_history_entries( + self, history_data: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: """Format raw history data into structured execution entries. - + Args: history_data: Raw history data from API - + Returns: List of formatted history entries with execution details """ formatted_entries = [] - + for entry in history_data: try: # Extract and format execution information @@ -7137,54 +7716,60 @@ def _format_history_entries(self, history_data: List[Dict[str, Any]]) -> List[Di "error_message": entry.get("errorMessage"), "query_execution_time_ms": entry.get("queryExecutionTimeMs"), "data_scanned_bytes": entry.get("dataScannedBytes"), - "evaluation_result": entry.get("evaluationResult", {}) + "evaluation_result": entry.get("evaluationResult", {}), } - + # Add trigger details if monitor was triggered if formatted_entry["triggered"]: formatted_entry["trigger_details"] = { "severity": entry.get("triggerSeverity", "Unknown"), "threshold_exceeded": entry.get("thresholdExceeded"), "trigger_condition": entry.get("triggerCondition", {}), - "alert_sent": entry.get("alertSent", False) + "alert_sent": entry.get("alertSent", False), } - + # Add performance indicators formatted_entry["performance_indicators"] = { - "execution_fast": (entry.get("executionDurationMs", 0) < 5000), # < 5 seconds - "data_volume_normal": (entry.get("dataScannedBytes", 0) < 100 * 1024 * 1024), # < 100MB - "result_count_reasonable": (entry.get("resultCount", 0) < 10000) # < 10k results + "execution_fast": ( + entry.get("executionDurationMs", 0) < 5000 + ), # < 5 seconds + "data_volume_normal": ( + entry.get("dataScannedBytes", 0) < 100 * 1024 * 1024 + ), # < 100MB + "result_count_reasonable": ( + entry.get("resultCount", 0) < 10000 + ), # < 10k results } - + formatted_entries.append(formatted_entry) - + except Exception as e: logger.warning( - f"Failed to format history entry: {str(e)}", - extra={"entry": entry} + f"Failed to format history entry: {str(e)}", extra={"entry": entry} ) # Include raw entry with error marker - formatted_entries.append({ - "timestamp": entry.get("timestamp", ""), - "status": "format_error", - "error_message": f"Failed to format entry: {str(e)}", - "raw_entry": entry - }) - + formatted_entries.append( + { + "timestamp": entry.get("timestamp", ""), + "status": "format_error", + "error_message": f"Failed to format entry: {str(e)}", + "raw_entry": entry, + } + ) + # Sort by timestamp (most recent first) - formatted_entries.sort( - key=lambda x: x.get("timestamp", ""), - reverse=True - ) - + formatted_entries.sort(key=lambda x: x.get("timestamp", ""), reverse=True) + return formatted_entries - def _calculate_performance_metrics(self, history_data: List[Dict[str, Any]]) -> Dict[str, Any]: + def _calculate_performance_metrics( + self, history_data: List[Dict[str, Any]] + ) -> Dict[str, Any]: """Calculate aggregated performance metrics from history data. - + Args: history_data: Raw history data from API - + Returns: Dictionary containing performance statistics and metrics """ @@ -7198,119 +7783,158 @@ def _calculate_performance_metrics(self, history_data: List[Dict[str, Any]]) -> "error_rate": 0.0, "avg_result_count": 0, "total_data_scanned_bytes": 0, - "avg_data_scanned_bytes": 0 + "avg_data_scanned_bytes": 0, } - + # Extract metrics from history entries execution_times = [] result_counts = [] data_scanned = [] successful_executions = 0 failed_executions = 0 - + for entry in history_data: # Execution time metrics exec_time = entry.get("executionDurationMs") if exec_time is not None and isinstance(exec_time, (int, float)): execution_times.append(exec_time) - + # Result count metrics result_count = entry.get("resultCount") if result_count is not None and isinstance(result_count, (int, float)): result_counts.append(result_count) - + # Data scanned metrics data_bytes = entry.get("dataScannedBytes") if data_bytes is not None and isinstance(data_bytes, (int, float)): data_scanned.append(data_bytes) - + # Success/failure tracking status = entry.get("status", "").lower() if status in ["success", "completed", "normal"]: successful_executions += 1 elif status in ["error", "failed", "timeout"]: failed_executions += 1 - + total_executions = len(history_data) - + # Calculate aggregated metrics metrics = { "total_executions": total_executions, "successful_executions": successful_executions, "failed_executions": failed_executions, - "success_rate": (successful_executions / total_executions * 100) if total_executions > 0 else 0.0, - "error_rate": (failed_executions / total_executions * 100) if total_executions > 0 else 0.0 + "success_rate": ( + (successful_executions / total_executions * 100) + if total_executions > 0 + else 0.0 + ), + "error_rate": ( + (failed_executions / total_executions * 100) + if total_executions > 0 + else 0.0 + ), } - + # Execution time statistics if execution_times: - metrics.update({ - "avg_execution_time_ms": sum(execution_times) / len(execution_times), - "max_execution_time_ms": max(execution_times), - "min_execution_time_ms": min(execution_times), - "median_execution_time_ms": sorted(execution_times)[len(execution_times) // 2] - }) + metrics.update( + { + "avg_execution_time_ms": sum(execution_times) + / len(execution_times), + "max_execution_time_ms": max(execution_times), + "min_execution_time_ms": min(execution_times), + "median_execution_time_ms": sorted(execution_times)[ + len(execution_times) // 2 + ], + } + ) else: - metrics.update({ - "avg_execution_time_ms": 0, - "max_execution_time_ms": 0, - "min_execution_time_ms": 0, - "median_execution_time_ms": 0 - }) - + metrics.update( + { + "avg_execution_time_ms": 0, + "max_execution_time_ms": 0, + "min_execution_time_ms": 0, + "median_execution_time_ms": 0, + } + ) + # Result count statistics if result_counts: - metrics.update({ - "avg_result_count": sum(result_counts) / len(result_counts), - "max_result_count": max(result_counts), - "min_result_count": min(result_counts), - "total_results_processed": sum(result_counts) - }) + metrics.update( + { + "avg_result_count": sum(result_counts) / len(result_counts), + "max_result_count": max(result_counts), + "min_result_count": min(result_counts), + "total_results_processed": sum(result_counts), + } + ) else: - metrics.update({ - "avg_result_count": 0, - "max_result_count": 0, - "min_result_count": 0, - "total_results_processed": 0 - }) - + metrics.update( + { + "avg_result_count": 0, + "max_result_count": 0, + "min_result_count": 0, + "total_results_processed": 0, + } + ) + # Data scanning statistics if data_scanned: total_data = sum(data_scanned) - metrics.update({ - "total_data_scanned_bytes": total_data, - "avg_data_scanned_bytes": total_data / len(data_scanned), - "max_data_scanned_bytes": max(data_scanned), - "min_data_scanned_bytes": min(data_scanned), - "total_data_scanned_mb": round(total_data / (1024 * 1024), 2), - "avg_data_scanned_mb": round((total_data / len(data_scanned)) / (1024 * 1024), 2) - }) + metrics.update( + { + "total_data_scanned_bytes": total_data, + "avg_data_scanned_bytes": total_data / len(data_scanned), + "max_data_scanned_bytes": max(data_scanned), + "min_data_scanned_bytes": min(data_scanned), + "total_data_scanned_mb": round(total_data / (1024 * 1024), 2), + "avg_data_scanned_mb": round( + (total_data / len(data_scanned)) / (1024 * 1024), 2 + ), + } + ) else: - metrics.update({ - "total_data_scanned_bytes": 0, - "avg_data_scanned_bytes": 0, - "max_data_scanned_bytes": 0, - "min_data_scanned_bytes": 0, - "total_data_scanned_mb": 0, - "avg_data_scanned_mb": 0 - }) - + metrics.update( + { + "total_data_scanned_bytes": 0, + "avg_data_scanned_bytes": 0, + "max_data_scanned_bytes": 0, + "min_data_scanned_bytes": 0, + "total_data_scanned_mb": 0, + "avg_data_scanned_mb": 0, + } + ) + # Performance indicators metrics["performance_indicators"] = { - "fast_execution_rate": len([t for t in execution_times if t < 5000]) / len(execution_times) * 100 if execution_times else 0, - "slow_execution_count": len([t for t in execution_times if t > 30000]), # > 30 seconds - "high_data_volume_count": len([d for d in data_scanned if d > 100 * 1024 * 1024]), # > 100MB - "large_result_set_count": len([r for r in result_counts if r > 10000]) # > 10k results + "fast_execution_rate": ( + len([t for t in execution_times if t < 5000]) + / len(execution_times) + * 100 + if execution_times + else 0 + ), + "slow_execution_count": len( + [t for t in execution_times if t > 30000] + ), # > 30 seconds + "high_data_volume_count": len( + [d for d in data_scanned if d > 100 * 1024 * 1024] + ), # > 100MB + "large_result_set_count": len( + [r for r in result_counts if r > 10000] + ), # > 10k results } - + return metrics - def _analyze_trigger_patterns(self, history_data: List[Dict[str, Any]]) -> Dict[str, Any]: + def _analyze_trigger_patterns( + self, history_data: List[Dict[str, Any]] + ) -> Dict[str, Any]: """Analyze trigger patterns and frequency from history data. - + Args: history_data: Raw history data from API - + Returns: Dictionary containing trigger pattern analysis and statistics """ @@ -7320,44 +7944,48 @@ def _analyze_trigger_patterns(self, history_data: List[Dict[str, Any]]) -> Dict[ "trigger_rate": 0.0, "triggers_by_severity": {}, "trigger_frequency": {}, - "recent_trigger_trend": "stable" + "recent_trigger_trend": "stable", } - + # Extract trigger information triggers = [] triggers_by_severity = {} trigger_timestamps = [] - + for entry in history_data: if entry.get("triggered", False): trigger_info = { "timestamp": entry.get("timestamp", ""), "severity": entry.get("triggerSeverity", "Unknown"), "trigger_value": entry.get("triggerValue"), - "threshold_exceeded": entry.get("thresholdExceeded") + "threshold_exceeded": entry.get("thresholdExceeded"), } triggers.append(trigger_info) trigger_timestamps.append(entry.get("timestamp", "")) - + # Count by severity severity = trigger_info["severity"] - triggers_by_severity[severity] = triggers_by_severity.get(severity, 0) + 1 - + triggers_by_severity[severity] = ( + triggers_by_severity.get(severity, 0) + 1 + ) + total_executions = len(history_data) total_triggers = len(triggers) - + # Calculate trigger rate - trigger_rate = (total_triggers / total_executions * 100) if total_executions > 0 else 0.0 - + trigger_rate = ( + (total_triggers / total_executions * 100) if total_executions > 0 else 0.0 + ) + # Analyze trigger frequency patterns trigger_frequency = self._calculate_trigger_frequency(trigger_timestamps) - + # Analyze recent trend recent_trend = self._analyze_recent_trigger_trend(history_data) - + # Calculate trigger duration patterns (if available) trigger_durations = self._analyze_trigger_durations(triggers) - + analysis = { "total_triggers": total_triggers, "trigger_rate": round(trigger_rate, 2), @@ -7370,20 +7998,26 @@ def _analyze_trigger_patterns(self, history_data: List[Dict[str, Any]]) -> Dict[ "is_frequently_triggering": trigger_rate > 10.0, # > 10% trigger rate "has_critical_triggers": "Critical" in triggers_by_severity, "severity_distribution": { - severity: round(count / total_triggers * 100, 1) if total_triggers > 0 else 0 + severity: ( + round(count / total_triggers * 100, 1) + if total_triggers > 0 + else 0 + ) for severity, count in triggers_by_severity.items() - } - } + }, + }, } - + return analysis - def _calculate_trigger_frequency(self, trigger_timestamps: List[str]) -> Dict[str, Any]: + def _calculate_trigger_frequency( + self, trigger_timestamps: List[str] + ) -> Dict[str, Any]: """Calculate trigger frequency patterns from timestamps. - + Args: trigger_timestamps: List of trigger timestamp strings - + Returns: Dictionary containing frequency analysis """ @@ -7392,107 +8026,117 @@ def _calculate_trigger_frequency(self, trigger_timestamps: List[str]) -> Dict[st "triggers_per_hour": 0, "triggers_per_day": 0, "peak_trigger_hours": [], - "trigger_intervals": [] + "trigger_intervals": [], } - - from datetime import datetime + import re - + from datetime import datetime + # Parse timestamps and calculate intervals parsed_timestamps = [] for ts in trigger_timestamps: try: # Handle different timestamp formats - if ts.endswith('Z'): - dt = datetime.fromisoformat(ts.replace('Z', '+00:00')) + if ts.endswith("Z"): + dt = datetime.fromisoformat(ts.replace("Z", "+00:00")) else: dt = datetime.fromisoformat(ts) parsed_timestamps.append(dt) except ValueError: continue - + if len(parsed_timestamps) < 2: return { "triggers_per_hour": len(parsed_timestamps), "triggers_per_day": len(parsed_timestamps), "peak_trigger_hours": [], - "trigger_intervals": [] + "trigger_intervals": [], } - + # Sort timestamps parsed_timestamps.sort() - + # Calculate time span time_span = parsed_timestamps[-1] - parsed_timestamps[0] total_hours = time_span.total_seconds() / 3600 total_days = time_span.days + (time_span.seconds / 86400) - + # Calculate frequency rates - triggers_per_hour = len(parsed_timestamps) / total_hours if total_hours > 0 else 0 + triggers_per_hour = ( + len(parsed_timestamps) / total_hours if total_hours > 0 else 0 + ) triggers_per_day = len(parsed_timestamps) / total_days if total_days > 0 else 0 - + # Analyze peak hours hour_counts = {} for dt in parsed_timestamps: hour = dt.hour hour_counts[hour] = hour_counts.get(hour, 0) + 1 - + # Find peak hours (hours with above-average trigger counts) - avg_triggers_per_hour = len(parsed_timestamps) / 24 if len(parsed_timestamps) > 24 else 0 - peak_hours = [hour for hour, count in hour_counts.items() if count > avg_triggers_per_hour] - + avg_triggers_per_hour = ( + len(parsed_timestamps) / 24 if len(parsed_timestamps) > 24 else 0 + ) + peak_hours = [ + hour for hour, count in hour_counts.items() if count > avg_triggers_per_hour + ] + # Calculate intervals between triggers intervals = [] for i in range(1, len(parsed_timestamps)): - interval = (parsed_timestamps[i] - parsed_timestamps[i-1]).total_seconds() / 60 # minutes + interval = ( + parsed_timestamps[i] - parsed_timestamps[i - 1] + ).total_seconds() / 60 # minutes intervals.append(interval) - + return { "triggers_per_hour": round(triggers_per_hour, 2), "triggers_per_day": round(triggers_per_day, 2), "peak_trigger_hours": sorted(peak_hours), "trigger_intervals": { - "avg_interval_minutes": round(sum(intervals) / len(intervals), 2) if intervals else 0, + "avg_interval_minutes": ( + round(sum(intervals) / len(intervals), 2) if intervals else 0 + ), "min_interval_minutes": round(min(intervals), 2) if intervals else 0, - "max_interval_minutes": round(max(intervals), 2) if intervals else 0 - } + "max_interval_minutes": round(max(intervals), 2) if intervals else 0, + }, } def _analyze_recent_trigger_trend(self, history_data: List[Dict[str, Any]]) -> str: """Analyze recent trigger trend to determine if triggers are increasing, decreasing, or stable. - + Args: history_data: Raw history data from API - + Returns: String indicating trend: "increasing", "decreasing", "stable", or "insufficient_data" """ if len(history_data) < 10: return "insufficient_data" - + # Sort by timestamp (most recent first) sorted_data = sorted( - history_data, - key=lambda x: x.get("timestamp", ""), - reverse=True + history_data, key=lambda x: x.get("timestamp", ""), reverse=True ) - + # Split into recent and older halves mid_point = len(sorted_data) // 2 recent_half = sorted_data[:mid_point] older_half = sorted_data[mid_point:] - + # Count triggers in each half - recent_triggers = sum(1 for entry in recent_half if entry.get("triggered", False)) + recent_triggers = sum( + 1 for entry in recent_half if entry.get("triggered", False) + ) older_triggers = sum(1 for entry in older_half if entry.get("triggered", False)) - + # Calculate trigger rates recent_rate = recent_triggers / len(recent_half) if recent_half else 0 older_rate = older_triggers / len(older_half) if older_half else 0 - + # Determine trend with threshold for significance threshold = 0.05 # 5% difference threshold - + if recent_rate > older_rate + threshold: return "increasing" elif recent_rate < older_rate - threshold: @@ -7500,87 +8144,84 @@ def _analyze_recent_trigger_trend(self, history_data: List[Dict[str, Any]]) -> s else: return "stable" - def _analyze_trigger_durations(self, triggers: List[Dict[str, Any]]) -> Dict[str, Any]: + def _analyze_trigger_durations( + self, triggers: List[Dict[str, Any]] + ) -> Dict[str, Any]: """Analyze trigger duration patterns if duration data is available. - + Args: triggers: List of trigger information dictionaries - + Returns: Dictionary containing trigger duration analysis """ # This is a placeholder for trigger duration analysis # In a real implementation, this would analyze how long triggers last # based on when they start and when they resolve - + return { "avg_duration_minutes": 0, "max_duration_minutes": 0, "min_duration_minutes": 0, "unresolved_triggers": 0, - "note": "Trigger duration analysis requires additional API data not available in execution history" - } + "note": "Trigger duration analysis requires additional API data not available in execution history", + } # Additional error handling and utility methods - + async def handle_monitor_operation_error( self, error: Exception, operation: str, monitor_id: Optional[str] = None, monitor_name: Optional[str] = None, - additional_context: Optional[Dict[str, Any]] = None + additional_context: Optional[Dict[str, Any]] = None, ) -> Exception: """Handle and enhance monitor operation errors with context. - + This method provides a centralized way to handle and enhance errors from monitor operations, adding appropriate context and converting to monitor-specific error types. - + Args: error: Original exception operation: Monitor operation that failed monitor_id: Optional monitor ID monitor_name: Optional monitor name additional_context: Additional error context - + Returns: Enhanced exception with monitor context """ context = create_monitor_error_context( - operation, - monitor_id=monitor_id, - monitor_name=monitor_name + operation, monitor_id=monitor_id, monitor_name=monitor_name ) - + if additional_context: context.update(additional_context) - + # Use the error handler to enhance the error return await self.error_handler._enhance_error( error, operation, monitor_id, monitor_name, 0.0 ) - + async def validate_monitor_operation_preconditions( - self, - operation: str, - monitor_id: Optional[str] = None, - **operation_params + self, operation: str, monitor_id: Optional[str] = None, **operation_params ) -> Dict[str, Any]: """Validate preconditions for monitor operations. - + This method performs common validation checks before executing monitor operations to catch issues early and provide better error messages. - + Args: operation: Monitor operation to validate monitor_id: Optional monitor ID to validate **operation_params: Operation-specific parameters to validate - + Returns: Dictionary with validation results - + Raises: MonitorValidationError: If validation fails """ @@ -7588,9 +8229,9 @@ async def validate_monitor_operation_preconditions( "valid": True, "errors": [], "warnings": [], - "validated_params": {} + "validated_params": {}, } - + # Validate monitor_id if provided if monitor_id is not None: try: @@ -7599,7 +8240,7 @@ async def validate_monitor_operation_preconditions( except MonitorValidationError as e: validation_results["valid"] = False validation_results["errors"].append(str(e)) - + # Operation-specific validations if operation in ["list_monitors", "search_monitors"]: # Validate pagination parameters @@ -7614,36 +8255,38 @@ async def validate_monitor_operation_preconditions( except MonitorValidationError as e: validation_results["valid"] = False validation_results["errors"].append(str(e)) - + elif operation == "create_monitor": # Validate required parameters for monitor creation name = operation_params.get("name") query = operation_params.get("query") trigger_conditions = operation_params.get("trigger_conditions") - + if not name or not name.strip(): validation_results["valid"] = False validation_results["errors"].append("Monitor name is required") elif len(name.strip()) > 255: validation_results["valid"] = False - validation_results["errors"].append("Monitor name cannot exceed 255 characters") - + validation_results["errors"].append( + "Monitor name cannot exceed 255 characters" + ) + if not query or not query.strip(): validation_results["valid"] = False validation_results["errors"].append("Monitor query is required") - + if not trigger_conditions: validation_results["valid"] = False validation_results["errors"].append("Trigger conditions are required") - + # Add warnings for potentially problematic configurations if operation == "delete_monitor" and monitor_id: validation_results["warnings"].append( "Monitor deletion is irreversible. Ensure you have backups if needed." ) - + return validation_results - + def log_monitor_operation_metrics( self, operation: str, @@ -7651,10 +8294,10 @@ def log_monitor_operation_metrics( execution_time_ms: float, monitor_id: Optional[str] = None, monitor_name: Optional[str] = None, - error_type: Optional[str] = None + error_type: Optional[str] = None, ): """Log monitor operation metrics for monitoring and analysis. - + Args: operation: Monitor operation name success: Whether the operation was successful @@ -7667,33 +8310,33 @@ def log_monitor_operation_metrics( "operation": operation, "success": success, "execution_time_ms": round(execution_time_ms, 2), - "tool": "monitor_tools" + "tool": "monitor_tools", } - + if monitor_id: log_context["monitor_id"] = monitor_id if monitor_name: log_context["monitor_name"] = monitor_name if error_type: log_context["error_type"] = error_type - + if success: logger.info("Monitor operation completed successfully", extra=log_context) else: logger.error("Monitor operation failed", extra=log_context) - + async def get_monitor_operation_health(self) -> Dict[str, Any]: """Get health status of monitor operations and error handling. - + Returns: Dictionary containing health status and operational metrics """ error_stats = self.get_error_statistics() - + # Determine overall health based on error patterns health_status = "healthy" health_issues = [] - + # Check circuit breaker status circuit_status = error_stats["resilience_status"]["circuit_breaker"]["state"] if circuit_status == "open": @@ -7702,13 +8345,13 @@ async def get_monitor_operation_health(self) -> Dict[str, Any]: elif circuit_status == "half_open": health_status = "degraded" health_issues.append("Circuit breaker is in recovery mode") - + # Check error rates recent_errors = error_stats["monitor_tools_errors"]["recent_errors"] if len(recent_errors) > 10: health_status = "degraded" health_issues.append(f"High recent error count: {len(recent_errors)}") - + # Check for specific error patterns error_patterns = error_stats["monitor_tools_errors"]["error_patterns"] for error_key, count in error_patterns.items(): @@ -7716,60 +8359,78 @@ async def get_monitor_operation_health(self) -> Dict[str, Any]: if health_status == "healthy": health_status = "degraded" health_issues.append(f"High error count for {error_key}: {count}") - + return { "health_status": health_status, "health_issues": health_issues, "error_statistics": error_stats, - "recommendations": self._generate_health_recommendations(health_status, health_issues), - "last_check": datetime.now().isoformat() + "recommendations": self._generate_health_recommendations( + health_status, health_issues + ), + "last_check": datetime.now().isoformat(), } - + def _generate_health_recommendations( - self, - health_status: str, - health_issues: List[str] + self, health_status: str, health_issues: List[str] ) -> List[str]: """Generate health recommendations based on current status. - + Args: health_status: Current health status health_issues: List of identified health issues - + Returns: List of recommendations to improve health """ recommendations = [] - + if health_status == "unhealthy": - recommendations.append("Check Sumo Logic API connectivity and authentication") + recommendations.append( + "Check Sumo Logic API connectivity and authentication" + ) recommendations.append("Verify network connectivity and firewall settings") - recommendations.append("Consider increasing timeout values if operations are timing out") - + recommendations.append( + "Consider increasing timeout values if operations are timing out" + ) + elif health_status == "degraded": - recommendations.append("Monitor error patterns and consider adjusting retry policies") - recommendations.append("Check for rate limiting and adjust request frequency if needed") + recommendations.append( + "Monitor error patterns and consider adjusting retry policies" + ) + recommendations.append( + "Check for rate limiting and adjust request frequency if needed" + ) recommendations.append("Verify monitor configurations are valid") - + # Specific recommendations based on issues for issue in health_issues: if "circuit breaker" in issue.lower(): - recommendations.append("Wait for circuit breaker recovery or check underlying service health") + recommendations.append( + "Wait for circuit breaker recovery or check underlying service health" + ) elif "error count" in issue.lower(): - recommendations.append("Investigate specific error patterns and root causes") + recommendations.append( + "Investigate specific error patterns and root causes" + ) elif "rate limit" in issue.lower(): - recommendations.append("Reduce request frequency or implement better rate limiting") - + recommendations.append( + "Reduce request frequency or implement better rate limiting" + ) + if not recommendations: - recommendations.append("Monitor operations are healthy - continue normal operations") - - return recommendations - + recommendations.append( + "Monitor operations are healthy - continue normal operations" + ) + + return recommendations + # Enhanced logging and monitoring methods - - def configure_enhanced_logging(self, log_level: str = "INFO", enable_structured_logging: bool = True): + + def configure_enhanced_logging( + self, log_level: str = "INFO", enable_structured_logging: bool = True + ): """Configure enhanced logging for monitor operations. - + Args: log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) enable_structured_logging: Whether to enable structured logging with extra context @@ -7777,42 +8438,42 @@ def configure_enhanced_logging(self, log_level: str = "INFO", enable_structured_ # Configure the logger for this module monitor_logger = logging.getLogger(__name__) monitor_logger.setLevel(getattr(logging, log_level.upper())) - + # Create formatter for structured logging if enable_structured_logging: formatter = logging.Formatter( - '%(asctime)s - %(name)s - %(levelname)s - %(message)s - %(extra_context)s', - defaults={'extra_context': '{}'} + "%(asctime)s - %(name)s - %(levelname)s - %(message)s - %(extra_context)s", + defaults={"extra_context": "{}"}, ) else: formatter = logging.Formatter( - '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) - + # Add console handler if not already present if not monitor_logger.handlers: console_handler = logging.StreamHandler() console_handler.setFormatter(formatter) monitor_logger.addHandler(console_handler) - + logger.info( "Enhanced logging configured for monitor tools", extra={ "log_level": log_level, "structured_logging": enable_structured_logging, - "logger_name": __name__ - } + "logger_name": __name__, + }, ) - + async def log_monitor_operation_start( self, operation: str, monitor_id: Optional[str] = None, monitor_name: Optional[str] = None, - **operation_params + **operation_params, ): """Log the start of a monitor operation with context. - + Args: operation: Monitor operation name monitor_id: Optional monitor ID @@ -7822,31 +8483,31 @@ async def log_monitor_operation_start( log_context = { "operation": operation, "operation_stage": "start", - "timestamp": datetime.now().isoformat() + "timestamp": datetime.now().isoformat(), } - + if monitor_id: log_context["monitor_id"] = monitor_id if monitor_name: log_context["monitor_name"] = monitor_name - + # Add operation-specific parameters (sanitized) if operation_params: sanitized_params = self._sanitize_log_parameters(operation_params) log_context["operation_params"] = sanitized_params - + logger.info(f"Starting monitor operation: {operation}", extra=log_context) - + async def log_monitor_operation_success( self, operation: str, execution_time_ms: float, monitor_id: Optional[str] = None, monitor_name: Optional[str] = None, - result_summary: Optional[Dict[str, Any]] = None + result_summary: Optional[Dict[str, Any]] = None, ): """Log successful completion of a monitor operation. - + Args: operation: Monitor operation name execution_time_ms: Execution time in milliseconds @@ -7858,18 +8519,20 @@ async def log_monitor_operation_success( "operation": operation, "operation_stage": "success", "execution_time_ms": round(execution_time_ms, 2), - "timestamp": datetime.now().isoformat() + "timestamp": datetime.now().isoformat(), } - + if monitor_id: log_context["monitor_id"] = monitor_id if monitor_name: log_context["monitor_name"] = monitor_name if result_summary: log_context["result_summary"] = result_summary - - logger.info(f"Monitor operation completed successfully: {operation}", extra=log_context) - + + logger.info( + f"Monitor operation completed successfully: {operation}", extra=log_context + ) + async def log_monitor_operation_error( self, operation: str, @@ -7877,10 +8540,10 @@ async def log_monitor_operation_error( execution_time_ms: float, monitor_id: Optional[str] = None, monitor_name: Optional[str] = None, - error_context: Optional[Dict[str, Any]] = None + error_context: Optional[Dict[str, Any]] = None, ): """Log failed monitor operation with error details. - + Args: operation: Monitor operation name error: Exception that occurred @@ -7895,56 +8558,64 @@ async def log_monitor_operation_error( "error_type": type(error).__name__, "error_message": str(error), "execution_time_ms": round(execution_time_ms, 2), - "timestamp": datetime.now().isoformat() + "timestamp": datetime.now().isoformat(), } - + if monitor_id: log_context["monitor_id"] = monitor_id if monitor_name: log_context["monitor_name"] = monitor_name if error_context: log_context["error_context"] = error_context - + # Add specific error details based on error type if isinstance(error, MonitorError): log_context["monitor_error_details"] = { "monitor_id": error.monitor_id, "monitor_name": error.monitor_name, "operation": error.operation, - "context": error.context + "context": error.context, } elif isinstance(error, APIError): log_context["api_error_details"] = { "status_code": error.status_code, "request_id": error.request_id, - "is_retryable": error.is_retryable + "is_retryable": error.is_retryable, } elif isinstance(error, RateLimitError): log_context["rate_limit_details"] = { "retry_after": error.retry_after, - "limit_type": error.limit_type + "limit_type": error.limit_type, } - + logger.error(f"Monitor operation failed: {operation}", extra=log_context) - + def _sanitize_log_parameters(self, params: Dict[str, Any]) -> Dict[str, Any]: """Sanitize parameters for logging to remove sensitive information. - + Args: params: Parameters dictionary to sanitize - + Returns: Sanitized parameters dictionary """ sanitized = {} sensitive_keys = { - 'password', 'token', 'key', 'secret', 'credential', 'auth', - 'webhook_url', 'api_key', 'access_token', 'private_key' + "password", + "token", + "key", + "secret", + "credential", + "auth", + "webhook_url", + "api_key", + "access_token", + "private_key", } - + for key, value in params.items(): key_lower = key.lower() - + # Check if key contains sensitive information if any(sensitive_word in key_lower for sensitive_word in sensitive_keys): sanitized[key] = "[REDACTED]" @@ -7959,163 +8630,202 @@ def _sanitize_log_parameters(self, params: Dict[str, Any]) -> Dict[str, Any]: sanitized[key] = value[:10] + ["... [TRUNCATED]"] else: sanitized[key] = value - + return sanitized - + async def generate_monitor_operation_report( - self, - time_range_hours: int = 24 + self, time_range_hours: int = 24 ) -> Dict[str, Any]: """Generate a comprehensive report of monitor operations. - + Args: time_range_hours: Time range in hours for the report - + Returns: Dictionary containing operation report with statistics and insights """ try: # Get error statistics error_stats = self.get_error_statistics() - + # Get health status health_status = await self.get_monitor_operation_health() - + # Calculate time range end_time = datetime.now() start_time = end_time - timedelta(hours=time_range_hours) - + report = { "report_metadata": { "generated_at": end_time.isoformat(), "time_range": { "start": start_time.isoformat(), "end": end_time.isoformat(), - "duration_hours": time_range_hours + "duration_hours": time_range_hours, }, - "report_version": "1.0" + "report_version": "1.0", }, "error_statistics": error_stats, "health_status": health_status, - "operational_insights": self._generate_operational_insights(error_stats, health_status), - "recommendations": self._generate_operational_recommendations(error_stats, health_status) + "operational_insights": self._generate_operational_insights( + error_stats, health_status + ), + "recommendations": self._generate_operational_recommendations( + error_stats, health_status + ), } - + logger.info( "Generated monitor operation report", extra={ "time_range_hours": time_range_hours, "health_status": health_status["health_status"], - "total_error_types": error_stats["monitor_tools_errors"]["total_error_types"] - } + "total_error_types": error_stats["monitor_tools_errors"][ + "total_error_types" + ], + }, ) - + return report - + except Exception as e: logger.error( f"Failed to generate monitor operation report: {str(e)}", - extra={"error": str(e), "time_range_hours": time_range_hours} + extra={"error": str(e), "time_range_hours": time_range_hours}, ) return { "report_metadata": { "generated_at": datetime.now().isoformat(), - "generation_error": str(e) + "generation_error": str(e), }, - "error": f"Report generation failed: {str(e)}" + "error": f"Report generation failed: {str(e)}", } - + def _generate_operational_insights( - self, - error_stats: Dict[str, Any], - health_status: Dict[str, Any] + self, error_stats: Dict[str, Any], health_status: Dict[str, Any] ) -> List[str]: """Generate operational insights from error statistics and health status. - + Args: error_stats: Error statistics dictionary health_status: Health status dictionary - + Returns: List of operational insights """ insights = [] - + # Analyze error patterns monitor_errors = error_stats.get("monitor_tools_errors", {}) total_errors = monitor_errors.get("total_errors", 0) error_patterns = monitor_errors.get("error_patterns", {}) - + if total_errors == 0: - insights.append("No errors detected in monitor operations - system is operating normally") + insights.append( + "No errors detected in monitor operations - system is operating normally" + ) elif total_errors < 5: - insights.append(f"Low error rate detected ({total_errors} total errors) - system is stable") + insights.append( + f"Low error rate detected ({total_errors} total errors) - system is stable" + ) elif total_errors < 20: - insights.append(f"Moderate error rate detected ({total_errors} total errors) - monitor for trends") + insights.append( + f"Moderate error rate detected ({total_errors} total errors) - monitor for trends" + ) else: - insights.append(f"High error rate detected ({total_errors} total errors) - investigation recommended") - + insights.append( + f"High error rate detected ({total_errors} total errors) - investigation recommended" + ) + # Analyze circuit breaker status - circuit_status = error_stats.get("resilience_status", {}).get("circuit_breaker", {}).get("state") + circuit_status = ( + error_stats.get("resilience_status", {}) + .get("circuit_breaker", {}) + .get("state") + ) if circuit_status == "open": - insights.append("Circuit breaker is open - service may be experiencing persistent failures") + insights.append( + "Circuit breaker is open - service may be experiencing persistent failures" + ) elif circuit_status == "half_open": - insights.append("Circuit breaker is in recovery mode - monitoring service recovery") - + insights.append( + "Circuit breaker is in recovery mode - monitoring service recovery" + ) + # Analyze specific error types for error_type, count in error_patterns.items(): if count > 3: - insights.append(f"Frequent {error_type} errors detected ({count} occurrences)") - + insights.append( + f"Frequent {error_type} errors detected ({count} occurrences)" + ) + # Analyze health status health = health_status.get("health_status", "unknown") if health == "unhealthy": - insights.append("Monitor tools health is unhealthy - immediate attention required") + insights.append( + "Monitor tools health is unhealthy - immediate attention required" + ) elif health == "degraded": - insights.append("Monitor tools health is degraded - performance may be impacted") - + insights.append( + "Monitor tools health is degraded - performance may be impacted" + ) + return insights - + def _generate_operational_recommendations( - self, - error_stats: Dict[str, Any], - health_status: Dict[str, Any] + self, error_stats: Dict[str, Any], health_status: Dict[str, Any] ) -> List[str]: """Generate operational recommendations based on current status. - + Args: error_stats: Error statistics dictionary health_status: Health status dictionary - + Returns: List of operational recommendations """ recommendations = [] - + # Get health recommendations health_recommendations = health_status.get("recommendations", []) recommendations.extend(health_recommendations) - + # Add specific recommendations based on error patterns - error_patterns = error_stats.get("monitor_tools_errors", {}).get("error_patterns", {}) - + error_patterns = error_stats.get("monitor_tools_errors", {}).get( + "error_patterns", {} + ) + for error_type, count in error_patterns.items(): if "rate_limit" in error_type.lower() and count > 2: - recommendations.append("Consider implementing request throttling or increasing rate limits") + recommendations.append( + "Consider implementing request throttling or increasing rate limits" + ) elif "timeout" in error_type.lower() and count > 2: - recommendations.append("Consider increasing timeout values or optimizing query performance") + recommendations.append( + "Consider increasing timeout values or optimizing query performance" + ) elif "validation" in error_type.lower() and count > 3: - recommendations.append("Review monitor configuration validation rules and user guidance") + recommendations.append( + "Review monitor configuration validation rules and user guidance" + ) elif "permission" in error_type.lower() and count > 1: - recommendations.append("Review user permissions and access control settings") - + recommendations.append( + "Review user permissions and access control settings" + ) + # Add general recommendations - total_errors = error_stats.get("monitor_tools_errors", {}).get("total_errors", 0) + total_errors = error_stats.get("monitor_tools_errors", {}).get( + "total_errors", 0 + ) if total_errors > 10: - recommendations.append("Consider implementing additional monitoring and alerting for monitor tools") - recommendations.append("Review error handling patterns and retry logic configuration") - + recommendations.append( + "Consider implementing additional monitoring and alerting for monitor tools" + ) + recommendations.append( + "Review error handling patterns and retry logic configuration" + ) + if not recommendations: recommendations.append("System is operating normally - continue monitoring") - - return recommendations \ No newline at end of file + + return recommendations diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..8753c26 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,56 @@ +import pytest +from pydantic import ValidationError + +from sumologic_mcp.config import SumoLogicConfig + + +class TestConfigEndpointValidation: + def test_valid_standard_endpoint(self): + config = SumoLogicConfig( + access_id="test_id", + access_key="test_key", + endpoint="https://api.sumologic.com", + ) + assert config.endpoint == "https://api.sumologic.com" + + def test_endpoint_with_trailing_slash(self): + config = SumoLogicConfig( + access_id="test_id", + access_key="test_key", + endpoint="https://api.sumologic.com/", + ) + assert config.endpoint == "https://api.sumologic.com" + + def test_endpoint_with_api_suffix(self): + config = SumoLogicConfig( + access_id="test_id", + access_key="test_key", + endpoint="https://api.sumologic.com/api", + ) + assert config.endpoint == "https://api.sumologic.com" + + def test_endpoint_with_api_v1_suffix(self): + config = SumoLogicConfig( + access_id="test_id", + access_key="test_key", + endpoint="https://api.us2.sumologic.com/api/v1/", + ) + assert config.endpoint == "https://api.us2.sumologic.com" + + def test_invalid_protocol(self): + with pytest.raises(ValidationError) as excinfo: + SumoLogicConfig( + access_id="test_id", + access_key="test_key", + endpoint="ftp://api.sumologic.com", + ) + assert "Endpoint must be a valid HTTP/HTTPS URL" in str(excinfo.value) + + def test_invalid_domain(self): + with pytest.raises(ValidationError) as excinfo: + SumoLogicConfig( + access_id="test_id", + access_key="test_key", + endpoint="https://api.example.com", + ) + assert "Endpoint must be a valid Sumo Logic domain" in str(excinfo.value) From 985c7178bf3030640fa5dd3fd8da865e63fa7e46 Mon Sep 17 00:00:00 2001 From: "a.key" Date: Tue, 16 Jun 2026 19:21:20 +0100 Subject: [PATCH 13/14] feat: Add folder management MCP tools and client endpoints --- sumologic_mcp/api_client.py | 240 ++++++++++++++++ sumologic_mcp/server.py | 10 + sumologic_mcp/tools/__init__.py | 2 + sumologic_mcp/tools/folder_tools.py | 421 ++++++++++++++++++++++++++++ tests/test_folder_tools.py | 252 +++++++++++++++++ 5 files changed, 925 insertions(+) create mode 100644 sumologic_mcp/tools/folder_tools.py create mode 100644 tests/test_folder_tools.py diff --git a/sumologic_mcp/api_client.py b/sumologic_mcp/api_client.py index 7f7dee1..df77c14 100644 --- a/sumologic_mcp/api_client.py +++ b/sumologic_mcp/api_client.py @@ -4257,3 +4257,243 @@ def _get_most_reliable_endpoint(self) -> Optional[str]: best_endpoint = endpoint_name return best_endpoint + + async def get_personal_folder_root(self) -> Dict[str, Any]: + """Get the root of the user's personal content folder. + + Returns: + Dictionary containing personal folder configuration + + Raises: + APIError: If API request fails + """ + logger.debug("Retrieving personal folder root") + try: + response = await self._make_request( + method="GET", + endpoint="/api/v2/content/folders/personal", + operation_type="folder", + ) + return await self._parse_json_response(response) + except APIError as e: + raise APIError( + f"Failed to get personal folder root: {e.message}", + status_code=e.status_code, + response_body=e.response_body, + request_id=e.request_id, + ) from e + + async def get_global_folder_root(self) -> Dict[str, Any]: + """Get the root of the organization's global content folder. + + Returns: + Dictionary containing global folder configuration + + Raises: + APIError: If API request fails + """ + logger.debug("Retrieving global folder root") + try: + response = await self._make_request( + method="GET", + endpoint="/api/v2/content/folders/global", + operation_type="folder", + ) + return await self._parse_json_response(response) + except APIError as e: + raise APIError( + f"Failed to get global folder root: {e.message}", + status_code=e.status_code, + response_body=e.response_body, + request_id=e.request_id, + ) from e + + async def get_folder(self, folder_id: str) -> Dict[str, Any]: + """Get specific folder configuration, metadata, and contents. + + Args: + folder_id: Folder ID to retrieve + + Returns: + Dictionary containing folder configuration and child items + + Raises: + APIParameterError: If folder_id is invalid + APIError: If API request fails + """ + if not folder_id or not folder_id.strip(): + raise APIParameterError( + param_name="folder_id", + param_value=folder_id, + expected_type="non-empty string representing a valid folder ID", + api_endpoint="folder retrieval API", + ) + + folder_id = folder_id.strip() + logger.debug(f"Retrieving folder {folder_id}") + + try: + response = await self._make_request( + method="GET", + endpoint=f"/api/v2/content/{folder_id}", + operation_type="folder", + ) + return await self._parse_json_response(response) + except APIError as e: + raise APIError( + f"Failed to get folder {folder_id}: {e.message}", + status_code=e.status_code, + response_body=e.response_body, + request_id=e.request_id, + context={"folder_id": folder_id}, + ) from e + + async def create_folder( + self, name: str, parent_id: str, description: Optional[str] = None + ) -> Dict[str, Any]: + """Create a new folder in the content library. + + Args: + name: Folder name + parent_id: Parent folder ID + description: Optional folder description + + Returns: + Dictionary containing created folder information + + Raises: + APIParameterError: If name or parent_id is invalid + APIError: If API request fails + """ + if not name or not name.strip(): + raise APIParameterError( + param_name="name", + param_value=name, + expected_type="non-empty string representing a valid folder name", + api_endpoint="create folder API", + ) + if not parent_id or not parent_id.strip(): + raise APIParameterError( + param_name="parent_id", + param_value=parent_id, + expected_type="non-empty string representing a valid parent folder ID", + api_endpoint="create folder API", + ) + + name = name.strip() + parent_id = parent_id.strip() + + payload = { + "name": name, + "parentId": parent_id + } + if description: + payload["description"] = description.strip() + + logger.info(f"Creating folder '{name}' under parent {parent_id}") + + try: + response = await self._make_request( + method="POST", + endpoint="/api/v2/content/folders", + json_data=payload, + operation_type="folder", + ) + return await self._parse_json_response(response) + except APIError as e: + raise APIError( + f"Failed to create folder '{name}': {e.message}", + status_code=e.status_code, + response_body=e.response_body, + request_id=e.request_id, + context={"name": name, "parent_id": parent_id}, + ) from e + + async def delete_folder_job(self, folder_id: str) -> Dict[str, Any]: + """Start asynchronous delete job for a folder or content item. + + Args: + folder_id: Folder or content item ID to delete + + Returns: + Dictionary containing deletion job ID + + Raises: + APIParameterError: If folder_id is invalid + APIError: If API request fails + """ + if not folder_id or not folder_id.strip(): + raise APIParameterError( + param_name="folder_id", + param_value=folder_id, + expected_type="non-empty string representing a valid folder ID", + api_endpoint="delete folder API", + ) + + folder_id = folder_id.strip() + logger.info(f"Starting delete job for folder/content {folder_id}") + + try: + response = await self._make_request( + method="DELETE", + endpoint=f"/api/v2/content/{folder_id}/delete", + operation_type="folder", + ) + return await self._parse_json_response(response) + except APIError as e: + raise APIError( + f"Failed to start delete job for folder {folder_id}: {e.message}", + status_code=e.status_code, + response_body=e.response_body, + request_id=e.request_id, + context={"folder_id": folder_id}, + ) from e + + async def get_delete_folder_status(self, folder_id: str, job_id: str) -> Dict[str, Any]: + """Get status of a folder/content deletion job. + + Args: + folder_id: Folder or content item ID + job_id: Deletion job ID + + Returns: + Dictionary containing deletion job status + + Raises: + APIParameterError: If folder_id or job_id is invalid + APIError: If API request fails + """ + if not folder_id or not folder_id.strip(): + raise APIParameterError( + param_name="folder_id", + param_value=folder_id, + expected_type="non-empty string representing a valid folder ID", + api_endpoint="delete folder status API", + ) + if not job_id or not job_id.strip(): + raise APIParameterError( + param_name="job_id", + param_value=job_id, + expected_type="non-empty string representing a valid deletion job ID", + api_endpoint="delete folder status API", + ) + + folder_id = folder_id.strip() + job_id = job_id.strip() + logger.debug(f"Checking delete job status for folder {folder_id}, job {job_id}") + + try: + response = await self._make_request( + method="GET", + endpoint=f"/api/v2/content/{folder_id}/delete/{job_id}/status", + operation_type="folder", + ) + return await self._parse_json_response(response) + except APIError as e: + raise APIError( + f"Failed to check delete job status for folder {folder_id}: {e.message}", + status_code=e.status_code, + response_body=e.response_body, + request_id=e.request_id, + context={"folder_id": folder_id, "job_id": job_id}, + ) from e diff --git a/sumologic_mcp/server.py b/sumologic_mcp/server.py index d1e0471..018e82e 100644 --- a/sumologic_mcp/server.py +++ b/sumologic_mcp/server.py @@ -24,6 +24,7 @@ from .tools.api_tools import APITools from .tools.collector_tools import CollectorTools from .tools.dashboard_tools import DashboardTools +from .tools.folder_tools import FolderTools from .tools.metrics_tools import MetricsTools from .tools.monitor_tools import MonitorTools from .tools.search_tools import SearchTools @@ -54,6 +55,7 @@ def __init__(self, config: SumoLogicConfig): self.metrics_tools: Optional[MetricsTools] = None self.collector_tools: Optional[CollectorTools] = None self.monitor_tools: Optional[MonitorTools] = None + self.folder_tools: Optional[FolderTools] = None self.api_tools: Optional[APITools] = None # Tool registry @@ -97,6 +99,7 @@ async def start(self) -> None: self.metrics_tools = MetricsTools(self.api_client) self.collector_tools = CollectorTools(self.api_client) self.monitor_tools = MonitorTools(self.api_client) + self.folder_tools = FolderTools(self.api_client) self.api_tools = APITools(self.api_client) # Start monitoring @@ -236,6 +239,10 @@ def register_tools(self) -> None: monitor_tool_defs = self.monitor_tools.get_tool_definitions() all_tools.extend(monitor_tool_defs) + # Get folder tools + folder_tool_defs = self.folder_tools.get_tool_definitions() + all_tools.extend(folder_tool_defs) + # Get api tools api_tool_defs = self.api_tools.get_tool_definitions() all_tools.extend(api_tool_defs) @@ -278,6 +285,8 @@ def register_tools(self) -> None: handler = getattr(self.collector_tools, tool_name) elif tool_name in [t["name"] for t in monitor_tool_defs]: handler = getattr(self.monitor_tools, tool_name) + elif tool_name in [t["name"] for t in folder_tool_defs]: + handler = getattr(self.folder_tools, tool_name) elif tool_name in [t["name"] for t in api_tool_defs]: handler = getattr(self.api_tools, tool_name) else: @@ -307,6 +316,7 @@ async def list_tools_handler(): metrics_tools=len(metrics_tool_defs), collector_tools=len(collector_tool_defs), monitor_tools=len(monitor_tool_defs), + folder_tools=len(folder_tool_defs), api_tools=len(api_tool_defs), ) diff --git a/sumologic_mcp/tools/__init__.py b/sumologic_mcp/tools/__init__.py index 2c11a3f..e909d5a 100644 --- a/sumologic_mcp/tools/__init__.py +++ b/sumologic_mcp/tools/__init__.py @@ -3,6 +3,7 @@ from .api_tools import APITools from .collector_tools import CollectorTools from .dashboard_tools import DashboardTools +from .folder_tools import FolderTools from .metrics_tools import MetricsTools from .monitor_tools import MonitorTools from .search_tools import SearchTools @@ -10,6 +11,7 @@ __all__ = [ "SearchTools", "DashboardTools", + "FolderTools", "MetricsTools", "CollectorTools", "MonitorTools", diff --git a/sumologic_mcp/tools/folder_tools.py b/sumologic_mcp/tools/folder_tools.py new file mode 100644 index 0000000..4bd8838 --- /dev/null +++ b/sumologic_mcp/tools/folder_tools.py @@ -0,0 +1,421 @@ +"""Folder management tools for Sumo Logic MCP server. + +This module implements MCP tools for Sumo Logic content library and folder operations. +""" + +import asyncio +from typing import Dict, Any, Optional, List +import logging +from datetime import datetime + +from ..api_client import SumoLogicAPIClient +from ..exceptions import ValidationError, APIError + +logger = logging.getLogger(__name__) + + +class FolderTools: + """MCP tools for Sumo Logic content folder operations.""" + + def __init__(self, api_client: SumoLogicAPIClient): + """Initialize FolderTools with API client. + + Args: + api_client: Configured SumoLogicAPIClient instance + """ + self.api_client = api_client + + async def get_personal_folder_root(self) -> Dict[str, Any]: + """Retrieve the user's personal content folder root. + + Returns: + Dict containing personal folder configuration and contents. + """ + try: + logger.info("Retrieving personal folder root") + return await self.api_client.get_personal_folder_root() + except APIError as e: + logger.error(f"Failed to get personal folder root: {e}") + raise + + async def get_global_folder_root(self) -> Dict[str, Any]: + """Retrieve the organization's global content folder root. + + Returns: + Dict containing global folder configuration and contents. + """ + try: + logger.info("Retrieving global folder root") + return await self.api_client.get_global_folder_root() + except APIError as e: + logger.error(f"Failed to get global folder root: {e}") + raise + + async def get_folder(self, folder_id: str) -> Dict[str, Any]: + """Retrieve a specific folder configuration and its contents by ID. + + Args: + folder_id: Folder ID to retrieve + + Returns: + Dict containing folder configuration and contents. + """ + try: + logger.info(f"Retrieving folder {folder_id}") + return await self.api_client.get_folder(folder_id) + except APIError as e: + logger.error(f"Failed to get folder {folder_id}: {e}") + raise + + async def create_folder( + self, name: str, parent_id: str, description: Optional[str] = None + ) -> Dict[str, Any]: + """Create a new folder in the content library under a specified parent folder. + + Args: + name: Folder name + parent_id: Parent folder ID + description: Optional folder description + + Returns: + Dict containing created folder metadata. + """ + try: + logger.info(f"Creating folder '{name}' under parent {parent_id}") + return await self.api_client.create_folder( + name=name, parent_id=parent_id, description=description + ) + except APIError as e: + logger.error(f"Failed to create folder '{name}': {e}") + raise + + async def delete_folder( + self, folder_id: str, wait_for_completion: bool = True, timeout: int = 60 + ) -> Dict[str, Any]: + """Delete a folder or content item by ID. + + Args: + folder_id: Folder ID to delete + wait_for_completion: Whether to wait for the deletion job to complete (default: True) + timeout: Maximum wait time in seconds (default: 60) + + Returns: + Dict containing deletion status/job ID. + """ + try: + logger.info(f"Deleting folder/content {folder_id}") + job_response = await self.api_client.delete_folder_job(folder_id) + job_id = job_response.get("id") or job_response.get("jobId") + + if not job_id: + # If no job ID was returned, assume it completed synchronously or response had no ID + return { + "status": "Success", + "folder_id": folder_id, + "message": "Deletion requested successfully.", + } + + if not wait_for_completion: + return { + "status": "InProgress", + "folder_id": folder_id, + "job_id": job_id, + "message": "Deletion job started asynchronously.", + } + + # Polling status + start_time = datetime.now() + while True: + status_response = await self.api_client.get_delete_folder_status( + folder_id, job_id + ) + status = status_response.get("status", "InProgress") + + if status == "Success": + return { + "status": "Success", + "folder_id": folder_id, + "job_id": job_id, + "message": "Folder deleted successfully.", + } + elif status == "Failed": + error_msg = status_response.get("error", {}).get("message", "Unknown error") + raise APIError(f"Folder deletion job failed: {error_msg}") + + # Check timeout + elapsed = (datetime.now() - start_time).total_seconds() + if elapsed > timeout: + raise APIError( + f"Folder deletion job {job_id} timed out after {timeout} seconds" + ) + + await asyncio.sleep(2) + + except APIError as e: + logger.error(f"Failed to delete folder {folder_id}: {e}") + raise + + async def list_folders( + self, parent_id: Optional[str] = None, recursive: bool = False, max_depth: int = 3 + ) -> Dict[str, Any]: + """List subfolders within a specific parent folder, optionally recursing down the tree. + + If parent_id is not specified, it lists the contents under the Personal folder root. + + Args: + parent_id: Optional unique identifier of the parent folder. + recursive: Whether to recursively traverse subfolders. + max_depth: Maximum depth to traverse if recursive is True (default: 3, max: 5). + + Returns: + Dict containing a list of subfolders and parent metadata. + """ + try: + if not parent_id or not parent_id.strip(): + logger.info("No parent_id specified. Defaulting to Personal folder root.") + parent_folder = await self.api_client.get_personal_folder_root() + else: + logger.info(f"Listing subfolders under parent {parent_id}") + parent_folder = await self.api_client.get_folder(parent_id.strip()) + + # Enforce max depth limit of 5 to protect API performance + safe_max_depth = min(max(1, max_depth), 5) + subfolders = [] + + async def recurse_folders(current_folder: Dict[str, Any], current_path: str, depth: int): + if depth > safe_max_depth: + return + + children = current_folder.get("children", []) + for child in children: + item_type = (child.get("itemType") or child.get("contentType") or "").lower() + if item_type == "folder": + child_id = child.get("id") + child_name = child.get("name") or child.get("title", "") + child_path = f"{current_path}/{child_name}" + + folder_record = { + "id": child_id, + "name": child_name, + "path": child_path, + "description": child.get("description", ""), + "created_at": child.get("createdAt"), + "created_by": child.get("createdBy"), + "modified_at": child.get("modifiedAt"), + "modified_by": child.get("modifiedBy"), + "parent_id": current_folder.get("id"), + "depth": depth + } + subfolders.append(folder_record) + + if recursive: + try: + nested_folder = await self.api_client.get_folder(child_id) + await recurse_folders(nested_folder, child_path, depth + 1) + except Exception as e: + logger.warning( + f"Failed to fetch subfolder {child_name} ({child_id}) during recursion: {e}" + ) + + parent_name = parent_folder.get("name") or parent_folder.get("title", "Root") + await recurse_folders(parent_folder, f"/{parent_name}", 1) + + return { + "parent_folder_id": parent_folder.get("id"), + "parent_folder_name": parent_name, + "recursive_applied": recursive, + "max_depth_applied": safe_max_depth, + "subfolders": subfolders, + "total_count": len(subfolders), + } + + except APIError as e: + logger.error(f"Failed to list subfolders: {e}") + raise + + async def get_folder_by_path(self, path: str) -> Dict[str, Any]: + """Resolve a slash-separated path (e.g. '/Personal/MyFolder/SubFolder') to a folder. + + Args: + path: Slash-separated folder path. First segment must be 'Personal' or 'Global'/'Shared'. + + Returns: + Dict containing folder configuration and contents. + """ + try: + # Clean and parse the path + clean_path = path.strip().strip("/") + if not clean_path: + raise ValidationError("Path must be a non-empty string") + + segments = clean_path.split("/") + root_segment = segments[0].lower() + + logger.info(f"Resolving folder path: {path}") + + # Resolve root folder + if root_segment == "personal": + current_folder = await self.api_client.get_personal_folder_root() + elif root_segment in ["global", "shared"]: + current_folder = await self.api_client.get_global_folder_root() + else: + # Default fallback to personal root + logger.warning( + f"Root segment '{segments[0]}' not recognized. Defaulting to 'Personal'." + ) + current_folder = await self.api_client.get_personal_folder_root() + # If segment[0] is just a folder name under personal root, don't consume it as the root specifier + # We start traversal from personal root, but we include segments[0] as a child folder to resolve + segments = ["personal"] + segments + + # Traverse down the subfolder path + for segment in segments[1:]: + children = current_folder.get("children", []) + found_child = None + for child in children: + item_type = (child.get("itemType") or child.get("contentType") or "").lower() + item_name = (child.get("name") or child.get("title") or "").lower() + if item_type == "folder" and item_name == segment.lower(): + found_child = child + break + + if not found_child: + raise ValidationError( + f"Subfolder '{segment}' not found in parent path. Available folders: " + f"{[c.get('name') or c.get('title') for c in children if (c.get('itemType') or c.get('contentType') or '').lower() == 'folder']}" + ) + + # Fetch child folder's full details (with its own children) + current_folder = await self.api_client.get_folder(found_child["id"]) + + return current_folder + + except ValidationError as e: + logger.error(f"Path resolution validation failed: {e}") + raise + except APIError as e: + logger.error(f"Path resolution API error: {e}") + raise + except Exception as e: + logger.error(f"Unexpected error resolving path: {e}") + raise APIError(f"Path resolution failed: {str(e)}") + + def get_tool_definitions(self) -> List[Dict[str, Any]]: + """Get MCP tool definitions for folder operations. + + Returns: + List of tool definitions for MCP server registration. + """ + return [ + { + "name": "list_folders", + "description": "List all subfolders under a parent folder, optionally traversing recursively.", + "inputSchema": { + "type": "object", + "properties": { + "parent_id": { + "type": "string", + "description": "Optional ID of the parent folder to list subfolders from", + }, + "recursive": { + "type": "boolean", + "description": "True to recursively traverse subfolders (default: false)", + "default": False, + }, + "max_depth": { + "type": "integer", + "description": "Maximum recursion depth to traverse if recursive is True (default: 3, max: 5)", + "minimum": 1, + "maximum": 5, + "default": 3, + }, + }, + "required": [], + }, + }, + { + "name": "get_personal_folder_root", + "description": "Retrieve the root of the user's personal content folder", + "inputSchema": {"type": "object", "properties": {}, "required": []}, + }, + { + "name": "get_global_folder_root", + "description": "Retrieve the root of the organization's global content folder", + "inputSchema": {"type": "object", "properties": {}, "required": []}, + }, + { + "name": "get_folder", + "description": "Retrieve a specific folder configuration and its child items by ID", + "inputSchema": { + "type": "object", + "properties": { + "folder_id": { + "type": "string", + "description": "Unique identifier of the folder", + } + }, + "required": ["folder_id"], + }, + }, + { + "name": "create_folder", + "description": "Create a new folder in the content library", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the new folder (max 255 characters)", + }, + "parent_id": { + "type": "string", + "description": "ID of the parent folder where this folder will reside", + }, + "description": { + "type": "string", + "description": "Optional folder description", + }, + }, + "required": ["name", "parent_id"], + }, + }, + { + "name": "delete_folder", + "description": "Delete a folder or other library content item", + "inputSchema": { + "type": "object", + "properties": { + "folder_id": { + "type": "string", + "description": "Unique identifier of the folder or content item to delete", + }, + "wait_for_completion": { + "type": "boolean", + "description": "Wait for deletion job to finish before returning (default: true)", + "default": True, + }, + }, + "required": ["folder_id"], + }, + }, + { + "name": "get_folder_by_path", + "description": ( + "Resolve a slash-separated path (e.g. '/Personal/MyFolder/SubFolder') to " + "retrieve a folder's ID, metadata, and contents" + ), + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": ( + "Slash-separated path. First segment must be 'Personal' or " + "'Global'/'Shared' (e.g., '/Personal/Production/Alerts')" + ), + } + }, + "required": ["path"], + }, + }, + ] diff --git a/tests/test_folder_tools.py b/tests/test_folder_tools.py new file mode 100644 index 0000000..4143f54 --- /dev/null +++ b/tests/test_folder_tools.py @@ -0,0 +1,252 @@ +"""Unit tests for FolderTools class and folder management MCP tools.""" + +from unittest.mock import AsyncMock, MagicMock +import pytest +from datetime import datetime + +from sumologic_mcp.api_client import SumoLogicAPIClient +from sumologic_mcp.exceptions import ValidationError, APIError +from sumologic_mcp.tools.folder_tools import FolderTools + + +@pytest.fixture +def mock_api_client(): + client = MagicMock(spec=SumoLogicAPIClient) + client.get_personal_folder_root = AsyncMock() + client.get_global_folder_root = AsyncMock() + client.get_folder = AsyncMock() + client.create_folder = AsyncMock() + client.delete_folder_job = AsyncMock() + client.get_delete_folder_status = AsyncMock() + return client + + +class TestFolderTools: + @pytest.mark.asyncio + async def test_get_personal_folder_root(self, mock_api_client): + tools = FolderTools(mock_api_client) + mock_response = {"id": "personal_root", "name": "Personal", "children": []} + mock_api_client.get_personal_folder_root.return_value = mock_response + + result = await tools.get_personal_folder_root() + assert result == mock_response + mock_api_client.get_personal_folder_root.assert_called_once() + + @pytest.mark.asyncio + async def test_get_global_folder_root(self, mock_api_client): + tools = FolderTools(mock_api_client) + mock_response = {"id": "global_root", "name": "Global", "children": []} + mock_api_client.get_global_folder_root.return_value = mock_response + + result = await tools.get_global_folder_root() + assert result == mock_response + mock_api_client.get_global_folder_root.assert_called_once() + + @pytest.mark.asyncio + async def test_get_folder(self, mock_api_client): + tools = FolderTools(mock_api_client) + mock_response = {"id": "folder123", "name": "My Folder", "children": []} + mock_api_client.get_folder.return_value = mock_response + + result = await tools.get_folder("folder123") + assert result == mock_response + mock_api_client.get_folder.assert_called_once_with("folder123") + + @pytest.mark.asyncio + async def test_create_folder(self, mock_api_client): + tools = FolderTools(mock_api_client) + mock_response = {"id": "new_folder", "name": "Sub", "parentId": "parent123"} + mock_api_client.create_folder.return_value = mock_response + + result = await tools.create_folder("Sub", "parent123", "Desc") + assert result == mock_response + mock_api_client.create_folder.assert_called_once_with( + name="Sub", parent_id="parent123", description="Desc" + ) + + @pytest.mark.asyncio + async def test_delete_folder_no_wait(self, mock_api_client): + tools = FolderTools(mock_api_client) + mock_api_client.delete_folder_job.return_value = {"id": "job123"} + + result = await tools.delete_folder("folder123", wait_for_completion=False) + assert result["status"] == "InProgress" + assert result["job_id"] == "job123" + mock_api_client.delete_folder_job.assert_called_once_with("folder123") + mock_api_client.get_delete_folder_status.assert_not_called() + + @pytest.mark.asyncio + async def test_delete_folder_wait_success(self, mock_api_client): + tools = FolderTools(mock_api_client) + mock_api_client.delete_folder_job.return_value = {"id": "job123"} + mock_api_client.get_delete_folder_status.side_effect = [ + {"status": "InProgress"}, + {"status": "Success"}, + ] + + result = await tools.delete_folder("folder123", wait_for_completion=True) + assert result["status"] == "Success" + assert result["job_id"] == "job123" + assert mock_api_client.get_delete_folder_status.call_count == 2 + + @pytest.mark.asyncio + async def test_delete_folder_wait_failed(self, mock_api_client): + tools = FolderTools(mock_api_client) + mock_api_client.delete_folder_job.return_value = {"id": "job123"} + mock_api_client.get_delete_folder_status.return_value = { + "status": "Failed", + "error": {"message": "Permission denied"}, + } + + with pytest.raises(APIError) as excinfo: + await tools.delete_folder("folder123", wait_for_completion=True) + + assert "Folder deletion job failed: Permission denied" in str(excinfo.value) + + @pytest.mark.asyncio + async def test_list_folders_default(self, mock_api_client): + tools = FolderTools(mock_api_client) + personal_root = { + "id": "personal_root", + "name": "Personal", + "children": [ + {"id": "sub1", "name": "Analytics", "itemType": "Folder"}, + {"id": "dash1", "title": "Dashboard", "itemType": "Dashboard"}, + ], + } + mock_api_client.get_personal_folder_root.return_value = personal_root + + result = await tools.list_folders() + assert result["parent_folder_id"] == "personal_root" + assert len(result["subfolders"]) == 1 + assert result["subfolders"][0]["id"] == "sub1" + assert result["subfolders"][0]["name"] == "Analytics" + mock_api_client.get_personal_folder_root.assert_called_once() + mock_api_client.get_folder.assert_not_called() + + @pytest.mark.asyncio + async def test_list_folders_with_id(self, mock_api_client): + tools = FolderTools(mock_api_client) + mock_folder = { + "id": "folder123", + "name": "My Folder", + "children": [ + {"id": "sub2", "name": "Production", "contentType": "Folder"}, + ], + } + mock_api_client.get_folder.return_value = mock_folder + + result = await tools.list_folders("folder123") + assert result["parent_folder_id"] == "folder123" + assert len(result["subfolders"]) == 1 + assert result["subfolders"][0]["id"] == "sub2" + mock_api_client.get_folder.assert_called_once_with("folder123") + + @pytest.mark.asyncio + async def test_list_folders_recursive(self, mock_api_client): + tools = FolderTools(mock_api_client) + personal_root = { + "id": "personal_root", + "name": "Personal", + "children": [ + {"id": "sub1", "name": "Analytics", "itemType": "Folder"}, + ], + } + analytics_folder = { + "id": "sub1", + "name": "Analytics", + "children": [ + {"id": "sub2", "name": "Production", "itemType": "Folder"} + ], + } + prod_folder = { + "id": "sub2", + "name": "Production", + "children": [], + } + + mock_api_client.get_personal_folder_root.return_value = personal_root + mock_api_client.get_folder.side_effect = [analytics_folder, prod_folder] + + result = await tools.list_folders(recursive=True) + assert result["parent_folder_id"] == "personal_root" + assert result["recursive_applied"] is True + assert len(result["subfolders"]) == 2 + + # Verify first level child + assert result["subfolders"][0]["id"] == "sub1" + assert result["subfolders"][0]["path"] == "/Personal/Analytics" + assert result["subfolders"][0]["depth"] == 1 + + # Verify nested child + assert result["subfolders"][1]["id"] == "sub2" + assert result["subfolders"][1]["path"] == "/Personal/Analytics/Production" + assert result["subfolders"][1]["depth"] == 2 + + mock_api_client.get_personal_folder_root.assert_called_once() + mock_api_client.get_folder.assert_any_call("sub1") + mock_api_client.get_folder.assert_any_call("sub2") + + @pytest.mark.asyncio + async def test_get_folder_by_path_personal(self, mock_api_client): + tools = FolderTools(mock_api_client) + personal_root = { + "id": "personal_root", + "name": "Personal", + "children": [ + {"id": "sub1", "name": "Analytics", "itemType": "Folder"}, + {"id": "dash1", "title": "Dashboard", "itemType": "Dashboard"}, + ], + } + analytics_folder = { + "id": "sub1", + "name": "Analytics", + "children": [ + {"id": "sub2", "name": "Production", "contentType": "Folder"} + ], + } + prod_folder = { + "id": "sub2", + "name": "Production", + "children": [], + } + + mock_api_client.get_personal_folder_root.return_value = personal_root + mock_api_client.get_folder.side_effect = [analytics_folder, prod_folder] + + result = await tools.get_folder_by_path("/Personal/Analytics/Production") + assert result == prod_folder + mock_api_client.get_personal_folder_root.assert_called_once() + mock_api_client.get_folder.assert_any_call("sub1") + mock_api_client.get_folder.assert_any_call("sub2") + + @pytest.mark.asyncio + async def test_get_folder_by_path_not_found(self, mock_api_client): + tools = FolderTools(mock_api_client) + personal_root = { + "id": "personal_root", + "name": "Personal", + "children": [ + {"id": "sub1", "name": "Analytics", "itemType": "Folder"}, + ], + } + mock_api_client.get_personal_folder_root.return_value = personal_root + + with pytest.raises(ValidationError) as excinfo: + await tools.get_folder_by_path("/Personal/MissingFolder") + + assert "Subfolder 'MissingFolder' not found" in str(excinfo.value) + + def test_get_tool_definitions(self, mock_api_client): + tools = FolderTools(mock_api_client) + defs = tools.get_tool_definitions() + + assert len(defs) == 7 + names = [d["name"] for d in defs] + assert "list_folders" in names + assert "get_personal_folder_root" in names + assert "get_global_folder_root" in names + assert "get_folder" in names + assert "create_folder" in names + assert "delete_folder" in names + assert "get_folder_by_path" in names From a201ec9db1685d29758a6864d0237de5ed9a2bf6 Mon Sep 17 00:00:00 2001 From: "a.key" Date: Wed, 17 Jun 2026 18:26:30 +0100 Subject: [PATCH 14/14] Fix asyncio reentrant-lock deadlock in monitoring ConnectionMonitor.get_all_connections_status() and MetricsCollector.get_all_metrics() acquired their non-reentrant asyncio.Lock and then, while still holding it, called get_connection_status() / get_metric() which try to acquire the same lock again. asyncio.Lock is not reentrant, so these calls deadlocked forever. This hung the health_check tool, since get_comprehensive_status() invokes get_all_connections_status() (directly and via the connection_monitor health check). Fix: snapshot the keys under the lock, then release it before calling the per-item helpers that re-acquire it. --- sumologic_mcp/monitoring.py | 356 ++++++++++++++++++------------------ 1 file changed, 176 insertions(+), 180 deletions(-) diff --git a/sumologic_mcp/monitoring.py b/sumologic_mcp/monitoring.py index e5bfa32..4f8091c 100644 --- a/sumologic_mcp/monitoring.py +++ b/sumologic_mcp/monitoring.py @@ -7,20 +7,20 @@ import asyncio import logging import time +from collections import defaultdict, deque from dataclasses import dataclass, field from datetime import datetime, timedelta from enum import Enum -from typing import Any, Dict, List, Optional, Callable, Union -from collections import defaultdict, deque - -from .exceptions import SumoLogicError, APIError, TimeoutError +from typing import Any, Callable, Dict, List, Optional, Union +from .exceptions import APIError, SumoLogicError, TimeoutError logger = logging.getLogger(__name__) class HealthStatus(Enum): """Health status levels.""" + HEALTHY = "healthy" DEGRADED = "degraded" UNHEALTHY = "unhealthy" @@ -29,6 +29,7 @@ class HealthStatus(Enum): class MetricType(Enum): """Types of metrics that can be collected.""" + COUNTER = "counter" GAUGE = "gauge" HISTOGRAM = "histogram" @@ -38,13 +39,14 @@ class MetricType(Enum): @dataclass class HealthCheckResult: """Result of a health check.""" + name: str status: HealthStatus message: str timestamp: datetime duration_ms: float details: Dict[str, Any] = field(default_factory=dict) - + def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for serialization.""" return { @@ -53,54 +55,57 @@ def to_dict(self) -> Dict[str, Any]: "message": self.message, "timestamp": self.timestamp.isoformat(), "duration_ms": self.duration_ms, - "details": self.details + "details": self.details, } @dataclass class MetricValue: """A metric value with timestamp.""" + value: Union[int, float] timestamp: datetime labels: Dict[str, str] = field(default_factory=dict) - + def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for serialization.""" return { "value": self.value, "timestamp": self.timestamp.isoformat(), - "labels": self.labels + "labels": self.labels, } class MetricsCollector: """Collects and stores metrics for monitoring.""" - + def __init__(self, max_history: int = 1000): """Initialize metrics collector. - + Args: max_history: Maximum number of metric values to keep in history """ self.max_history = max_history - self._metrics: Dict[str, Dict[str, Any]] = defaultdict(lambda: { - "type": MetricType.COUNTER, - "values": deque(maxlen=max_history), - "current_value": 0, - "labels": {} - }) + self._metrics: Dict[str, Dict[str, Any]] = defaultdict( + lambda: { + "type": MetricType.COUNTER, + "values": deque(maxlen=max_history), + "current_value": 0, + "labels": {}, + } + ) self._lock = asyncio.Lock() - + logger.info(f"Initialized metrics collector with max_history={max_history}") - + async def increment_counter( self, name: str, value: Union[int, float] = 1, - labels: Optional[Dict[str, str]] = None + labels: Optional[Dict[str, str]] = None, ): """Increment a counter metric. - + Args: name: Metric name value: Value to increment by @@ -110,27 +115,27 @@ async def increment_counter( metric = self._metrics[name] metric["type"] = MetricType.COUNTER metric["current_value"] += value - + metric_value = MetricValue( value=metric["current_value"], timestamp=datetime.utcnow(), - labels=labels or {} + labels=labels or {}, ) metric["values"].append(metric_value) - + if labels: metric["labels"].update(labels) - + logger.debug(f"Incremented counter '{name}' by {value}") - + async def set_gauge( self, name: str, value: Union[int, float], - labels: Optional[Dict[str, str]] = None + labels: Optional[Dict[str, str]] = None, ): """Set a gauge metric value. - + Args: name: Metric name value: Gauge value @@ -140,27 +145,22 @@ async def set_gauge( metric = self._metrics[name] metric["type"] = MetricType.GAUGE metric["current_value"] = value - + metric_value = MetricValue( - value=value, - timestamp=datetime.utcnow(), - labels=labels or {} + value=value, timestamp=datetime.utcnow(), labels=labels or {} ) metric["values"].append(metric_value) - + if labels: metric["labels"].update(labels) - + logger.debug(f"Set gauge '{name}' to {value}") - + async def record_timer( - self, - name: str, - duration_ms: float, - labels: Optional[Dict[str, str]] = None + self, name: str, duration_ms: float, labels: Optional[Dict[str, str]] = None ): """Record a timer metric. - + Args: name: Metric name duration_ms: Duration in milliseconds @@ -169,32 +169,30 @@ async def record_timer( async with self._lock: metric = self._metrics[name] metric["type"] = MetricType.TIMER - + metric_value = MetricValue( - value=duration_ms, - timestamp=datetime.utcnow(), - labels=labels or {} + value=duration_ms, timestamp=datetime.utcnow(), labels=labels or {} ) metric["values"].append(metric_value) - + if labels: metric["labels"].update(labels) - + logger.debug(f"Recorded timer '{name}': {duration_ms}ms") - + async def get_metric(self, name: str) -> Optional[Dict[str, Any]]: """Get a metric by name. - + Args: name: Metric name - + Returns: Metric data or None if not found """ async with self._lock: if name not in self._metrics: return None - + metric = self._metrics[name] return { "name": name, @@ -202,24 +200,25 @@ async def get_metric(self, name: str) -> Optional[Dict[str, Any]]: "current_value": metric["current_value"], "labels": metric["labels"], "history_count": len(metric["values"]), - "latest_values": [v.to_dict() for v in list(metric["values"])[-10:]] + "latest_values": [v.to_dict() for v in list(metric["values"])[-10:]], } - + async def get_all_metrics(self) -> Dict[str, Any]: """Get all metrics. - + Returns: Dictionary of all metrics """ async with self._lock: - result = {} - for name in self._metrics: - result[name] = await self.get_metric(name) - return result - + names = list(self._metrics.keys()) + result = {} + for name in names: + result[name] = await self.get_metric(name) + return result + async def get_metrics_summary(self) -> Dict[str, Any]: """Get a summary of all metrics. - + Returns: Summary of metrics """ @@ -227,22 +226,22 @@ async def get_metrics_summary(self) -> Dict[str, Any]: summary = { "total_metrics": len(self._metrics), "metrics_by_type": defaultdict(int), - "total_data_points": 0 + "total_data_points": 0, } - + for metric in self._metrics.values(): summary["metrics_by_type"][metric["type"].value] += 1 summary["total_data_points"] += len(metric["values"]) - + return dict(summary) class ConnectionMonitor: """Monitors connection status to Sumo Logic APIs.""" - + def __init__(self, check_interval: float = 60.0): """Initialize connection monitor. - + Args: check_interval: Interval between connection checks in seconds """ @@ -251,40 +250,41 @@ def __init__(self, check_interval: float = 60.0): self._monitoring_task: Optional[asyncio.Task] = None self._stop_event = asyncio.Event() self._lock = asyncio.Lock() - - logger.info(f"Initialized connection monitor with check_interval={check_interval}s") - + + logger.info( + f"Initialized connection monitor with check_interval={check_interval}s" + ) + async def start_monitoring(self): """Start connection monitoring.""" if self._monitoring_task and not self._monitoring_task.done(): logger.warning("Connection monitoring is already running") return - + self._stop_event.clear() self._monitoring_task = asyncio.create_task(self._monitoring_loop()) logger.info("Started connection monitoring") - + async def stop_monitoring(self): """Stop connection monitoring.""" self._stop_event.set() - + if self._monitoring_task: try: await asyncio.wait_for(self._monitoring_task, timeout=5.0) except asyncio.TimeoutError: logger.warning("Connection monitoring task did not stop gracefully") self._monitoring_task.cancel() - + logger.info("Stopped connection monitoring") - + async def _monitoring_loop(self): """Main monitoring loop.""" while not self._stop_event.is_set(): try: await self._check_all_connections() await asyncio.wait_for( - self._stop_event.wait(), - timeout=self.check_interval + self._stop_event.wait(), timeout=self.check_interval ) except asyncio.TimeoutError: # Expected timeout, continue monitoring @@ -292,21 +292,18 @@ async def _monitoring_loop(self): except Exception as e: logger.error(f"Error in connection monitoring loop: {e}") await asyncio.sleep(5) # Brief pause before retrying - + async def _check_all_connections(self): """Check all registered connections.""" # This would be implemented to check actual API endpoints # For now, we'll simulate connection checks pass - + async def register_connection( - self, - name: str, - endpoint: str, - check_func: Optional[Callable] = None + self, name: str, endpoint: str, check_func: Optional[Callable] = None ): """Register a connection to monitor. - + Args: name: Connection name endpoint: API endpoint URL @@ -323,22 +320,22 @@ async def register_connection( "consecutive_failures": 0, "total_checks": 0, "total_successes": 0, - "total_failures": 0 + "total_failures": 0, } - + logger.info(f"Registered connection '{name}' for monitoring") - + async def check_connection(self, name: str) -> HealthCheckResult: """Check a specific connection. - + Args: name: Connection name - + Returns: Health check result """ start_time = time.time() - + async with self._lock: if name not in self._connection_status: return HealthCheckResult( @@ -346,16 +343,16 @@ async def check_connection(self, name: str) -> HealthCheckResult: status=HealthStatus.UNKNOWN, message="Connection not registered", timestamp=datetime.utcnow(), - duration_ms=0 + duration_ms=0, ) - + connection = self._connection_status[name] - + # Perform the actual check try: if connection["check_func"]: await connection["check_func"]() - + # Update success metrics async with self._lock: connection["status"] = HealthStatus.HEALTHY @@ -364,21 +361,18 @@ async def check_connection(self, name: str) -> HealthCheckResult: connection["consecutive_failures"] = 0 connection["total_checks"] += 1 connection["total_successes"] += 1 - + duration_ms = (time.time() - start_time) * 1000 - + return HealthCheckResult( name=name, status=HealthStatus.HEALTHY, message="Connection successful", timestamp=datetime.utcnow(), duration_ms=duration_ms, - details={ - "endpoint": connection["endpoint"], - "consecutive_failures": 0 - } + details={"endpoint": connection["endpoint"], "consecutive_failures": 0}, ) - + except Exception as e: # Update failure metrics async with self._lock: @@ -387,15 +381,15 @@ async def check_connection(self, name: str) -> HealthCheckResult: connection["consecutive_failures"] += 1 connection["total_checks"] += 1 connection["total_failures"] += 1 - + # Determine status based on consecutive failures if connection["consecutive_failures"] >= 3: connection["status"] = HealthStatus.UNHEALTHY else: connection["status"] = HealthStatus.DEGRADED - + duration_ms = (time.time() - start_time) * 1000 - + return HealthCheckResult( name=name, status=connection["status"], @@ -405,97 +399,99 @@ async def check_connection(self, name: str) -> HealthCheckResult: details={ "endpoint": connection["endpoint"], "consecutive_failures": connection["consecutive_failures"], - "error_type": type(e).__name__ - } + "error_type": type(e).__name__, + }, ) - + async def get_connection_status(self, name: str) -> Optional[Dict[str, Any]]: """Get status of a specific connection. - + Args: name: Connection name - + Returns: Connection status or None if not found """ async with self._lock: if name not in self._connection_status: return None - + connection = self._connection_status[name] return { "name": name, "endpoint": connection["endpoint"], "status": connection["status"].value, - "last_check": connection["last_check"].isoformat() if connection["last_check"] else None, - "last_success": connection["last_success"].isoformat() if connection["last_success"] else None, - "last_failure": connection["last_failure"].isoformat() if connection["last_failure"] else None, + "last_check": connection["last_check"].isoformat() + if connection["last_check"] + else None, + "last_success": connection["last_success"].isoformat() + if connection["last_success"] + else None, + "last_failure": connection["last_failure"].isoformat() + if connection["last_failure"] + else None, "consecutive_failures": connection["consecutive_failures"], "total_checks": connection["total_checks"], "total_successes": connection["total_successes"], "total_failures": connection["total_failures"], "success_rate": ( connection["total_successes"] / connection["total_checks"] - if connection["total_checks"] > 0 else 0 - ) + if connection["total_checks"] > 0 + else 0 + ), } - + async def get_all_connections_status(self) -> Dict[str, Any]: """Get status of all connections. - + Returns: Status of all connections """ async with self._lock: - result = {} - for name in self._connection_status: - result[name] = await self.get_connection_status(name) - return result + names = list(self._connection_status.keys()) + result = {} + for name in names: + result[name] = await self.get_connection_status(name) + return result class HealthChecker: """Performs comprehensive health checks for the MCP server.""" - + def __init__(self): """Initialize health checker.""" self._health_checks: Dict[str, Callable] = {} self._last_results: Dict[str, HealthCheckResult] = {} self._lock = asyncio.Lock() - + logger.info("Initialized health checker") - + async def register_health_check( - self, - name: str, - check_func: Callable, - description: str = "" + self, name: str, check_func: Callable, description: str = "" ): """Register a health check function. - + Args: name: Health check name check_func: Async function that performs the check description: Description of what the check does """ async with self._lock: - self._health_checks[name] = { - "func": check_func, - "description": description - } - + self._health_checks[name] = {"func": check_func, "description": description} + logger.info(f"Registered health check '{name}': {description}") - + async def run_health_check(self, name: str) -> HealthCheckResult: """Run a specific health check. - + Args: name: Health check name - + Returns: Health check result """ start_time = time.time() - + async with self._lock: if name not in self._health_checks: return HealthCheckResult( @@ -503,15 +499,15 @@ async def run_health_check(self, name: str) -> HealthCheckResult: status=HealthStatus.UNKNOWN, message="Health check not found", timestamp=datetime.utcnow(), - duration_ms=0 + duration_ms=0, ) - + check_info = self._health_checks[name] - + try: # Run the health check await check_info["func"]() - + duration_ms = (time.time() - start_time) * 1000 result = HealthCheckResult( name=name, @@ -519,9 +515,9 @@ async def run_health_check(self, name: str) -> HealthCheckResult: message="Health check passed", timestamp=datetime.utcnow(), duration_ms=duration_ms, - details={"description": check_info["description"]} + details={"description": check_info["description"]}, ) - + except Exception as e: duration_ms = (time.time() - start_time) * 1000 result = HealthCheckResult( @@ -532,31 +528,31 @@ async def run_health_check(self, name: str) -> HealthCheckResult: duration_ms=duration_ms, details={ "description": check_info["description"], - "error_type": type(e).__name__ - } + "error_type": type(e).__name__, + }, ) - + # Store the result async with self._lock: self._last_results[name] = result - + return result - + async def run_all_health_checks(self) -> Dict[str, HealthCheckResult]: """Run all registered health checks. - + Returns: Dictionary of health check results """ results = {} - + async with self._lock: check_names = list(self._health_checks.keys()) - + # Run all checks concurrently tasks = [self.run_health_check(name) for name in check_names] check_results = await asyncio.gather(*tasks, return_exceptions=True) - + for name, result in zip(check_names, check_results): if isinstance(result, Exception): results[name] = HealthCheckResult( @@ -564,32 +560,32 @@ async def run_all_health_checks(self) -> Dict[str, HealthCheckResult]: status=HealthStatus.UNHEALTHY, message=f"Health check error: {str(result)}", timestamp=datetime.utcnow(), - duration_ms=0 + duration_ms=0, ) else: results[name] = result - + return results - + async def get_overall_health(self) -> Dict[str, Any]: """Get overall health status. - + Returns: Overall health summary """ results = await self.run_all_health_checks() - + if not results: return { "status": HealthStatus.UNKNOWN.value, "message": "No health checks configured", "timestamp": datetime.utcnow().isoformat(), - "checks": {} + "checks": {}, } - + # Determine overall status statuses = [result.status for result in results.values()] - + if all(status == HealthStatus.HEALTHY for status in statuses): overall_status = HealthStatus.HEALTHY message = "All health checks passed" @@ -601,7 +597,7 @@ async def get_overall_health(self) -> Dict[str, Any]: overall_status = HealthStatus.DEGRADED degraded_count = sum(1 for s in statuses if s == HealthStatus.DEGRADED) message = f"{degraded_count} health check(s) degraded" - + return { "status": overall_status.value, "message": message, @@ -612,66 +608,66 @@ async def get_overall_health(self) -> Dict[str, Any]: "healthy": sum(1 for s in statuses if s == HealthStatus.HEALTHY), "degraded": sum(1 for s in statuses if s == HealthStatus.DEGRADED), "unhealthy": sum(1 for s in statuses if s == HealthStatus.UNHEALTHY), - "unknown": sum(1 for s in statuses if s == HealthStatus.UNKNOWN) - } + "unknown": sum(1 for s in statuses if s == HealthStatus.UNKNOWN), + }, } class MonitoringManager: """Central manager for all monitoring functionality.""" - + def __init__(self): """Initialize monitoring manager.""" self.metrics_collector = MetricsCollector() self.connection_monitor = ConnectionMonitor() self.health_checker = HealthChecker() - + # Register default health checks asyncio.create_task(self._register_default_health_checks()) - + logger.info("Initialized monitoring manager") - + async def _register_default_health_checks(self): """Register default health checks.""" await self.health_checker.register_health_check( "metrics_collector", self._check_metrics_collector, - "Check if metrics collector is functioning" + "Check if metrics collector is functioning", ) - + await self.health_checker.register_health_check( "connection_monitor", self._check_connection_monitor, - "Check if connection monitor is functioning" + "Check if connection monitor is functioning", ) - + async def _check_metrics_collector(self): """Health check for metrics collector.""" # Try to get metrics summary summary = await self.metrics_collector.get_metrics_summary() if not isinstance(summary, dict): raise SumoLogicError("Metrics collector returned invalid summary") - + async def _check_connection_monitor(self): """Health check for connection monitor.""" # Check if connection monitor is responsive status = await self.connection_monitor.get_all_connections_status() if not isinstance(status, dict): raise SumoLogicError("Connection monitor returned invalid status") - + async def start(self): """Start all monitoring components.""" await self.connection_monitor.start_monitoring() logger.info("Started monitoring manager") - + async def stop(self): """Stop all monitoring components.""" await self.connection_monitor.stop_monitoring() logger.info("Stopped monitoring manager") - + async def get_comprehensive_status(self) -> Dict[str, Any]: """Get comprehensive monitoring status. - + Returns: Complete monitoring status including health, metrics, and connections """ @@ -679,11 +675,11 @@ async def get_comprehensive_status(self) -> Dict[str, Any]: health_task = self.health_checker.get_overall_health() metrics_task = self.metrics_collector.get_metrics_summary() connections_task = self.connection_monitor.get_all_connections_status() - + health_status, metrics_summary, connections_status = await asyncio.gather( health_task, metrics_task, connections_task ) - + return { "timestamp": datetime.utcnow().isoformat(), "health": health_status, @@ -692,9 +688,9 @@ async def get_comprehensive_status(self) -> Dict[str, Any]: "monitoring": { "metrics_collector_active": True, "connection_monitor_active": ( - self.connection_monitor._monitoring_task is not None and - not self.connection_monitor._monitoring_task.done() + self.connection_monitor._monitoring_task is not None + and not self.connection_monitor._monitoring_task.done() ), - "health_checker_active": True - } - } \ No newline at end of file + "health_checker_active": True, + }, + }