fastmcp 2.3.5__py3-none-any.whl → 2.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastmcp/client/client.py +21 -5
- fastmcp/client/logging.py +14 -8
- fastmcp/client/transports.py +137 -42
- fastmcp/server/http.py +23 -1
- fastmcp/server/openapi.py +113 -10
- fastmcp/server/server.py +329 -96
- fastmcp/settings.py +16 -0
- fastmcp/utilities/mcp_config.py +76 -0
- fastmcp/utilities/openapi.py +233 -602
- fastmcp/utilities/tests.py +8 -4
- {fastmcp-2.3.5.dist-info → fastmcp-2.4.0.dist-info}/METADATA +24 -1
- {fastmcp-2.3.5.dist-info → fastmcp-2.4.0.dist-info}/RECORD +15 -14
- {fastmcp-2.3.5.dist-info → fastmcp-2.4.0.dist-info}/WHEEL +0 -0
- {fastmcp-2.3.5.dist-info → fastmcp-2.4.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.3.5.dist-info → fastmcp-2.4.0.dist-info}/licenses/LICENSE +0 -0
fastmcp/client/client.py
CHANGED
|
@@ -3,12 +3,18 @@ from contextlib import AsyncExitStack, asynccontextmanager
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
from typing import Any, cast
|
|
5
5
|
|
|
6
|
+
import anyio
|
|
6
7
|
import mcp.types
|
|
7
8
|
from exceptiongroup import catch
|
|
8
9
|
from mcp import ClientSession
|
|
9
10
|
from pydantic import AnyUrl
|
|
10
11
|
|
|
11
|
-
from fastmcp.client.logging import
|
|
12
|
+
from fastmcp.client.logging import (
|
|
13
|
+
LogHandler,
|
|
14
|
+
MessageHandler,
|
|
15
|
+
create_log_callback,
|
|
16
|
+
default_log_handler,
|
|
17
|
+
)
|
|
12
18
|
from fastmcp.client.progress import ProgressHandler, default_progress_handler
|
|
13
19
|
from fastmcp.client.roots import (
|
|
14
20
|
RootsHandler,
|
|
@@ -19,6 +25,7 @@ from fastmcp.client.sampling import SamplingHandler, create_sampling_callback
|
|
|
19
25
|
from fastmcp.exceptions import ToolError
|
|
20
26
|
from fastmcp.server import FastMCP
|
|
21
27
|
from fastmcp.utilities.exceptions import get_catch_handlers
|
|
28
|
+
from fastmcp.utilities.mcp_config import MCPConfig
|
|
22
29
|
|
|
23
30
|
from .transports import ClientTransport, SessionKwargs, infer_transport
|
|
24
31
|
|
|
@@ -47,6 +54,7 @@ class Client:
|
|
|
47
54
|
- FastMCP: In-process FastMCP server
|
|
48
55
|
- AnyUrl | str: URL to connect to
|
|
49
56
|
- Path: File path for local socket
|
|
57
|
+
- MCPConfig: MCP server configuration
|
|
50
58
|
- dict: Transport configuration
|
|
51
59
|
roots: Optional RootsList or RootsHandler for filesystem access
|
|
52
60
|
sampling_handler: Optional handler for sampling requests
|
|
@@ -71,7 +79,13 @@ class Client:
|
|
|
71
79
|
|
|
72
80
|
def __init__(
|
|
73
81
|
self,
|
|
74
|
-
transport: ClientTransport
|
|
82
|
+
transport: ClientTransport
|
|
83
|
+
| FastMCP
|
|
84
|
+
| AnyUrl
|
|
85
|
+
| Path
|
|
86
|
+
| MCPConfig
|
|
87
|
+
| dict[str, Any]
|
|
88
|
+
| str,
|
|
75
89
|
# Common args
|
|
76
90
|
roots: RootsList | RootsHandler | None = None,
|
|
77
91
|
sampling_handler: SamplingHandler | None = None,
|
|
@@ -100,7 +114,7 @@ class Client:
|
|
|
100
114
|
self._session_kwargs: SessionKwargs = {
|
|
101
115
|
"sampling_callback": None,
|
|
102
116
|
"list_roots_callback": None,
|
|
103
|
-
"logging_callback": log_handler,
|
|
117
|
+
"logging_callback": create_log_callback(log_handler),
|
|
104
118
|
"message_handler": message_handler,
|
|
105
119
|
"read_timeout_seconds": timeout,
|
|
106
120
|
}
|
|
@@ -153,10 +167,12 @@ class Client:
|
|
|
153
167
|
) as session:
|
|
154
168
|
self._session = session
|
|
155
169
|
# Initialize the session
|
|
156
|
-
self._initialize_result = await self._session.initialize()
|
|
157
|
-
|
|
158
170
|
try:
|
|
171
|
+
with anyio.fail_after(1):
|
|
172
|
+
self._initialize_result = await self._session.initialize()
|
|
159
173
|
yield
|
|
174
|
+
except TimeoutError:
|
|
175
|
+
raise RuntimeError("Failed to initialize server session")
|
|
160
176
|
finally:
|
|
161
177
|
self._exit_stack = None
|
|
162
178
|
self._session = None
|
fastmcp/client/logging.py
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
+
from collections.abc import Awaitable, Callable
|
|
1
2
|
from typing import TypeAlias
|
|
2
3
|
|
|
3
|
-
from mcp.client.session import
|
|
4
|
-
LoggingFnT,
|
|
5
|
-
MessageHandlerFnT,
|
|
6
|
-
)
|
|
4
|
+
from mcp.client.session import LoggingFnT, MessageHandlerFnT
|
|
7
5
|
from mcp.types import LoggingMessageNotificationParams
|
|
8
6
|
|
|
9
7
|
from fastmcp.utilities.logging import get_logger
|
|
@@ -11,11 +9,19 @@ from fastmcp.utilities.logging import get_logger
|
|
|
11
9
|
logger = get_logger(__name__)
|
|
12
10
|
|
|
13
11
|
LogMessage: TypeAlias = LoggingMessageNotificationParams
|
|
14
|
-
LogHandler: TypeAlias =
|
|
12
|
+
LogHandler: TypeAlias = Callable[[LogMessage], Awaitable[None]]
|
|
15
13
|
MessageHandler: TypeAlias = MessageHandlerFnT
|
|
16
14
|
|
|
17
|
-
__all__ = ["LogMessage", "LogHandler", "MessageHandler"]
|
|
18
15
|
|
|
16
|
+
async def default_log_handler(message: LogMessage) -> None:
|
|
17
|
+
logger.debug(f"Log received: {message}")
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
|
|
20
|
+
def create_log_callback(handler: LogHandler | None = None) -> LoggingFnT:
|
|
21
|
+
if handler is None:
|
|
22
|
+
handler = default_log_handler
|
|
23
|
+
|
|
24
|
+
async def log_callback(params: LoggingMessageNotificationParams) -> None:
|
|
25
|
+
await handler(params)
|
|
26
|
+
|
|
27
|
+
return log_callback
|
fastmcp/client/transports.py
CHANGED
|
@@ -6,8 +6,7 @@ import shutil
|
|
|
6
6
|
import sys
|
|
7
7
|
from collections.abc import AsyncIterator
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import Any, TypedDict, cast
|
|
10
|
-
from urllib.parse import urlparse
|
|
9
|
+
from typing import TYPE_CHECKING, Any, TypedDict, cast
|
|
11
10
|
|
|
12
11
|
from mcp import ClientSession, StdioServerParameters
|
|
13
12
|
from mcp.client.session import (
|
|
@@ -25,7 +24,12 @@ from pydantic import AnyUrl
|
|
|
25
24
|
from typing_extensions import Unpack
|
|
26
25
|
|
|
27
26
|
from fastmcp.server import FastMCP as FastMCPServer
|
|
27
|
+
from fastmcp.server.server import FastMCP
|
|
28
28
|
from fastmcp.utilities.logging import get_logger
|
|
29
|
+
from fastmcp.utilities.mcp_config import MCPConfig, infer_transport_type_from_url
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from fastmcp.utilities.mcp_config import MCPConfig
|
|
29
33
|
|
|
30
34
|
logger = get_logger(__name__)
|
|
31
35
|
|
|
@@ -71,7 +75,7 @@ class ClientTransport(abc.ABC):
|
|
|
71
75
|
A mcp.ClientSession instance.
|
|
72
76
|
"""
|
|
73
77
|
raise NotImplementedError
|
|
74
|
-
yield
|
|
78
|
+
yield # type: ignore
|
|
75
79
|
|
|
76
80
|
def __repr__(self) -> str:
|
|
77
81
|
# Basic representation for subclasses
|
|
@@ -452,7 +456,7 @@ class FastMCPTransport(ClientTransport):
|
|
|
452
456
|
"""
|
|
453
457
|
|
|
454
458
|
def __init__(self, mcp: FastMCPServer):
|
|
455
|
-
self.
|
|
459
|
+
self.server = mcp # Can be FastMCP or MCPServer
|
|
456
460
|
|
|
457
461
|
@contextlib.asynccontextmanager
|
|
458
462
|
async def connect_session(
|
|
@@ -460,17 +464,105 @@ class FastMCPTransport(ClientTransport):
|
|
|
460
464
|
) -> AsyncIterator[ClientSession]:
|
|
461
465
|
# create_connected_server_and_client_session manages the session lifecycle itself
|
|
462
466
|
async with create_connected_server_and_client_session(
|
|
463
|
-
server=self.
|
|
467
|
+
server=self.server._mcp_server,
|
|
464
468
|
**session_kwargs,
|
|
465
469
|
) as session:
|
|
466
470
|
yield session
|
|
467
471
|
|
|
468
472
|
def __repr__(self) -> str:
|
|
469
|
-
return f"<FastMCP(server='{self.
|
|
473
|
+
return f"<FastMCP(server='{self.server.name}')>"
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
class MCPConfigTransport(ClientTransport):
|
|
477
|
+
"""Transport for connecting to one or more MCP servers defined in an MCPConfig.
|
|
478
|
+
|
|
479
|
+
This transport provides a unified interface to multiple MCP servers defined in an MCPConfig
|
|
480
|
+
object or dictionary matching the MCPConfig schema. It supports two key scenarios:
|
|
481
|
+
|
|
482
|
+
1. If the MCPConfig contains exactly one server, it creates a direct transport to that server.
|
|
483
|
+
2. If the MCPConfig contains multiple servers, it creates a composite client by mounting
|
|
484
|
+
all servers on a single FastMCP instance, with each server's name used as its mounting prefix.
|
|
485
|
+
|
|
486
|
+
In the multi-server case, tools are accessible with the prefix pattern `{server_name}_{tool_name}`
|
|
487
|
+
and resources with the pattern `protocol://{server_name}/path/to/resource`.
|
|
488
|
+
|
|
489
|
+
This is particularly useful for creating clients that need to interact with multiple specialized
|
|
490
|
+
MCP servers through a single interface, simplifying client code.
|
|
491
|
+
|
|
492
|
+
Examples:
|
|
493
|
+
```python
|
|
494
|
+
from fastmcp import Client
|
|
495
|
+
from fastmcp.utilities.mcp_config import MCPConfig
|
|
496
|
+
|
|
497
|
+
# Create a config with multiple servers
|
|
498
|
+
config = {
|
|
499
|
+
"mcpServers": {
|
|
500
|
+
"weather": {
|
|
501
|
+
"url": "https://weather-api.example.com/mcp",
|
|
502
|
+
"transport": "streamable-http"
|
|
503
|
+
},
|
|
504
|
+
"calendar": {
|
|
505
|
+
"url": "https://calendar-api.example.com/mcp",
|
|
506
|
+
"transport": "streamable-http"
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
# Create a client with the config
|
|
512
|
+
client = Client(config)
|
|
513
|
+
|
|
514
|
+
async with client:
|
|
515
|
+
# Access tools with prefixes
|
|
516
|
+
weather = await client.call_tool("weather_get_forecast", {"city": "London"})
|
|
517
|
+
events = await client.call_tool("calendar_list_events", {"date": "2023-06-01"})
|
|
518
|
+
|
|
519
|
+
# Access resources with prefixed URIs
|
|
520
|
+
icons = await client.read_resource("weather://weather/icons/sunny")
|
|
521
|
+
```
|
|
522
|
+
"""
|
|
523
|
+
|
|
524
|
+
def __init__(self, config: MCPConfig | dict):
|
|
525
|
+
from fastmcp.client.client import Client
|
|
526
|
+
|
|
527
|
+
if isinstance(config, dict):
|
|
528
|
+
config = MCPConfig.from_dict(config)
|
|
529
|
+
self.config = config
|
|
530
|
+
|
|
531
|
+
# if there's exactly one server, create a client for that server
|
|
532
|
+
if len(self.config.mcpServers) == 1:
|
|
533
|
+
self.transport = list(self.config.mcpServers.values())[0].to_transport()
|
|
534
|
+
|
|
535
|
+
# otherwise create a composite client
|
|
536
|
+
else:
|
|
537
|
+
composite_server = FastMCP()
|
|
538
|
+
|
|
539
|
+
for name, server in self.config.mcpServers.items():
|
|
540
|
+
server_client = Client(transport=server.to_transport())
|
|
541
|
+
composite_server.mount(
|
|
542
|
+
prefix=name, server=FastMCP.as_proxy(server_client)
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
self.transport = FastMCPTransport(mcp=composite_server)
|
|
546
|
+
|
|
547
|
+
@contextlib.asynccontextmanager
|
|
548
|
+
async def connect_session(
|
|
549
|
+
self, **session_kwargs: Unpack[SessionKwargs]
|
|
550
|
+
) -> AsyncIterator[ClientSession]:
|
|
551
|
+
async with self.transport.connect_session(**session_kwargs) as session:
|
|
552
|
+
yield session
|
|
553
|
+
|
|
554
|
+
def __repr__(self) -> str:
|
|
555
|
+
return f"<MCPConfig(config='{self.config}')>"
|
|
470
556
|
|
|
471
557
|
|
|
472
558
|
def infer_transport(
|
|
473
|
-
transport: ClientTransport
|
|
559
|
+
transport: ClientTransport
|
|
560
|
+
| FastMCPServer
|
|
561
|
+
| AnyUrl
|
|
562
|
+
| Path
|
|
563
|
+
| MCPConfig
|
|
564
|
+
| dict[str, Any]
|
|
565
|
+
| str,
|
|
474
566
|
) -> ClientTransport:
|
|
475
567
|
"""
|
|
476
568
|
Infer the appropriate transport type from the given transport argument.
|
|
@@ -479,8 +571,41 @@ def infer_transport(
|
|
|
479
571
|
argument, handling various input types and converting them to the appropriate
|
|
480
572
|
ClientTransport subclass.
|
|
481
573
|
|
|
574
|
+
The function supports these input types:
|
|
575
|
+
- ClientTransport: Used directly without modification
|
|
576
|
+
- FastMCPServer: Creates an in-memory FastMCPTransport
|
|
577
|
+
- Path or str (file path): Creates PythonStdioTransport (.py) or NodeStdioTransport (.js)
|
|
578
|
+
- AnyUrl or str (URL): Creates StreamableHttpTransport (default) or SSETransport (for /sse endpoints)
|
|
579
|
+
- MCPConfig or dict: Creates MCPConfigTransport, potentially connecting to multiple servers
|
|
580
|
+
|
|
482
581
|
For HTTP URLs, they are assumed to be Streamable HTTP URLs unless they end in `/sse`.
|
|
582
|
+
|
|
583
|
+
For MCPConfig with multiple servers, a composite client is created where each server
|
|
584
|
+
is mounted with its name as prefix. This allows accessing tools and resources from multiple
|
|
585
|
+
servers through a single unified client interface, using naming patterns like
|
|
586
|
+
`servername_toolname` for tools and `protocol://servername/path` for resources.
|
|
587
|
+
If the MCPConfig contains only one server, a direct connection is established without prefixing.
|
|
588
|
+
|
|
589
|
+
Examples:
|
|
590
|
+
```python
|
|
591
|
+
# Connect to a local Python script
|
|
592
|
+
transport = infer_transport("my_script.py")
|
|
593
|
+
|
|
594
|
+
# Connect to a remote server via HTTP
|
|
595
|
+
transport = infer_transport("http://example.com/mcp")
|
|
596
|
+
|
|
597
|
+
# Connect to multiple servers using MCPConfig
|
|
598
|
+
config = {
|
|
599
|
+
"mcpServers": {
|
|
600
|
+
"weather": {"url": "http://weather.example.com/mcp"},
|
|
601
|
+
"calendar": {"url": "http://calendar.example.com/mcp"}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
transport = infer_transport(config)
|
|
605
|
+
```
|
|
483
606
|
"""
|
|
607
|
+
from fastmcp.utilities.mcp_config import MCPConfig
|
|
608
|
+
|
|
484
609
|
# the transport is already a ClientTransport
|
|
485
610
|
if isinstance(transport, ClientTransport):
|
|
486
611
|
return transport
|
|
@@ -500,45 +625,15 @@ def infer_transport(
|
|
|
500
625
|
|
|
501
626
|
# the transport is an http(s) URL
|
|
502
627
|
elif isinstance(transport, AnyUrl | str) and str(transport).startswith("http"):
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
parsed_url = urlparse(transport_str)
|
|
506
|
-
path = parsed_url.path
|
|
507
|
-
|
|
508
|
-
# Check if path contains /sse/ or ends with /sse
|
|
509
|
-
if "/sse/" in path or path.rstrip("/").endswith("/sse"):
|
|
628
|
+
inferred_transport_type = infer_transport_type_from_url(transport)
|
|
629
|
+
if inferred_transport_type == "sse":
|
|
510
630
|
inferred_transport = SSETransport(url=transport)
|
|
511
631
|
else:
|
|
512
632
|
inferred_transport = StreamableHttpTransport(url=transport)
|
|
513
633
|
|
|
514
|
-
|
|
515
|
-
elif isinstance(transport, dict):
|
|
516
|
-
|
|
517
|
-
raise ValueError("Invalid transport dictionary: missing 'mcpServers' key")
|
|
518
|
-
else:
|
|
519
|
-
server = transport["mcpServers"]
|
|
520
|
-
if len(list(server.keys())) > 1:
|
|
521
|
-
raise ValueError(
|
|
522
|
-
"Invalid transport dictionary: multiple servers found - only one expected"
|
|
523
|
-
)
|
|
524
|
-
server_name = list(server.keys())[0]
|
|
525
|
-
# Stdio transport
|
|
526
|
-
if "command" in server[server_name] and "args" in server[server_name]:
|
|
527
|
-
inferred_transport = StdioTransport(
|
|
528
|
-
command=server[server_name]["command"],
|
|
529
|
-
args=server[server_name]["args"],
|
|
530
|
-
env=server[server_name].get("env", None),
|
|
531
|
-
cwd=server[server_name].get("cwd", None),
|
|
532
|
-
)
|
|
533
|
-
|
|
534
|
-
# HTTP transport
|
|
535
|
-
elif "url" in server:
|
|
536
|
-
inferred_transport = SSETransport(
|
|
537
|
-
url=server["url"],
|
|
538
|
-
headers=server.get("headers", None),
|
|
539
|
-
)
|
|
540
|
-
|
|
541
|
-
raise ValueError("Cannot determine transport type from dictionary")
|
|
634
|
+
# if the transport is a config dict or MCPConfig
|
|
635
|
+
elif isinstance(transport, dict | MCPConfig):
|
|
636
|
+
inferred_transport = MCPConfigTransport(config=transport)
|
|
542
637
|
|
|
543
638
|
# the transport is an unknown type
|
|
544
639
|
else:
|
fastmcp/server/http.py
CHANGED
|
@@ -306,7 +306,29 @@ def create_streamable_http_app(
|
|
|
306
306
|
async def handle_streamable_http(
|
|
307
307
|
scope: Scope, receive: Receive, send: Send
|
|
308
308
|
) -> None:
|
|
309
|
-
|
|
309
|
+
try:
|
|
310
|
+
await session_manager.handle_request(scope, receive, send)
|
|
311
|
+
except RuntimeError as e:
|
|
312
|
+
if str(e) == "Task group is not initialized. Make sure to use run().":
|
|
313
|
+
logger.error(
|
|
314
|
+
f"Original RuntimeError from mcp library: {e}", exc_info=True
|
|
315
|
+
)
|
|
316
|
+
new_error_message = (
|
|
317
|
+
"FastMCP's StreamableHTTPSessionManager task group was not initialized. "
|
|
318
|
+
"This commonly occurs when the FastMCP application's lifespan is not "
|
|
319
|
+
"passed to the parent ASGI application (e.g., FastAPI or Starlette). "
|
|
320
|
+
"Please ensure you are setting `lifespan=mcp_app.lifespan` in your "
|
|
321
|
+
"parent app's constructor, where `mcp_app` is the application instance "
|
|
322
|
+
"returned by `fastmcp_instance.http_app()`. \\n"
|
|
323
|
+
"For more details, see the FastMCP ASGI integration documentation: "
|
|
324
|
+
"https://gofastmcp.com/deployment/asgi"
|
|
325
|
+
)
|
|
326
|
+
# Raise a new RuntimeError that includes the original error's message
|
|
327
|
+
# for full context, but leads with the more helpful guidance.
|
|
328
|
+
raise RuntimeError(f"{new_error_message}\\nOriginal error: {e}") from e
|
|
329
|
+
else:
|
|
330
|
+
# Re-raise other RuntimeErrors if they don't match the specific message
|
|
331
|
+
raise
|
|
310
332
|
|
|
311
333
|
# Get auth middleware and routes
|
|
312
334
|
auth_middleware, auth_routes, required_scopes = setup_auth_middleware_and_routes(
|
fastmcp/server/openapi.py
CHANGED
|
@@ -47,7 +47,7 @@ class RouteType(enum.Enum):
|
|
|
47
47
|
class RouteMap:
|
|
48
48
|
"""Mapping configuration for HTTP routes to FastMCP component types."""
|
|
49
49
|
|
|
50
|
-
methods: list[HttpMethod]
|
|
50
|
+
methods: list[HttpMethod] | Literal["*"]
|
|
51
51
|
pattern: Pattern[str] | str
|
|
52
52
|
route_type: RouteType
|
|
53
53
|
|
|
@@ -86,7 +86,7 @@ def _determine_route_type(
|
|
|
86
86
|
# Check mappings in priority order (first match wins)
|
|
87
87
|
for route_map in mappings:
|
|
88
88
|
# Check if the HTTP method matches
|
|
89
|
-
if route.method in route_map.methods:
|
|
89
|
+
if route_map.methods == "*" or route.method in route_map.methods:
|
|
90
90
|
# Handle both string patterns and compiled Pattern objects
|
|
91
91
|
if isinstance(route_map.pattern, Pattern):
|
|
92
92
|
pattern_matches = route_map.pattern.search(route.path)
|
|
@@ -171,17 +171,120 @@ class OpenAPITool(Tool):
|
|
|
171
171
|
raise ToolError(f"Missing required path parameters: {missing_params}")
|
|
172
172
|
|
|
173
173
|
for param_name, param_value in path_params.items():
|
|
174
|
+
# Handle array path parameters with style 'simple' (comma-separated)
|
|
175
|
+
# In OpenAPI, 'simple' is the default style for path parameters
|
|
176
|
+
param_info = next(
|
|
177
|
+
(p for p in self._route.parameters if p.name == param_name), None
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if param_info and isinstance(param_value, list):
|
|
181
|
+
# Check if schema indicates an array type
|
|
182
|
+
schema = param_info.schema_
|
|
183
|
+
is_array = schema.get("type") == "array"
|
|
184
|
+
|
|
185
|
+
if is_array:
|
|
186
|
+
# Format array values as comma-separated string
|
|
187
|
+
# This follows the OpenAPI 'simple' style (default for path)
|
|
188
|
+
if all(
|
|
189
|
+
isinstance(item, str | int | float | bool)
|
|
190
|
+
for item in param_value
|
|
191
|
+
):
|
|
192
|
+
# Handle simple array types
|
|
193
|
+
path = path.replace(
|
|
194
|
+
f"{{{param_name}}}", ",".join(str(v) for v in param_value)
|
|
195
|
+
)
|
|
196
|
+
else:
|
|
197
|
+
# Handle complex array types (containing objects/dicts)
|
|
198
|
+
try:
|
|
199
|
+
# Try to create a simple representation without Python syntax artifacts
|
|
200
|
+
formatted_parts = []
|
|
201
|
+
for item in param_value:
|
|
202
|
+
if isinstance(item, dict):
|
|
203
|
+
# For objects, serialize key-value pairs
|
|
204
|
+
item_parts = []
|
|
205
|
+
for k, v in item.items():
|
|
206
|
+
item_parts.append(f"{k}:{v}")
|
|
207
|
+
formatted_parts.append(".".join(item_parts))
|
|
208
|
+
else:
|
|
209
|
+
# Fallback for other complex types
|
|
210
|
+
formatted_parts.append(str(item))
|
|
211
|
+
|
|
212
|
+
# Join parts with commas
|
|
213
|
+
formatted_value = ",".join(formatted_parts)
|
|
214
|
+
path = path.replace(f"{{{param_name}}}", formatted_value)
|
|
215
|
+
except Exception as e:
|
|
216
|
+
logger.warning(
|
|
217
|
+
f"Failed to format complex array path parameter '{param_name}': {e}"
|
|
218
|
+
)
|
|
219
|
+
# Fallback to string representation, but remove Python syntax artifacts
|
|
220
|
+
str_value = (
|
|
221
|
+
str(param_value)
|
|
222
|
+
.replace("[", "")
|
|
223
|
+
.replace("]", "")
|
|
224
|
+
.replace("'", "")
|
|
225
|
+
.replace('"', "")
|
|
226
|
+
)
|
|
227
|
+
path = path.replace(f"{{{param_name}}}", str_value)
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
# Default handling for non-array parameters or non-array schemas
|
|
174
231
|
path = path.replace(f"{{{param_name}}}", str(param_value))
|
|
175
232
|
|
|
176
233
|
# Prepare query parameters - filter out None and empty strings
|
|
177
|
-
query_params = {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
234
|
+
query_params = {}
|
|
235
|
+
for p in self._route.parameters:
|
|
236
|
+
if (
|
|
237
|
+
p.location == "query"
|
|
238
|
+
and p.name in kwargs
|
|
239
|
+
and kwargs.get(p.name) is not None
|
|
240
|
+
and kwargs.get(p.name) != ""
|
|
241
|
+
):
|
|
242
|
+
param_value = kwargs.get(p.name)
|
|
243
|
+
|
|
244
|
+
# Format array query parameters as comma-separated strings
|
|
245
|
+
# following OpenAPI form style (default for query parameters)
|
|
246
|
+
if isinstance(param_value, list) and p.schema_.get("type") == "array":
|
|
247
|
+
# Get explode parameter from schema, default is True for query parameters
|
|
248
|
+
# If explode is True, the array is serialized as separate parameters
|
|
249
|
+
# If explode is False, the array is serialized as a comma-separated string
|
|
250
|
+
explode = p.schema_.get("explode", True)
|
|
251
|
+
|
|
252
|
+
if explode:
|
|
253
|
+
# When explode=True, we pass the array directly, which HTTPX will serialize
|
|
254
|
+
# as multiple parameters with the same name
|
|
255
|
+
query_params[p.name] = param_value
|
|
256
|
+
else:
|
|
257
|
+
# For arrays of simple types (strings, numbers, etc.), join with commas
|
|
258
|
+
if all(
|
|
259
|
+
isinstance(item, str | int | float | bool)
|
|
260
|
+
for item in param_value
|
|
261
|
+
):
|
|
262
|
+
query_params[p.name] = ",".join(str(v) for v in param_value)
|
|
263
|
+
else:
|
|
264
|
+
# For complex types, try to create a simpler representation
|
|
265
|
+
try:
|
|
266
|
+
# Try to create a simple string representation
|
|
267
|
+
formatted_parts = []
|
|
268
|
+
for item in param_value:
|
|
269
|
+
if isinstance(item, dict):
|
|
270
|
+
# For objects, serialize key-value pairs
|
|
271
|
+
item_parts = []
|
|
272
|
+
for k, v in item.items():
|
|
273
|
+
item_parts.append(f"{k}:{v}")
|
|
274
|
+
formatted_parts.append(".".join(item_parts))
|
|
275
|
+
else:
|
|
276
|
+
formatted_parts.append(str(item))
|
|
277
|
+
|
|
278
|
+
query_params[p.name] = ",".join(formatted_parts)
|
|
279
|
+
except Exception as e:
|
|
280
|
+
logger.warning(
|
|
281
|
+
f"Failed to format complex array query parameter '{p.name}': {e}"
|
|
282
|
+
)
|
|
283
|
+
# Fallback to string representation
|
|
284
|
+
query_params[p.name] = param_value
|
|
285
|
+
else:
|
|
286
|
+
# Non-array parameters are passed as is
|
|
287
|
+
query_params[p.name] = param_value
|
|
185
288
|
|
|
186
289
|
# Prepare headers - fix typing by ensuring all values are strings
|
|
187
290
|
headers = {}
|