diff --git a/pyproject.toml b/pyproject.toml index b5a21cdf8..4fe53f6e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ authors = [{ name = "Jeremiah Lowin" }] dependencies = [ "httpx>=0.26.0", "mcp>=1.0.0", + "pydantic-settings>=2.6.1", "pydantic>=2.5.3", "typer>=0.9.0", ] diff --git a/src/fastmcp/_version.py b/src/fastmcp/_version.py index 12d7bf85a..168394220 100644 --- a/src/fastmcp/_version.py +++ b/src/fastmcp/_version.py @@ -12,5 +12,5 @@ __version__: str __version_tuple__: VERSION_TUPLE version_tuple: VERSION_TUPLE -__version__ = version = '0.1.dev0+d20241129' -__version_tuple__ = version_tuple = (0, 1, 'dev0', 'd20241129') +__version__ = version = '0.1.dev4+g18aaf41.d20241129' +__version_tuple__ = version_tuple = (0, 1, 'dev4', 'g18aaf41.d20241129') diff --git a/src/fastmcp/resources.py b/src/fastmcp/resources.py index bec280113..330581b73 100644 --- a/src/fastmcp/resources.py +++ b/src/fastmcp/resources.py @@ -4,13 +4,14 @@ import abc import asyncio import json import logging +import warnings from pathlib import Path -from typing import Dict, Optional +from typing import Dict, Optional, Callable, Any +from urllib.parse import parse_qs, urlparse import httpx from pydantic import BaseModel, field_validator - logger = logging.getLogger("mcp") @@ -28,6 +29,49 @@ class Resource(BaseModel): return "" +class FunctionResource(Resource): + """A resource that is generated by a function call. + + The function is called with kwargs parsed from the URI query string. + For example, a URI of "fn://my_func?x=1&y=2" will call the function with + kwargs {"x": "1", "y": "2"}. + """ + + func: Callable[..., Any] + + @field_validator("uri") + @classmethod + def validate_uri(cls, uri: str) -> str: + """Ensure URI starts with fn://.""" + if not uri.startswith("fn://"): + raise ValueError(f"URI must start with fn://: {uri}") + return uri + + def _parse_uri_params(self) -> Dict[str, str]: + """Parse URI query string into kwargs.""" + parsed = urlparse(self.uri) + if not parsed.query: + return {} + # parse_qs returns Dict[str, List[str]], we want Dict[str, str] + params = parse_qs(parsed.query) + return {k: v[0] for k, v in params.items()} + + async def read(self) -> str: + """Read the resource content by calling the function with URI params.""" + try: + kwargs = self._parse_uri_params() + result = await asyncio.to_thread(self.func, **kwargs) + if isinstance(result, Resource): + return await result.read() + if isinstance(result, bytes): + return result.decode() + if not isinstance(result, str): + return str(result) + return result + except Exception as e: + raise ValueError(f"Error calling function {self.func.__name__}: {e}") + + class FileResource(Resource): """A file resource.""" @@ -123,16 +167,38 @@ class DirectoryResource(Resource): class ResourceManager: """Manages FastMCP resources.""" - def __init__(self): + def __init__(self, warn_on_duplicate_resources: bool = True): self._resources: Dict[str, Resource] = {} + self.warn_on_duplicate_resources = warn_on_duplicate_resources def get_resource(self, uri: str) -> Optional[Resource]: - """Get resource by URI.""" + """Get resource by URI. + + First tries to find an exact match for the URI. If none is found, + tries to match against any FunctionResources with wildcard patterns. + """ logger.debug("Getting resource", extra={"uri": uri}) - resource = self._resources.get(uri) - if not resource: - raise ValueError(f"Unknown resource: {uri}") - return resource + + # First try exact match + if resource := self._resources.get(uri): + return resource + + # Then try pattern matching for FunctionResources + for resource in self._resources.values(): + if isinstance(resource, FunctionResource) and hasattr( + resource, "uri_regex" + ): + if resource.uri_regex.match(uri): + # Create a new instance with the actual URI + return FunctionResource( + uri=uri, # Use actual URI + name=resource.name, + description=resource.description, + mime_type=resource.mime_type, + func=resource.func, + ) + + raise ValueError(f"Unknown resource: {uri}") def list_resources(self) -> list[Resource]: """List all registered resources.""" @@ -159,6 +225,12 @@ class ResourceManager: ) existing = self._resources.get(resource.uri) if existing: + if self.warn_on_duplicate_resources: + warnings.warn( + f"Resource already exists: {resource.uri}", + ResourceWarning, + stacklevel=2, + ) return existing self._resources[resource.uri] = resource return resource diff --git a/src/fastmcp/server.py b/src/fastmcp/server.py index 43b081ee5..125e766a0 100644 --- a/src/fastmcp/server.py +++ b/src/fastmcp/server.py @@ -1,30 +1,74 @@ """FastMCP - A more ergonomic interface for MCP servers.""" import base64 +import functools import json import logging -from typing import Any, Callable, Dict, Optional, Sequence, Union +from typing import Any, Callable, Dict, Optional, Sequence, Union, Literal from mcp.server import Server as MCPServer from mcp.server.stdio import stdio_server from mcp.types import Resource as MCPResource from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource from pydantic import BaseModel +from pydantic_settings import BaseSettings from .exceptions import ResourceError -from .resources import Resource, ResourceManager +from .resources import Resource, FunctionResource, ResourceManager from .tools import ToolManager -logger = logging.getLogger("mcp") + +logger = logging.getLogger("fastmcp") + + +class Settings(BaseSettings): + """FastMCP server settings. + + All settings can be configured via environment variables with the prefix FASTMCP_. + For example, FASTMCP_DEBUG=true will set debug=True. + """ + + model_config: dict = dict(env_prefix="FASTMCP_") + + # Server settings + debug: bool = False + log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO" + + # HTTP settings + host: str = "0.0.0.0" + port: int = 8000 + + # resource settings + warn_on_duplicate_resources: bool = True + + # tool settings + warn_on_duplicate_tools: bool = True class FastMCPServer: - def __init__(self, name: str): - self._mcp_server = MCPServer(name) - self._tool_manager = ToolManager() - self._resource_manager = ResourceManager() + def __init__(self, name=None, **settings: Optional[Settings]): + self.settings = Settings(**settings) + self._mcp_server = MCPServer(name=name or "FastMCPServer") + self._tool_manager = ToolManager( + warn_on_duplicate_tools=self.settings.warn_on_duplicate_tools + ) + self._resource_manager = ResourceManager( + warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources + ) + + # Configure logging + logging.basicConfig( + level=getattr(logging, self.settings.log_level.upper()), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + logger.setLevel(getattr(logging, self.settings.log_level.upper())) + self._setup_handlers() + @property + def name(self) -> str: + return self._mcp_server.name + def _setup_handlers(self) -> None: """Set up core MCP protocol handlers.""" @@ -206,6 +250,48 @@ class FastMCPServer: ) self.add_resource(resource) + def resource( + self, + name: str, + *, + description: Optional[str] = None, + mime_type: Optional[str] = None, + ) -> Callable: + """Decorator to register a function as a dynamic resource. + + The function will be called with kwargs parsed from the URI query string. + For example, a URI of "fn://my_func?x=1&y=2" will call the function with + kwargs {"x": "1", "y": "2"}. + + Args: + name: Name for the resource (used in fn:// URI) + description: Optional description of the resource + mime_type: Optional MIME type for the resource + + Example: + @server.resource("my_func") + def get_data(x: str, y: str) -> str: + # Called with fn://my_func?x=1&y=2 + return f"x={x}, y={y}" + """ + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(**kwargs) -> Any: + return func(**kwargs) + + resource = FunctionResource( + uri=f"fn://{name}", # Base URI, params added when called + name=name, + description=description, + mime_type=mime_type or "text/plain", + func=wrapper, + ) + self.add_resource(resource) + return wrapper + + return decorator + async def run(self, *args, **kwargs) -> None: """Run the FastMCP server.""" await self._mcp_server.run(*args, **kwargs) @@ -222,7 +308,8 @@ class FastMCPServer: @classmethod async def run_sse( - cls, app: "FastMCPServer", host: str = "0.0.0.0", port: int = 8000 + cls, + app: "FastMCPServer", ) -> None: """Run the server using SSE transport.""" from mcp.server.sse import SseServerTransport @@ -246,11 +333,16 @@ class FastMCPServer: await sse.handle_post_message(request.scope, request.receive, request._send) starlette_app = Starlette( - debug=True, + debug=app.settings.debug, routes=[ Route("/sse", endpoint=handle_sse), Route("/messages", endpoint=handle_messages, methods=["POST"]), ], ) - uvicorn.run(starlette_app, host=host, port=port) + uvicorn.run( + starlette_app, + host=app.settings.host, + port=app.settings.port, + log_level=app.settings.log_level, + ) diff --git a/src/fastmcp/tools.py b/src/fastmcp/tools.py index e37830fab..117ccb42a 100644 --- a/src/fastmcp/tools.py +++ b/src/fastmcp/tools.py @@ -1,11 +1,15 @@ """Tool management for FastMCP.""" import inspect +import warnings from typing import Any, Callable, Dict, Optional from pydantic import BaseModel, Field, TypeAdapter from .exceptions import ToolError +import logging + +logger = logging.getLogger("fastmcp") class Tool(BaseModel): @@ -54,8 +58,9 @@ class Tool(BaseModel): class ToolManager: """Manages FastMCP tools.""" - def __init__(self): + def __init__(self, warn_on_duplicate_tools: bool = True): self._tools: Dict[str, Tool] = {} + self.warn_on_duplicate_tools = warn_on_duplicate_tools def get_tool(self, name: str) -> Optional[Tool]: """Get tool by name.""" @@ -70,10 +75,20 @@ class ToolManager: func: Callable, name: Optional[str] = None, description: Optional[str] = None, - ) -> None: + ) -> Tool: """Add a tool to the server.""" tool = Tool.from_function(func, name=name, description=description) + existing = self._tools.get(tool.name) + if existing: + if self.warn_on_duplicate_tools: + warnings.warn( + f"Tool already exists: {tool.name}", + ResourceWarning, + stacklevel=2, + ) + return existing self._tools[tool.name] = tool + return tool async def call_tool(self, name: str, arguments: dict) -> Any: """Call a tool by name with arguments.""" diff --git a/tests/test_resource_manager.py b/tests/test_resource_manager.py index 40914566f..a299a0f5d 100644 --- a/tests/test_resource_manager.py +++ b/tests/test_resource_manager.py @@ -1,16 +1,9 @@ -"""Tests for resource management.""" - +import warnings import pytest from pathlib import Path from tempfile import NamedTemporaryFile, TemporaryDirectory -from fastmcp.resources import FileResource, ResourceManager - - -@pytest.fixture -def resource_manager(): - """Create a resource manager for testing.""" - return ResourceManager() +from fastmcp.resources import FileResource, FunctionResource, ResourceManager @pytest.fixture @@ -123,13 +116,142 @@ class TestFileResource: temp_file.chmod(0o644) # Restore permissions +class TestFunctionResource: + """Test FunctionResource functionality.""" + + def test_function_resource_creation(self): + """Test creating a FunctionResource.""" + + def my_func(x: str = "") -> str: + return f"Content: {x}" + + resource = FunctionResource( + uri="fn://test", + name="test", + description="test function", + mime_type="text/plain", + func=my_func, + ) + assert resource.uri == "fn://test" + assert resource.name == "test" + assert resource.description == "test function" + assert resource.mime_type == "text/plain" + assert resource.func == my_func + + def test_function_resource_invalid_uri(self): + """Test FunctionResource rejects invalid URIs.""" + + def my_func() -> str: + return "test" + + with pytest.raises(ValueError, match="URI must start with fn://"): + FunctionResource( + uri="invalid://test", + name="test", + func=my_func, + ) + + async def test_function_resource_read_no_params(self): + """Test reading a FunctionResource with no parameters.""" + + def my_func() -> str: + return "test content" + + resource = FunctionResource( + uri="fn://test", + name="test", + func=my_func, + ) + content = await resource.read() + assert content == "test content" + + async def test_function_resource_read_with_params(self): + """Test reading a FunctionResource with query parameters.""" + + def my_func(x: str, y: str) -> str: + return f"x={x}, y={y}" + + resource = FunctionResource( + uri="fn://test?x=1&y=2", + name="test", + func=my_func, + ) + content = await resource.read() + assert content == "x=1, y=2" + + async def test_function_resource_read_returns_resource(self, temp_file: Path): + """Test reading a FunctionResource that returns another Resource.""" + + def my_func(name: str = "test") -> FileResource: + return FileResource( + uri=f"file://{temp_file}", + name=name, + path=temp_file, + ) + + resource = FunctionResource( + uri="fn://test?name=example", + name="test", + func=my_func, + ) + content = await resource.read() + assert content == "test content" + + async def test_function_resource_read_error(self): + """Test error handling when reading a FunctionResource.""" + + def my_func(x: str) -> str: + raise ValueError(f"test error: {x}") + + resource = FunctionResource( + uri="fn://test?x=bad", + name="test", + func=my_func, + ) + with pytest.raises( + ValueError, match="Error calling function my_func: test error: bad" + ): + await resource.read() + + def test_parse_uri_params(self): + """Test parsing URI parameters.""" + + def my_func() -> str: + return "test" + + resource = FunctionResource( + uri="fn://test?x=1&y=hello&z=true", + name="test", + func=my_func, + ) + params = resource._parse_uri_params() + assert params == { + "x": "1", + "y": "hello", + "z": "true", + } + + def test_parse_uri_no_params(self): + """Test parsing URI with no parameters.""" + + def my_func() -> str: + return "test" + + resource = FunctionResource( + uri="fn://test", + name="test", + func=my_func, + ) + params = resource._parse_uri_params() + assert params == {} + + class TestResourceManagerAdd: """Test ResourceManager add functionality.""" - def test_add_file_resource( - self, resource_manager: ResourceManager, temp_file: Path - ): + def test_add_file_resource(self, temp_file: Path): """Test adding a file resource.""" + manager = ResourceManager() resource = FileResource( uri=f"file://{temp_file}", name="test", @@ -137,7 +259,7 @@ class TestResourceManagerAdd: mime_type="text/plain", path=temp_file, ) - added = resource_manager.add_resource(resource) + added = manager.add_resource(resource) assert isinstance(added, FileResource) assert added.uri == f"file://{temp_file}" assert added.name == "test" @@ -145,65 +267,90 @@ class TestResourceManagerAdd: assert added.mime_type == "text/plain" assert added.path == temp_file - def test_add_file_resource_relative_path_error( - self, resource_manager: ResourceManager - ): + def test_add_file_resource_relative_path_error(self): """Test ResourceManager rejects relative paths.""" with pytest.raises(ValueError, match="Path must be absolute"): - resource = FileResource( - uri="file://test.txt", + FileResource( + uri="file:///test.txt", name="test", path=Path("test.txt"), ) - resource_manager.add_resource(resource) + + def test_warn_on_duplicate_resources(self): + """Test warning on duplicate resources.""" + manager = ResourceManager() + resource = FileResource( + uri="file:///test.txt", + name="test", + path=Path("/test.txt"), + ) + manager.add_resource(resource) + with pytest.warns(ResourceWarning): + manager.add_resource(resource) + + def test_disable_warn_on_duplicate_resources(self): + """Test disabling warning on duplicate resources.""" + manager = ResourceManager() + resource = FileResource( + uri="file:///test.txt", + name="test", + path=Path("/test.txt"), + ) + manager.add_resource(resource) + manager.warn_on_duplicate_resources = False + with warnings.catch_warnings(): + warnings.simplefilter("error") + manager.add_resource(resource) class TestResourceManagerRead: """Test ResourceManager read functionality.""" - def test_get_resource_unknown_uri(self, resource_manager: ResourceManager): + def test_get_resource_unknown_uri(self): """Test getting a non-existent resource.""" + manager = ResourceManager() with pytest.raises(ValueError, match="Unknown resource"): - resource_manager.get_resource("file://unknown") + manager.get_resource("file://unknown") - def test_get_resource(self, resource_manager: ResourceManager, temp_file: Path): + def test_get_resource(self, temp_file: Path): """Test getting a resource by URI.""" + manager = ResourceManager() resource = FileResource( uri=f"file://{temp_file}", name="test", path=temp_file, ) - added = resource_manager.add_resource(resource) - retrieved = resource_manager.get_resource(added.uri) + added = manager.add_resource(resource) + retrieved = manager.get_resource(added.uri) assert retrieved == added - async def test_resource_read_through_manager( - self, resource_manager: ResourceManager, temp_file: Path - ): + async def test_resource_read_through_manager(self, temp_file: Path): """Test reading a resource through the manager.""" + manager = ResourceManager() resource = FileResource( uri=f"file://{temp_file}", name="test", path=temp_file, ) - added = resource_manager.add_resource(resource) - retrieved = resource_manager.get_resource(added.uri) + added = manager.add_resource(resource) + retrieved = manager.get_resource(added.uri) assert retrieved is not None content = await retrieved.read() assert content == "test content" async def test_resource_read_error_through_manager( - self, resource_manager: ResourceManager, temp_file_no_cleanup: Path + self, temp_file_no_cleanup: Path ): """Test error handling when reading through manager.""" + manager = ResourceManager() # Create resource while file exists resource = FileResource( uri=f"file://{temp_file_no_cleanup}", name="test", path=temp_file_no_cleanup, ) - added = resource_manager.add_resource(resource) - retrieved = resource_manager.get_resource(added.uri) + added = manager.add_resource(resource) + retrieved = manager.get_resource(added.uri) assert retrieved is not None # Delete file and verify read fails @@ -215,42 +362,38 @@ class TestResourceManagerRead: class TestResourceManagerList: """Test ResourceManager list functionality.""" - def test_list_resources(self, resource_manager: ResourceManager, temp_file: Path): + def test_list_resources(self, temp_file: Path): """Test listing all resources.""" + manager = ResourceManager() resource = FileResource( uri=f"file://{temp_file}", name="test", path=temp_file, ) - added = resource_manager.add_resource(resource) - resources = resource_manager.list_resources() + added = manager.add_resource(resource) + resources = manager.list_resources() assert len(resources) == 1 assert resources[0] == added - def test_list_resources_duplicate( - self, resource_manager: ResourceManager, temp_file: Path - ): + def test_list_resources_duplicate(self, temp_file: Path): """Test that adding the same resource twice only stores it once.""" + manager = ResourceManager() resource = FileResource( uri=f"file://{temp_file}", name="test", path=temp_file, ) - resource1 = resource_manager.add_resource(resource) - resource2 = resource_manager.add_resource(resource) + resource1 = manager.add_resource(resource) + resource2 = manager.add_resource(resource) - resources = resource_manager.list_resources() + resources = manager.list_resources() assert len(resources) == 1 assert resources[0] == resource1 assert resource1 == resource2 - def test_list_multiple_resources( - self, - resource_manager: ResourceManager, - temp_file: Path, - temp_file_no_cleanup: Path, - ): + def test_list_multiple_resources(self, temp_file: Path, temp_file_no_cleanup: Path): """Test listing multiple different resources.""" + manager = ResourceManager() resource1 = FileResource( uri=f"file://{temp_file}", name="test1", @@ -261,10 +404,10 @@ class TestResourceManagerList: name="test2", path=temp_file_no_cleanup, ) - added1 = resource_manager.add_resource(resource1) - added2 = resource_manager.add_resource(resource2) + added1 = manager.add_resource(resource1) + added2 = manager.add_resource(resource2) - resources = resource_manager.list_resources() + resources = manager.list_resources() assert len(resources) == 2 assert resources[0] == added1 assert resources[1] == added2 diff --git a/tests/test_server.py b/tests/test_server.py index ac9e119dd..cde7871b4 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,23 +1,32 @@ -from mcp.shared.memory import create_connected_server_and_client_session +from mcp.shared.memory import ( + create_connected_server_and_client_session as client_session, +) from fastmcp.server import FastMCPServer -async def test_list_tools(): - server = FastMCPServer("test_server") - server.add_tool(lambda x: x) - async with create_connected_server_and_client_session( - server._mcp_server - ) as client_session: - tools = await client_session.list_tools() - assert len(tools.tools) == 1 +class TestServer: + async def test_create_server(self): + server = FastMCPServer() + assert server.name == "FastMCPServer" -async def test_call_tool(): - server = FastMCPServer("test_server") - server.add_tool(lambda x: x) - async with create_connected_server_and_client_session( - server._mcp_server - ) as client_session: - result = await client_session.call_tool("my_tool", {"arg1": "value"}) - assert "error" not in result - assert len(result.content) > 0 +class TestServerTools: + async def test_add_tool(self): + server = FastMCPServer() + server.add_tool(lambda x: x) + assert len(server._tool_manager.list_tools()) == 1 + + async def test_list_tools(self): + server = FastMCPServer() + server.add_tool(lambda x: x) + async with client_session(server._mcp_server) as client: + tools = await client.list_tools() + assert len(tools.tools) == 1 + + async def test_call_tool(self): + server = FastMCPServer() + server.add_tool(lambda x: x) + async with client_session(server._mcp_server) as client: + result = await client.call_tool("my_tool", {"arg1": "value"}) + assert "error" not in result + assert len(result.content) > 0 diff --git a/tests/test_tool_manager.py b/tests/test_tool_manager.py index 16f244231..3018029fc 100644 --- a/tests/test_tool_manager.py +++ b/tests/test_tool_manager.py @@ -1,4 +1,4 @@ -"""Test tool registration and execution.""" +import warnings import pytest from pydantic import BaseModel @@ -71,6 +71,35 @@ class TestAddTools: with pytest.raises(AttributeError): manager.add_tool(1) + def test_add_lambda(self): + manager = ToolManager() + manager.add_tool(lambda x: x) + assert len(manager.list_tools()) == 1 + + def test_warn_on_duplicate_tools(self): + """Test warning on duplicate tools.""" + + def f(x: int) -> int: + return x + + manager = ToolManager() + manager.add_tool(f) + with pytest.warns(ResourceWarning): + manager.add_tool(f) + + def test_disable_warn_on_duplicate_tools(self): + """Test disabling warning on duplicate tools.""" + + def f(x: int) -> int: + return x + + manager = ToolManager() + manager.add_tool(f) + manager.warn_on_duplicate_tools = False + with warnings.catch_warnings(): + warnings.simplefilter("error") + manager.add_tool(f) + class TestCallTools: async def test_call_tool(self): diff --git a/uv.lock b/uv.lock index 7c9e5d16f..11ef22589 100644 --- a/uv.lock +++ b/uv.lock @@ -222,12 +222,13 @@ wheels = [ [[package]] name = "fastmcp" -version = "0.1.dev0+d20241129" +version = "0.1.dev4+g18aaf41.d20241129" source = { editable = "." } dependencies = [ { name = "httpx" }, { name = "mcp" }, { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "typer" }, ] @@ -245,6 +246,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.26.0" }, { name = "mcp", specifier = ">=1.0.0" }, { name = "pydantic", specifier = ">=2.5.3" }, + { name = "pydantic-settings", specifier = ">=2.6.1" }, { name = "typer", specifier = ">=0.9.0" }, ] @@ -620,6 +622,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/72/f881b5e18fbb67cf2fb4ab253660de3c6899dbb2dba409d0b757e3559e3d/pydantic_core-2.27.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:981fb88516bd1ae8b0cbbd2034678a39dedc98752f264ac9bc5839d3923fa04c", size = 2001864 }, ] +[[package]] +name = "pydantic-settings" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/d4/9dfbe238f45ad8b168f5c96ee49a3df0598ce18a0795a983b419949ce65b/pydantic_settings-2.6.1.tar.gz", hash = "sha256:e0f92546d8a9923cb8941689abf85d6601a8c19a23e97a34b2964a2e3f813ca0", size = 75646 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/f9/ff95fd7d760af42f647ea87f9b8a383d891cdb5e5dbd4613edaeb094252a/pydantic_settings-2.6.1-py3-none-any.whl", hash = "sha256:7fb0637c786a558d3103436278a7c4f1cfd29ba8973238a50c5bb9a55387da87", size = 28595 }, +] + [[package]] name = "pygments" version = "2.18.0" @@ -676,6 +691,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/31/6607dab48616902f76885dfcf62c08d929796fc3b2d2318faf9fd54dbed9/pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b", size = 18024 }, ] +[[package]] +name = "python-dotenv" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, +] + [[package]] name = "regex" version = "2024.11.6"