From fcd2c9f1e118c63169734b6387e9ec412c7780db Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 8 May 2026 20:59:48 +0000 Subject: [PATCH] Auto-generated library v4.1.0b0 for API v1.70.0-beta.0. --- docs/generation-report.md | 8 + meraki/__init__.py | 4 +- meraki/_version.py | 2 +- meraki/aio/__init__.py | 2 +- meraki/aio/api/camera.py | 2 +- meraki/api/camera.py | 2 +- meraki/config.py | 1 - meraki/encoding.py | 71 +---- meraki/exceptions.py | 51 +--- meraki/session/__init__.py | 8 +- meraki/session/async_.py | 546 +------------------------------------ meraki/session/base.py | 405 +-------------------------- meraki/session/sync.py | 313 +-------------------- pyproject.toml | 2 +- uv.lock | 2 +- 15 files changed, 35 insertions(+), 1384 deletions(-) create mode 100644 docs/generation-report.md diff --git a/docs/generation-report.md b/docs/generation-report.md new file mode 100644 index 0000000..18b6b57 --- /dev/null +++ b/docs/generation-report.md @@ -0,0 +1,8 @@ +# Generation Report + +## 2026-05-08 | Library v4.1.0b0 | API 1.70.0-beta.0 + + +No Python keyword parameter conflicts detected. + + diff --git a/meraki/__init__.py b/meraki/__init__.py index 8da989c..aafca11 100644 --- a/meraki/__init__.py +++ b/meraki/__init__.py @@ -47,12 +47,12 @@ USE_ITERATOR_FOR_GET_PAGES, VALIDATE_KWARGS, ) -from meraki.session.sync import RestSession +from meraki.rest_session import RestSession from meraki.exceptions import APIError, APIKeyError, APIResponseError, AsyncAPIError from meraki._version import __version__ # noqa: F401 from datetime import datetime -__api_version__ = "1.69.0" +__api_version__ = "1.70.0-beta.0" __all__ = [ "APIError", diff --git a/meraki/_version.py b/meraki/_version.py index 8bc3e3c..b672be1 100644 --- a/meraki/_version.py +++ b/meraki/_version.py @@ -1 +1 @@ -__version__ = "4.0.0b1" +__version__ = "4.1.0b0" diff --git a/meraki/aio/__init__.py b/meraki/aio/__init__.py index d88f3e1..6e8ed15 100644 --- a/meraki/aio/__init__.py +++ b/meraki/aio/__init__.py @@ -17,7 +17,7 @@ from meraki.aio.api.switch import AsyncSwitch from meraki.aio.api.wireless import AsyncWireless from meraki.aio.api.wirelessController import AsyncWirelessController -from meraki.session.async_ import AsyncRestSession +from meraki.aio.rest_session import AsyncRestSession from meraki.exceptions import APIKeyError from datetime import datetime diff --git a/meraki/aio/api/camera.py b/meraki/aio/api/camera.py index 14e3184..98e5c8a 100644 --- a/meraki/aio/api/camera.py +++ b/meraki/aio/api/camera.py @@ -175,7 +175,7 @@ def clipDeviceCamera(self, serial: str, startTimestamp: str, endTimestamp: str, - serial (string): Serial - startTimestamp (string): The start time for the clip. The timestamp is expected to be in ISO 8601 format. - endTimestamp (string): The end time for the clip. The timestamp is expected to be in ISO 8601 format. - - imagerId (integer): For multi-imager cameras, the imager ID to query. Defaults to '1' if omitted. + - imagerId (integer): The imager ID to query. Required for multi-imager cameras (must be between 1 and the imager count). For single-imager cameras, must be omitted or set to 0. """ kwargs.update(locals()) diff --git a/meraki/api/camera.py b/meraki/api/camera.py index 2d19e19..2f83425 100644 --- a/meraki/api/camera.py +++ b/meraki/api/camera.py @@ -175,7 +175,7 @@ def clipDeviceCamera(self, serial: str, startTimestamp: str, endTimestamp: str, - serial (string): Serial - startTimestamp (string): The start time for the clip. The timestamp is expected to be in ISO 8601 format. - endTimestamp (string): The end time for the clip. The timestamp is expected to be in ISO 8601 format. - - imagerId (integer): For multi-imager cameras, the imager ID to query. Defaults to '1' if omitted. + - imagerId (integer): The imager ID to query. Required for multi-imager cameras (must be between 1 and the imager count). For single-imager cameras, must be omitted or set to 0. """ kwargs.update(locals()) diff --git a/meraki/config.py b/meraki/config.py index ba311de..0dc893a 100644 --- a/meraki/config.py +++ b/meraki/config.py @@ -69,7 +69,6 @@ SIMULATE_API_CALLS = False # Number of concurrent API requests for asynchronous class -# Maps to httpx.Limits(max_connections=N) in AsyncRestSession AIO_MAXIMUM_CONCURRENT_REQUESTS = 8 # Legacy partner identifier for API usage tracking; can also be set as an environment variable BE_GEO_ID diff --git a/meraki/encoding.py b/meraki/encoding.py index 13a376e..1becba2 100644 --- a/meraki/encoding.py +++ b/meraki/encoding.py @@ -1,70 +1 @@ -"""Meraki-specific parameter encoding using only stdlib. - -This module provides encode_meraki_params(), a pure function replacement for -the monkey-patched requests._encode_params in rest_session.py. Uses only -urllib.parse (no requests dependency). See HTTP-04. -""" - -from urllib.parse import urlencode - - -def encode_meraki_params(data): - """Encode parameters for Meraki API requests. - - Supports: - - str/bytes: passthrough - - file-like (has .read): passthrough - - dict: URL encode with array-of-objects support - - list of 2-tuples: URL encode with array-of-objects support - - other: passthrough - - Array-of-objects encoding (Meraki-specific): - {"param[]": [{"key1": "val1"}]} -> "param%5B%5Dkey1=val1" - - Args: - data: Parameters to encode. Dict, list of tuples, str, bytes, - file-like object, or any other type (passthrough). - - Returns: - URL-encoded string for dict/list inputs, original value for passthrough types. - """ - if isinstance(data, (str, bytes)): - return data - elif hasattr(data, "read"): - return data - elif hasattr(data, "__iter__"): - result = [] - - # Convert to key-val list (stdlib replacement for requests.utils.to_key_val_list) - if hasattr(data, "items"): - items = list(data.items()) - else: - items = list(data) - - for k, vs in items: - # Normalize scalar to list - if isinstance(vs, str) or not hasattr(vs, "__iter__"): - vs = [vs] - - for v in vs: - if v is not None and not isinstance(v, dict): - # Simple key-value pair - result.append( - ( - k.encode("utf-8") if isinstance(k, str) else k, - v.encode("utf-8") if isinstance(v, str) else v, - ) - ) - elif v is not None: - # Array-of-objects: concatenate dict keys to param name - for k_inner, v_inner in v.items(): - result.append( - ( - (k + k_inner).encode("utf-8") if isinstance(k, str) else k_inner, - v_inner.encode("utf-8") if isinstance(v_inner, str) else v_inner, - ) - ) - - return urlencode(result, doseq=True) - else: - return data +404: Not Found \ No newline at end of file diff --git a/meraki/exceptions.py b/meraki/exceptions.py index 84a5b82..3b2a3cc 100644 --- a/meraki/exceptions.py +++ b/meraki/exceptions.py @@ -39,9 +39,7 @@ def __init__(self, metadata, response): self.tag = metadata["tags"][0] self.operation = metadata["operation"] self.status = self.response.status_code if self.response is not None and self.response.status_code else None - self.reason = ( - self.response.reason_phrase if self.response is not None and hasattr(self.response, "reason_phrase") else None - ) + self.reason = self.response.reason if self.response is not None and self.response.reason else None try: self.message = self.response.json() if self.response is not None and self.response.json() else None except ValueError: @@ -55,41 +53,20 @@ def __repr__(self): # To catch exceptions while making AIO API calls -class AsyncAPIError(APIError): - """Deprecated: Use APIError for both sync and async exceptions. - - This exception is deprecated as of version 4.0. Catch APIError instead, - which now handles both synchronous and asynchronous errors. - - Existing code using ``except AsyncAPIError:`` will continue to work - because AsyncAPIError is now a subclass of APIError. - """ - - def __init__(self, metadata, response, message=None): - import warnings - - warnings.warn( - "AsyncAPIError is deprecated. Catch APIError instead, which now handles both sync and async errors.", - DeprecationWarning, - stacklevel=2, - ) +class AsyncAPIError(Exception): + def __init__(self, metadata, response, message): + self.response = response + self.tag = metadata["tags"][0] + self.operation = metadata["operation"] + self.status = response.status if response is not None and response.status else None + self.reason = response.reason if response is not None and response.reason else None + self.message = message + if isinstance(self.message, str): + self.message = self.message.strip() + if self.status == 404 and self.reason == "Not Found": + self.message += "please wait a minute if the key or org was just newly created." - if message is not None: - # Old 3-arg form: replicate original AsyncAPIError logic - self.response = response - self.tag = metadata["tags"][0] - self.operation = metadata["operation"] - self.status = response.status_code if response is not None and hasattr(response, "status_code") else None - self.reason = response.reason_phrase if response is not None and hasattr(response, "reason_phrase") else None - self.message = message - if isinstance(self.message, str): - self.message = self.message.strip() - if self.status == 404 and self.reason == "Not Found": - self.message += "please wait a minute if the key or org was just newly created." - Exception.__init__(self, f"{self.tag}, {self.operation} - {self.status} {self.reason}, {self.message}") - else: - # New 2-arg form: delegate to APIError - super().__init__(metadata, response) + super().__init__(f"{self.tag}, {self.operation} - {self.status} {self.reason}, {self.message}") def __repr__(self): return f"{self.tag}, {self.operation} - {self.status} {self.reason}, {self.message}" diff --git a/meraki/session/__init__.py b/meraki/session/__init__.py index be17338..1becba2 100644 --- a/meraki/session/__init__.py +++ b/meraki/session/__init__.py @@ -1,7 +1 @@ -"""Session implementations for Meraki Dashboard API.""" - -from meraki.session.base import SessionBase -from meraki.session.sync import RestSession -from meraki.session.async_ import AsyncRestSession - -__all__ = ["SessionBase", "RestSession", "AsyncRestSession"] +404: Not Found \ No newline at end of file diff --git a/meraki/session/async_.py b/meraki/session/async_.py index 730d499..1becba2 100644 --- a/meraki/session/async_.py +++ b/meraki/session/async_.py @@ -1,545 +1 @@ -"""Asynchronous REST session for Meraki Dashboard API.""" - -from __future__ import annotations - -import asyncio -import json -import random -import urllib.parse -from datetime import datetime, timezone -from typing import Any, Dict, Optional - -import httpx - -from meraki.common import validate_base_url, validate_user_agent -from meraki.config import AIO_MAXIMUM_CONCURRENT_REQUESTS -from meraki.exceptions import APIError, SessionInputError -from meraki.session.base import SessionBase - - -class AsyncRestSession(SessionBase): - """Asynchronous session using httpx.AsyncClient. - - Inherits config storage from SessionBase. - Overrides request() as async with await on _send_request/_sleep. - Uses httpx.Limits for concurrency control (replaces asyncio.Semaphore per D-02). - """ - - def __init__( - self, - logger, - api_key, - maximum_concurrent_requests: int = AIO_MAXIMUM_CONCURRENT_REQUESTS, - **kwargs: Any, - ) -> None: - super().__init__(logger, api_key, **kwargs) - - # Build headers dict - headers = self._build_headers() - # Async user-agent prefix - headers["User-Agent"] = f"python-meraki/aio-{self._version} " + validate_user_agent(self._be_geo_id, self._caller) - - # Build client config (per D-02: Limits replaces Semaphore, per D-06: proxy passthrough) - client_kwargs: Dict[str, Any] = { - "timeout": self._single_request_timeout, - "limits": httpx.Limits(max_connections=maximum_concurrent_requests), - "headers": headers, - } - if self._certificate_path: - client_kwargs["verify"] = self._certificate_path - if self._requests_proxy: - client_kwargs["proxy"] = self._requests_proxy - - # Persistent async client with connection pooling - self._client = httpx.AsyncClient(**client_kwargs) - - # Trigger the property setter to bind the correct get_pages implementation - self.use_iterator_for_get_pages = self._use_iterator_for_get_pages - - @property - def use_iterator_for_get_pages(self): - return self._use_iterator_for_get_pages - - @use_iterator_for_get_pages.setter - def use_iterator_for_get_pages(self, value): - if value: - self.get_pages = self._get_pages_iterator - else: - self.get_pages = self._get_pages_legacy - self._use_iterator_for_get_pages = value - - # ------------------------------------------------------------------ - # Abstract method implementations - # ------------------------------------------------------------------ - - async def _send_request(self, method: str, url: str, **kwargs: Any) -> httpx.Response: - """Send HTTP request via httpx.AsyncClient (pool limits enforce concurrency per D-02).""" - response = await self._client.request(method, url, follow_redirects=False, **kwargs) - return response - - async def _sleep(self, seconds: float) -> None: - """Async sleep for retry delays.""" - await asyncio.sleep(seconds) - - def _transport_kwargs(self, kwargs: Dict[str, Any]) -> Dict[str, Any]: - """No-op: httpx config handled at client initialization level.""" - return kwargs - - # ------------------------------------------------------------------ - # Async request override (awaits abstract methods) - # ------------------------------------------------------------------ - - async def request(self, metadata: Dict[str, Any], method: str, url: str, **kwargs: Any) -> Optional[httpx.Response]: - """Execute an API request with retry loop and status dispatch (async version). - - Mirrors SessionBase.request() but awaits _send_request and _sleep. - """ - tag = metadata["tags"][0] - operation = metadata["operation"] - - # Prepare transport-specific kwargs - kwargs = self._transport_kwargs(kwargs) - - # aiohttp manipulates URLs as instances of yarl.URL - if not isinstance(url, str): - url = str(url) - - # Resolve absolute URL - abs_url = validate_base_url(self, url) - - # Simulate non-GET calls - if self._logger: - self._logger.debug(metadata) - if self._simulate and method != "GET": - if self._logger: - self._logger.info(f"{tag}, {operation} - SIMULATED") - return None - - retries = self._maximum_retries - response: Optional[httpx.Response] = None - - while retries > 0: - # Attempt the request - try: - if response: - await response.aclose() - if self._logger: - self._logger.info(f"{method} {abs_url}") - response = await self._send_request(method, abs_url, **kwargs) - except httpx.HTTPError as e: - if self._logger: - self._logger.warning(f"{tag}, {operation} - {e}, retrying in 1 second") - await self._sleep(1) - retries -= 1 - if retries == 0: - raise APIError( - metadata, - type( - "FakeResponse", - (), - {"status_code": 503, "reason_phrase": str(e), "json": lambda self: {}, "content": b""}, - )(), - ) - continue - - status = response.status_code - reason = response.reason_phrase if response.reason_phrase else "" - - # Dispatch by status code - if 300 <= status < 400: - abs_url = self._handle_redirect_async(response) - elif 200 <= status < 300: - result = await self._handle_success_async(response, metadata, method) - if result is None: - # JSON decode failure, retry - retries -= 1 - if retries == 0: - raise APIError(metadata, response) - await self._sleep(1) - continue - return result - elif status == 429: - wait = self._handle_rate_limit_async(response, metadata, retries) - await self._sleep(wait) - retries -= 1 - if retries == 0: - raise APIError(metadata, response) - elif status >= 500: - if self._logger: - self._logger.warning(f"{tag}, {operation} - {status} {reason}, retrying in 1 second") - await self._sleep(1) - retries -= 1 - if retries == 0: - raise APIError(metadata, response) - elif 400 <= status < 500: - retries = await self._handle_client_error_async(response, metadata, retries) - - return response - - # ------------------------------------------------------------------ - # Async status handlers - # ------------------------------------------------------------------ - - async def _handle_success_async( - self, - response: Any, - metadata: Dict[str, Any], - method: str, - ) -> Optional[Any]: - """Handle 2xx responses (async). Returns response or None if JSON validation fails.""" - tag = metadata["tags"][0] - operation = metadata["operation"] - reason = response.reason_phrase if response.reason_phrase else "" - status = response.status_code - - if "page" in metadata: - counter = metadata["page"] - if self._logger: - self._logger.info(f"{tag}, {operation}; page {counter} - {status} {reason}") - else: - if self._logger: - self._logger.info(f"{tag}, {operation} - {status} {reason}") - - # For non-empty GET responses, validate JSON - try: - if method == "GET" and response.content.strip(): - response.json() - return response - except (json.decoder.JSONDecodeError, ValueError): - if self._logger: - self._logger.warning(f"{tag}, {operation} - JSON decode error, retrying in 1 second") - return None - - def _handle_redirect_async(self, response: Any) -> str: - """Handle 3xx redirects for aiohttp responses.""" - abs_url = str(response.headers["Location"]) - substring = "meraki.com/api/v" - if substring not in abs_url: - substring = "meraki.cn/api/v" - if substring in abs_url: - self._base_url = abs_url[: abs_url.find(substring) + len(substring) + 1] - return abs_url - - def _handle_rate_limit_async( - self, - response: Any, - metadata: Dict[str, Any], - retries: int, - ) -> float: - """Handle 429 rate limiting (async). Returns seconds to wait.""" - tag = metadata["tags"][0] - operation = metadata["operation"] - reason = response.reason_phrase if response.reason_phrase else "" - status = response.status_code - - if not self._wait_on_rate_limit or retries <= 0: - raise APIError(metadata, response) - - if "Retry-After" in response.headers: - wait = int(response.headers["Retry-After"]) - else: - attempt = self._maximum_retries - retries - wait = min( - (2**attempt) * (1 + random.random()), - self._nginx_429_retry_wait_time, - ) - - if self._logger: - self._logger.warning(f"{tag}, {operation} - {status} {reason}, retrying in {wait} seconds") - return wait - - async def _handle_client_error_async( - self, - response: Any, - metadata: Dict[str, Any], - retries: int, - ) -> int: - """Handle 4xx client errors (async). Returns updated retry count.""" - tag = metadata["tags"][0] - operation = metadata["operation"] - reason = response.reason_phrase if response.reason_phrase else "" - status = response.status_code - - # Parse response body - try: - message = response.json() - message_is_dict = isinstance(message, dict) - except (json.decoder.JSONDecodeError, ValueError): - message_is_dict = False - try: - message = response.text[:100] - except Exception: - message = None - - # Network delete concurrency error - if ( - metadata.get("operation") == "deleteNetwork" - and status == 400 - and message_is_dict - and "errors" in message - and "concurrent" in str(message["errors"][0]) - ): - wait = random.randint(30, self._network_delete_retry_wait_time) - if self._logger: - self._logger.warning(f"{tag}, {operation} - {status} {reason}, retrying in {wait} seconds") - await self._sleep(wait) - retries -= 1 - if retries == 0: - raise APIError(metadata, response) - return retries - - # Action batch concurrency error - if message_is_dict and "errors" in message and "executing batches" in str(message["errors"][0]).lower(): - wait = self._action_batch_retry_wait_time - if self._logger: - self._logger.warning(f"{tag}, {operation} - {status} {reason}, retrying in {wait} seconds") - await self._sleep(wait) - retries -= 1 - if retries == 0: - raise APIError(metadata, response) - return retries - - # Retry other 4xx if configured - if self._retry_4xx_error: - wait = random.randint(1, self._retry_4xx_error_wait_time) - if self._logger: - self._logger.warning(f"{tag}, {operation} - {status} {reason}, retrying in {wait} seconds") - await self._sleep(wait) - retries -= 1 - if retries == 0: - raise APIError(metadata, response) - return retries - - # Non-retryable client error - if self._logger: - self._logger.error(f"{tag}, {operation} - {status} {reason}, {message}") - raise APIError(metadata, response) - - # ------------------------------------------------------------------ - # Convenience HTTP methods - # ------------------------------------------------------------------ - - async def get(self, metadata, url, params=None): - metadata["method"] = "GET" - metadata["url"] = url - metadata["params"] = params - response = await self.request(metadata, "GET", url, params=params) - if response: - if response.content.strip(): - return response.json() - return None - - async def get_pages(self, metadata, url, params=None, total_pages=-1, direction="next", event_log_end_time=None): - pass - - async def _download_page(self, request): - response = await request - result = response.json() - return response, result - - async def _get_pages_iterator( - self, - metadata, - url, - params=None, - total_pages=-1, - direction="next", - event_log_end_time=None, - ): - if isinstance(total_pages, str) and total_pages.lower() == "all": - total_pages = -1 - elif isinstance(total_pages, str) and total_pages.isnumeric(): - total_pages = int(total_pages) - elif not isinstance(total_pages, int): - raise SessionInputError( - "total_pages", - total_pages, - "total_pages must be either an integer or 'all' as a string (remember to add the quotation marks).", - None, - ) - metadata["page"] = 1 - - request_task = asyncio.create_task(self._download_page(self.request(metadata, "GET", url, params=params))) - - # Get additional pages if more than one requested - while total_pages != 0: - response, results = await request_task - links = response.links - - # GET the subsequent page - if direction == "next" and "next" in links: - # Prevent getNetworkEvents from infinite loop as time goes forward - if metadata["operation"] == "getNetworkEvents": - starting_after = urllib.parse.unquote(str(links["next"]["url"]).split("startingAfter=")[1]) - delta = datetime.now(timezone.utc) - datetime.fromisoformat(starting_after) - # Break out of loop if startingAfter returned from next link is within 5 minutes of current time - if delta.total_seconds() < 300: - break - # Or if next page is past the specified window's end time - elif event_log_end_time and starting_after > event_log_end_time: - break - - metadata["page"] += 1 - nextlink = links["next"]["url"] - elif direction == "prev" and "prev" in links: - # Prevent getNetworkEvents from infinite loop as time goes backward (to epoch 0) - if metadata["operation"] == "getNetworkEvents": - ending_before = urllib.parse.unquote(str(links["prev"]["url"]).split("endingBefore=")[1]) - # Break out of loop if endingBefore returned from prev link is before 2014 - if ending_before < "2014-01-01": - break - - metadata["page"] += 1 - nextlink = links["prev"]["url"] - else: - total_pages = 1 - - await response.aclose() - - total_pages = total_pages - 1 - - if total_pages != 0: - request_task = asyncio.create_task(self._download_page(self.request(metadata, "GET", nextlink))) - - return_items = [] - # just prepare the list - if isinstance(results, list): - return_items = results - elif isinstance(results, dict) and "items" in results: - return_items = results["items"] - # For event log endpoint - elif isinstance(results, dict): - if direction == "next": - return_items = results["events"][::-1] - else: - return_items = results["events"] - - for item in return_items: - yield item - - async def _get_pages_legacy( - self, - metadata, - url, - params=None, - total_pages=-1, - direction="next", - event_log_end_time=None, - ): - if isinstance(total_pages, str) and total_pages.lower() == "all": - total_pages = -1 - elif isinstance(total_pages, str) and total_pages.isnumeric(): - total_pages = int(total_pages) - elif not isinstance(total_pages, int): - raise SessionInputError( - "total_pages", - total_pages, - "total_pages must be either an integer or 'all' as a string (remember to add the quotation marks).", - None, - ) - metadata["page"] = 1 - - response = await self.request(metadata, "GET", url, params=params) - - if response.status_code == 204: - results = None - else: - results = response.json() - - # For event log endpoint when using 'next' direction - if isinstance(results, dict) and metadata["operation"] == "getNetworkEvents" and direction == "next": - results["events"] = results["events"][::-1] - - links = response.links - await response.aclose() - - # Get additional pages if more than one requested - while total_pages != 1: - # GET the subsequent page - if direction == "next" and "next" in links: - # Prevent getNetworkEvents from infinite loop as time goes forward - if metadata["operation"] == "getNetworkEvents": - starting_after = urllib.parse.unquote(str(links["next"]["url"]).split("startingAfter=")[1]) - delta = datetime.now(timezone.utc) - datetime.fromisoformat(starting_after) - if delta.total_seconds() < 300: - break - elif event_log_end_time and starting_after > event_log_end_time: - break - - metadata["page"] += 1 - nextlink = links["next"]["url"] - elif direction == "prev" and "prev" in links: - if metadata["operation"] == "getNetworkEvents": - ending_before = urllib.parse.unquote(str(links["prev"]["url"]).split("endingBefore=")[1]) - if ending_before < "2014-01-01": - break - - metadata["page"] += 1 - nextlink = links["prev"]["url"] - else: - break - - response = await self.request(metadata, "GET", nextlink) - links = response.links - if isinstance(results, list): - results.extend(response.json()) - elif isinstance(results, dict) and "items" in results: - json_response = response.json() - results["items"].extend(json_response["items"]) - if "meta" in results: - results["meta"]["counts"]["items"]["remaining"] = json_response["meta"]["counts"]["items"]["remaining"] - # For event log endpoint - elif isinstance(results, dict): - json_response = response.json() - start = json_response["pageStartAt"] - end = json_response["pageEndAt"] - events = json_response["events"] - if direction == "next": - events = events[::-1] - if start < results["pageStartAt"]: - results["pageStartAt"] = start - if end > results["pageEndAt"]: - results["pageEndAt"] = end - results["events"].extend(events) - - await response.aclose() - total_pages = total_pages - 1 - - return results - - async def post(self, metadata, url, json=None): - metadata["method"] = "POST" - metadata["url"] = url - metadata["json"] = json - response = await self.request(metadata, "POST", url, json=json) - if response: - if response.content.strip(): - return response.json() - return None - - async def put(self, metadata, url, json=None): - metadata["method"] = "PUT" - metadata["url"] = url - metadata["json"] = json - response = await self.request(metadata, "PUT", url, json=json) - if response: - if response.content.strip(): - return response.json() - return None - - async def delete(self, metadata, url, params=None): - metadata["method"] = "DELETE" - metadata["url"] = url - metadata["params"] = params - await self.request(metadata, "DELETE", url, params=params) - return None - - async def close(self): - """Close the underlying httpx.AsyncClient and release connections.""" - await self._client.aclose() - - async def __aenter__(self): - return self - - async def __aexit__(self, *args): - await self.close() +404: Not Found \ No newline at end of file diff --git a/meraki/session/base.py b/meraki/session/base.py index 67852c7..1becba2 100644 --- a/meraki/session/base.py +++ b/meraki/session/base.py @@ -1,404 +1 @@ -"""Abstract base class for sync and async Meraki API sessions.""" - -from __future__ import annotations - -import json -import random -from abc import ABC, abstractmethod -from typing import Any, Dict, Optional - -from meraki._version import __version__ -from meraki.common import ( - check_python_version, - reject_v0_base_url, - validate_base_url, - validate_user_agent, -) -from meraki.config import ( - ACTION_BATCH_RETRY_WAIT_TIME, - BE_GEO_ID, - CERTIFICATE_PATH, - DEFAULT_BASE_URL, - MAXIMUM_RETRIES, - MERAKI_PYTHON_SDK_CALLER, - NETWORK_DELETE_RETRY_WAIT_TIME, - NGINX_429_RETRY_WAIT_TIME, - REQUESTS_PROXY, - RETRY_4XX_ERROR, - RETRY_4XX_ERROR_WAIT_TIME, - SIMULATE_API_CALLS, - SINGLE_REQUEST_TIMEOUT, - USE_ITERATOR_FOR_GET_PAGES, - WAIT_ON_RATE_LIMIT, -) -import httpx - -from meraki.exceptions import APIError, APIResponseError -from meraki.response_handler import handle_3xx - - -class SessionBase(ABC): - """Abstract base class providing config storage, URL resolution, retry loop, and status dispatch. - - Subclasses must implement: - _send_request: perform the actual HTTP call - _sleep: pause execution (sync or async) - _transport_kwargs: prepare transport-specific request kwargs - """ - - def __init__( - self, - logger: Any, - api_key: str, - base_url: str = DEFAULT_BASE_URL, - single_request_timeout: int = SINGLE_REQUEST_TIMEOUT, - certificate_path: str = CERTIFICATE_PATH, - requests_proxy: str = REQUESTS_PROXY, - wait_on_rate_limit: bool = WAIT_ON_RATE_LIMIT, - nginx_429_retry_wait_time: int = NGINX_429_RETRY_WAIT_TIME, - action_batch_retry_wait_time: int = ACTION_BATCH_RETRY_WAIT_TIME, - network_delete_retry_wait_time: int = NETWORK_DELETE_RETRY_WAIT_TIME, - retry_4xx_error: bool = RETRY_4XX_ERROR, - retry_4xx_error_wait_time: int = RETRY_4XX_ERROR_WAIT_TIME, - maximum_retries: int = MAXIMUM_RETRIES, - simulate: bool = SIMULATE_API_CALLS, - be_geo_id: str = BE_GEO_ID, - caller: str = MERAKI_PYTHON_SDK_CALLER, - use_iterator_for_get_pages: bool = USE_ITERATOR_FOR_GET_PAGES, - validate_kwargs: bool = False, - ) -> None: - super().__init__() - - # Store config attributes - self._version = __version__ - self._api_key = str(api_key) - self._base_url = str(base_url) - self._single_request_timeout = single_request_timeout - self._certificate_path = certificate_path - self._requests_proxy = requests_proxy - self._wait_on_rate_limit = wait_on_rate_limit - self._nginx_429_retry_wait_time = nginx_429_retry_wait_time - self._action_batch_retry_wait_time = action_batch_retry_wait_time - self._network_delete_retry_wait_time = network_delete_retry_wait_time - self._retry_4xx_error = retry_4xx_error - self._retry_4xx_error_wait_time = retry_4xx_error_wait_time - self._maximum_retries = maximum_retries - self._simulate = simulate - self._be_geo_id = be_geo_id - self._caller = caller - self._use_iterator_for_get_pages = use_iterator_for_get_pages - self._validate_kwargs = validate_kwargs - - # Check Python version - check_python_version() - - # Reject v0 base URL - reject_v0_base_url(self) - - # Logger and masked parameters for logging - self._logger = logger - self._parameters: Dict[str, Any] = {"version": self._version} - self._parameters["api_key"] = "*" * 36 + self._api_key[-4:] - self._parameters["base_url"] = self._base_url - self._parameters["single_request_timeout"] = self._single_request_timeout - self._parameters["certificate_path"] = self._certificate_path - self._parameters["requests_proxy"] = self._requests_proxy - self._parameters["wait_on_rate_limit"] = self._wait_on_rate_limit - self._parameters["nginx_429_retry_wait_time"] = self._nginx_429_retry_wait_time - self._parameters["action_batch_retry_wait_time"] = self._action_batch_retry_wait_time - self._parameters["network_delete_retry_wait_time"] = self._network_delete_retry_wait_time - self._parameters["retry_4xx_error"] = self._retry_4xx_error - self._parameters["retry_4xx_error_wait_time"] = self._retry_4xx_error_wait_time - self._parameters["maximum_retries"] = self._maximum_retries - self._parameters["simulate"] = self._simulate - self._parameters["be_geo_id"] = self._be_geo_id - self._parameters["caller"] = self._caller - self._parameters["use_iterator_for_get_pages"] = self._use_iterator_for_get_pages - - if self._logger: - self._logger.info(f"Meraki dashboard API session initialized with these parameters: {self._parameters}") - - # ------------------------------------------------------------------ - # Abstract methods (subclass contract) - # ------------------------------------------------------------------ - - @abstractmethod - def _send_request(self, method: str, url: str, **kwargs: Any) -> "httpx.Response": - """Send the HTTP request. Implemented by sync/async subclasses.""" - ... - - @abstractmethod - def _sleep(self, seconds: float) -> None: - """Sleep for the given duration. Sync uses time.sleep, async uses asyncio.sleep.""" - ... - - @abstractmethod - def _transport_kwargs(self, kwargs: Dict[str, Any]) -> Dict[str, Any]: - """Prepare transport-specific kwargs (verify, proxy, timeout, etc.).""" - ... - - # ------------------------------------------------------------------ - # Template method: request - # ------------------------------------------------------------------ - - def request(self, metadata: Dict[str, Any], method: str, url: str, **kwargs: Any) -> Optional["httpx.Response"]: - """Execute an API request with retry loop and status dispatch. - - Args: - metadata: Endpoint metadata (tags, operation, optional page counter). - method: HTTP method (GET, POST, PUT, DELETE). - url: Endpoint URL (relative or absolute). - **kwargs: Additional request kwargs (json, params, etc.). - - Returns: - httpx.Response on success, or None if simulated. - """ - tag = metadata["tags"][0] - operation = metadata["operation"] - - # Prepare transport-specific kwargs - kwargs = self._transport_kwargs(kwargs) - - # Resolve absolute URL - abs_url = validate_base_url(self, url) - - # Simulate non-GET calls - if self._logger: - self._logger.debug(metadata) - if self._simulate and method != "GET": - if self._logger: - self._logger.info(f"{tag}, {operation} - SIMULATED") - return None - - retries = self._maximum_retries - response: Optional["httpx.Response"] = None - - while retries > 0: - # Attempt the request - try: - if self._logger: - self._logger.info(f"{method} {abs_url}") - response = self._send_request(method, abs_url, **kwargs) - except httpx.HTTPError as e: - if self._logger: - self._logger.warning(f"{tag}, {operation} - {e}, retrying in 1 second") - self._sleep(1) - retries -= 1 - if retries == 0: - raise APIError( - metadata, - APIResponseError(e.__class__.__name__, 503, str(e)), - ) - continue - - status = response.status_code - - # Dispatch by status code - if 300 <= status < 400: - abs_url = self._handle_redirect(response) - elif 200 <= status < 300: - result = self._handle_success(response, metadata, method, retries) - if result is None: - # JSON decode failure, retry - retries -= 1 - if retries == 0: - raise APIError(metadata, response) - self._sleep(1) - continue - return result - elif status == 429: - wait = self._handle_rate_limit(response, metadata, retries) - self._sleep(wait) - retries -= 1 - if retries == 0: - raise APIError(metadata, response) - elif status >= 500: - self._handle_server_error(response, metadata) - self._sleep(1) - retries -= 1 - if retries == 0: - raise APIError(metadata, response) - elif 400 <= status < 500: - retries = self._handle_client_error(response, metadata, retries) - - return response - - # ------------------------------------------------------------------ - # Status handlers (each kept under cyclomatic complexity 10) - # ------------------------------------------------------------------ - - def _handle_success( - self, - response: "httpx.Response", - metadata: Dict[str, Any], - method: str, - retries: int, - ) -> Optional["httpx.Response"]: - """Handle 2xx responses. Returns response or None if JSON validation fails.""" - tag = metadata["tags"][0] - operation = metadata["operation"] - reason = response.reason_phrase if hasattr(response, "reason_phrase") else "" - status = response.status_code - - if "page" in metadata: - counter = metadata["page"] - if self._logger: - self._logger.info(f"{tag}, {operation}; page {counter} - {status} {reason}") - else: - if self._logger: - self._logger.info(f"{tag}, {operation} - {status} {reason}") - - # For non-empty GET responses, validate JSON - try: - if method == "GET" and response.content.strip(): - response.json() - return response - except (json.decoder.JSONDecodeError, ValueError): - if self._logger: - self._logger.warning(f"{tag}, {operation} - JSON decode error, retrying in 1 second") - return None - - def _handle_redirect(self, response: "httpx.Response") -> str: - """Handle 3xx redirects. Returns the new absolute URL.""" - return handle_3xx(self, response) - - def _handle_rate_limit( - self, - response: "httpx.Response", - metadata: Dict[str, Any], - retries: int, - ) -> float: - """Handle 429 rate limiting. Returns seconds to wait. - - Raises APIError if rate limit retries disabled or retries exhausted. - """ - tag = metadata["tags"][0] - operation = metadata["operation"] - reason = response.reason_phrase if hasattr(response, "reason_phrase") else "" - status = response.status_code - - if not self._wait_on_rate_limit or retries <= 0: - raise APIError(metadata, response) - - if "Retry-After" in response.headers: - wait = int(response.headers["Retry-After"]) - else: - attempt = self._maximum_retries - retries - wait = min( - (2**attempt) * (1 + random.random()), - self._nginx_429_retry_wait_time, - ) - - if self._logger: - self._logger.warning(f"{tag}, {operation} - {status} {reason}, retrying in {wait} seconds") - return wait - - def _handle_server_error(self, response: "httpx.Response", metadata: Dict[str, Any]) -> None: - """Handle 5xx server errors. Logs warning before retry.""" - tag = metadata["tags"][0] - operation = metadata["operation"] - reason = response.reason_phrase if hasattr(response, "reason_phrase") else "" - status = response.status_code - - if self._logger: - self._logger.warning(f"{tag}, {operation} - {status} {reason}, retrying in 1 second") - - def _handle_client_error( - self, - response: "httpx.Response", - metadata: Dict[str, Any], - retries: int, - ) -> int: - """Handle 4xx client errors. Returns updated retry count. - - Raises APIError if error is not retryable or retries exhausted. - """ - # Parse response body - try: - message = response.json() - except (ValueError, json.decoder.JSONDecodeError): - message = response.content[:100] - - # Determine wait time based on error type - wait = self._classify_client_error_wait(metadata, response, message) - - if wait is not None: - return self._retry_with_wait(wait, metadata, response, retries) - - # Non-retryable client error - tag = metadata["tags"][0] - operation = metadata["operation"] - reason = response.reason_phrase if hasattr(response, "reason_phrase") else "" - status = response.status_code - if self._logger: - self._logger.error(f"{tag}, {operation} - {status} {reason}, {message}") - raise APIError(metadata, response) - - # ------------------------------------------------------------------ - # Helper methods - # ------------------------------------------------------------------ - - def _classify_client_error_wait( - self, - metadata: Dict[str, Any], - response: "httpx.Response", - message: Any, - ) -> Optional[float]: - """Determine retry wait time for a 4xx error, or None if non-retryable.""" - if self._is_network_delete_concurrency(metadata, response, message): - return float(random.randint(30, self._network_delete_retry_wait_time)) - if self._is_action_batch_concurrency(message): - return float(self._action_batch_retry_wait_time) - if self._retry_4xx_error: - return float(random.randint(1, self._retry_4xx_error_wait_time)) - return None - - def _retry_with_wait( - self, - wait: float, - metadata: Dict[str, Any], - response: "httpx.Response", - retries: int, - ) -> int: - """Log, sleep, decrement retries; raise APIError if exhausted.""" - tag = metadata["tags"][0] - operation = metadata["operation"] - reason = response.reason_phrase if hasattr(response, "reason_phrase") else "" - status = response.status_code - - if self._logger: - self._logger.warning(f"{tag}, {operation} - {status} {reason}, retrying in {wait} seconds") - self._sleep(wait) - retries -= 1 - if retries == 0: - raise APIError(metadata, response) - return retries - - def _is_network_delete_concurrency( - self, - metadata: Dict[str, Any], - response: "httpx.Response", - message: Any, - ) -> bool: - """Check if error is a network delete concurrency conflict.""" - if metadata.get("operation") != "deleteNetwork": - return False - if response.status_code != 400: - return False - if isinstance(message, dict) and "errors" in message: - return "concurrent" in str(message["errors"][0]) - return False - - def _is_action_batch_concurrency(self, message: Any) -> bool: - """Check if error is an action batch concurrency conflict.""" - if isinstance(message, dict) and "errors" in message: - return "executing batches" in str(message["errors"][0]).lower() - return False - - def _build_headers(self) -> Dict[str, str]: - """Build standard request headers.""" - return { - "Authorization": "Bearer " + self._api_key, - "Content-Type": "application/json", - "User-Agent": f"python-meraki/{self._version} " + validate_user_agent(self._be_geo_id, self._caller), - } +404: Not Found \ No newline at end of file diff --git a/meraki/session/sync.py b/meraki/session/sync.py index 6de119e..1becba2 100644 --- a/meraki/session/sync.py +++ b/meraki/session/sync.py @@ -1,312 +1 @@ -"""Synchronous REST session for Meraki Dashboard API.""" - -from __future__ import annotations - -import time -import urllib.parse -from datetime import datetime, timezone -from typing import Any, Dict - -import httpx - -from meraki.common import ( - iterator_for_get_pages_bool, - use_iterator_for_get_pages_setter, -) -from meraki.exceptions import SessionInputError -from meraki.session.base import SessionBase - - -class RestSession(SessionBase): - """Synchronous session using httpx.Client. - - Inherits config, retry loop, and status dispatch from SessionBase. - Implements transport-specific sleep and request methods. - """ - - def __init__(self, logger, api_key, **kwargs: Any) -> None: - super().__init__(logger, api_key, **kwargs) - - # Build client config from session config (per D-06: requests_proxy -> proxy kwarg) - client_kwargs: Dict[str, Any] = { - "timeout": self._single_request_timeout, - } - if self._certificate_path: - client_kwargs["verify"] = self._certificate_path - if self._requests_proxy: - client_kwargs["proxy"] = self._requests_proxy - - # Persistent httpx client with connection pooling - self._client = httpx.Client(**client_kwargs) - self._client.headers.update(self._build_headers()) - - def close(self): - """Close the underlying httpx.Client and release connections.""" - self._client.close() - - def __enter__(self): - return self - - def __exit__(self, *args): - self.close() - - @property - def use_iterator_for_get_pages(self): - return iterator_for_get_pages_bool(self) - - @use_iterator_for_get_pages.setter - def use_iterator_for_get_pages(self, value): - use_iterator_for_get_pages_setter(self, value) - - def _send_request(self, method: str, url: str, **kwargs: Any) -> httpx.Response: - """Send HTTP request via persistent httpx.Client.""" - response = self._client.request(method, url, follow_redirects=False, **kwargs) - return response - - def _sleep(self, seconds: float) -> None: - """Blocking sleep for retry delays.""" - time.sleep(seconds) - - def _transport_kwargs(self, kwargs: Dict[str, Any]) -> Dict[str, Any]: - """No-op: httpx config handled at client initialization level.""" - return kwargs - - # ------------------------------------------------------------------ - # Convenience HTTP methods - # ------------------------------------------------------------------ - - def get(self, metadata, url, params=None): - metadata["method"] = "GET" - metadata["url"] = url - metadata["params"] = params - response = self.request(metadata, "GET", url, params=params) - ret = None - if response: - if response.content.strip(): - ret = response.json() - response.close() - return ret - - def post(self, metadata, url, json=None): - metadata["method"] = "POST" - metadata["url"] = url - metadata["json"] = json - response = self.request(metadata, "POST", url, json=json) - ret = None - if response: - if response.content.strip(): - ret = response.json() - response.close() - return ret - - def put(self, metadata, url, json=None): - metadata["method"] = "PUT" - metadata["url"] = url - metadata["json"] = json - response = self.request(metadata, "PUT", url, json=json) - ret = None - if response: - if response.content.strip(): - ret = response.json() - response.close() - return ret - - def delete(self, metadata, url, params=None): - metadata["method"] = "DELETE" - metadata["url"] = url - metadata["params"] = params - response = self.request(metadata, "DELETE", url, params=params) - if response: - response.close() - return None - - # ------------------------------------------------------------------ - # Pagination - # ------------------------------------------------------------------ - - def get_pages(self, metadata, url, params=None, total_pages=-1, direction="next", event_log_end_time=None): - """Dispatch to iterator or legacy pagination based on config.""" - if self._use_iterator_for_get_pages: - return self._get_pages_iterator(metadata, url, params, total_pages, direction, event_log_end_time) - return self._get_pages_legacy(metadata, url, params, total_pages, direction, event_log_end_time) - - def _get_pages_iterator( - self, - metadata, - url, - params=None, - total_pages=-1, - direction="next", - event_log_end_time=None, - ): - if isinstance(total_pages, str) and total_pages.lower() == "all": - total_pages = -1 - elif isinstance(total_pages, str) and total_pages.isnumeric(): - total_pages = int(total_pages) - elif not isinstance(total_pages, int): - raise SessionInputError( - "total_pages", - total_pages, - "total_pages must be either an integer or 'all' as a string (remember to add the quotation marks).", - None, - ) - metadata["page"] = 1 - - response = self.request(metadata, "GET", url, params=params) - - # Get additional pages if more than one requested - while total_pages != 0: - results = response.json() - links = response.links - - # GET the subsequent page - if direction == "next" and "next" in links: - # Prevent getNetworkEvents from infinite loop as time goes forward - if metadata["operation"] == "getNetworkEvents": - starting_after = urllib.parse.unquote(str(links["next"]["url"]).split("startingAfter=")[1]) - delta = datetime.now(timezone.utc) - datetime.fromisoformat(starting_after) - # Break out of loop if startingAfter returned from next link is within 5 minutes of current time - if delta.total_seconds() < 300: - break - # Or if the next page is past the specified window's end time - elif event_log_end_time and starting_after > event_log_end_time: - break - - metadata["page"] += 1 - nextlink = links["next"]["url"] - elif direction == "prev" and "prev" in links: - # Prevent getNetworkEvents from infinite loop as time goes backward (to epoch 0) - if metadata["operation"] == "getNetworkEvents": - ending_before = urllib.parse.unquote(str(links["prev"]["url"]).split("endingBefore=")[1]) - # Break out of loop if endingBefore returned from prev link is before 2014 - if ending_before < "2014-01-01": - break - - metadata["page"] += 1 - nextlink = links["prev"]["url"] - else: - total_pages = 1 - - response.close() - - return_items = [] - # Just prepare the list - if isinstance(results, list): - return_items = results - elif isinstance(results, dict) and "items" in results: - return_items = results["items"] - # For event log endpoint - elif isinstance(results, dict): - if direction == "next": - return_items = results["events"][::-1] - else: - return_items = results["events"] - - for item in return_items: - yield item - - total_pages = total_pages - 1 - - if total_pages != 0: - response = self.request(metadata, "GET", nextlink) - - def _get_pages_legacy( - self, - metadata, - url, - params=None, - total_pages=-1, - direction="next", - event_log_end_time=None, - ): - if isinstance(total_pages, str) and total_pages.lower() == "all": - total_pages = -1 - elif isinstance(total_pages, str) and total_pages.isnumeric(): - total_pages = int(total_pages) - elif not isinstance(total_pages, int): - raise SessionInputError( - "total_pages", - total_pages, - "total_pages must be either an integer or 'all' as a string (remember to add the quotation marks).", - None, - ) - - metadata["page"] = 1 - - response = self.request(metadata, "GET", url, params=params) - - # Handle GETs that produce 204 No Content responses - if response.status_code == 204: - results = None - else: - results = response.json() - - # For event log endpoint when using 'next' direction, so results/events are sorted chronologically - if isinstance(results, dict) and metadata["operation"] == "getNetworkEvents" and direction == "next": - results["events"] = results["events"][::-1] - - # Get additional pages if more than one requested - while total_pages != 1: - links = response.links - response.close() - response = None - - # GET the subsequent page - if direction == "next" and "next" in links: - # Prevent getNetworkEvents from infinite loop as time goes forward - if metadata["operation"] == "getNetworkEvents": - starting_after = urllib.parse.unquote(links["next"]["url"].split("startingAfter=")[1]) - delta = datetime.now(timezone.utc) - datetime.fromisoformat(starting_after) - # Break out of loop if startingAfter returned from next link is within 5 minutes of current time - if delta.total_seconds() < 300: - break - # Or if next page is past the specified window's end time - elif event_log_end_time and starting_after > event_log_end_time: - break - - metadata["page"] += 1 - response = self.request(metadata, "GET", links["next"]["url"]) - elif direction == "prev" and "prev" in links: - # Prevent getNetworkEvents from infinite loop as time goes backward (to epoch 0) - if metadata["operation"] == "getNetworkEvents": - ending_before = urllib.parse.unquote(links["prev"]["url"].split("endingBefore=")[1]) - # Break out of loop if endingBefore returned from prev link is before 2014 - if ending_before < "2014-01-01": - break - - metadata["page"] += 1 - response = self.request(metadata, "GET", links["prev"]["url"]) - else: - break - - # Append that page's results, depending on the endpoint - if isinstance(results, list): - results.extend(response.json()) - elif isinstance(results, dict) and "items" in results: - results["items"].extend(response.json()["items"]) - if "meta" in results: - results["meta"]["counts"]["items"]["remaining"] = response.json()["meta"]["counts"]["items"]["remaining"] - # For event log endpoint - elif isinstance(results, dict): - try: - start = response.json()["pageStartAt"] - except KeyError: - if self._logger: - self._logger.warning(f"pageStartAt missing from response: {response.headers}") - start = results["pageStartAt"] # fallback: keep existing value - end = response.json()["pageEndAt"] - events = response.json()["events"] - if direction == "next": - events = events[::-1] - if start < results["pageStartAt"]: - results["pageStartAt"] = start - if end > results["pageEndAt"]: - results["pageEndAt"] = end - results["events"].extend(events) - - total_pages -= 1 - - if response: - response.close() - - return results +404: Not Found \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 53a6c9f..44da44e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "meraki" -version = "4.0.0b1" +version = "4.1.0b0" description = "Cisco Meraki Dashboard API library" authors = [ {name = "Cisco Meraki", email = "api-feedback@meraki.net"} diff --git a/uv.lock b/uv.lock index cca0abb..5af41e2 100644 --- a/uv.lock +++ b/uv.lock @@ -302,7 +302,7 @@ wheels = [ [[package]] name = "meraki" -version = "4.0.0b1" +version = "4.1.0b0" source = { editable = "." } dependencies = [ { name = "httpx" },