Add duplicate tests

This commit is contained in:
Jeremiah Lowin 2024-11-29 17:47:54 -05:00
commit 156ecc8bae
9 changed files with 477 additions and 92 deletions

View file

@ -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",
]

View file

@ -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')

View file

@ -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

View file

@ -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,
)

View file

@ -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."""

View file

@ -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

View file

@ -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

View file

@ -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):

26
uv.lock generated
View file

@ -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"