mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-23 14:04:18 +02:00
Ensure that tools/templates/prompts are compatible with callable objects
This commit is contained in:
parent
2ab07cbcd1
commit
010170337f
13 changed files with 307 additions and 12 deletions
|
|
@ -48,6 +48,7 @@ dev = [
|
|||
"pytest>=8.3.3",
|
||||
"pytest-asyncio>=0.23.5",
|
||||
"pytest-cov>=6.1.1",
|
||||
"pytest-env>=1.1.5",
|
||||
"pytest-flakefinder",
|
||||
"pytest-report>=0.2.1",
|
||||
"pytest-timeout>=2.4.0",
|
||||
|
|
@ -84,6 +85,11 @@ asyncio_default_fixture_loop_scope = "session"
|
|||
asyncio_default_test_loop_scope = "session"
|
||||
filterwarnings = []
|
||||
timeout = 3
|
||||
env = [
|
||||
"FASTMCP_TEST_MODE=1",
|
||||
'D:FASTMCP_LOG_LEVEL=DEBUG',
|
||||
'D:FASTMCP_ENABLE_RICH_TRACEBACKS=0',
|
||||
]
|
||||
|
||||
[tool.pyright]
|
||||
include = ["src", "tests"]
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ class Prompt(BaseModel):
|
|||
"""
|
||||
from fastmcp.server.context import Context
|
||||
|
||||
func_name = name or fn.__name__
|
||||
func_name = name or getattr(fn, "__name__", None) or fn.__class__.__name__
|
||||
|
||||
if func_name == "<lambda>":
|
||||
raise ValueError("You must provide a name for lambda functions")
|
||||
|
|
@ -109,6 +109,12 @@ class Prompt(BaseModel):
|
|||
if param.kind == inspect.Parameter.VAR_KEYWORD:
|
||||
raise ValueError("Functions with **kwargs are not supported as prompts")
|
||||
|
||||
description = description or fn.__doc__
|
||||
|
||||
# if the fn is a callable class, we need to get the __call__ method from here out
|
||||
if not inspect.isfunction(fn):
|
||||
fn = fn.__call__
|
||||
|
||||
type_adapter = get_cached_typeadapter(fn)
|
||||
parameters = type_adapter.json_schema()
|
||||
|
||||
|
|
@ -139,7 +145,7 @@ class Prompt(BaseModel):
|
|||
|
||||
return cls(
|
||||
name=func_name,
|
||||
description=description or fn.__doc__,
|
||||
description=description,
|
||||
arguments=arguments,
|
||||
fn=fn,
|
||||
tags=tags or set(),
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ from pydantic import (
|
|||
BaseModel,
|
||||
BeforeValidator,
|
||||
Field,
|
||||
TypeAdapter,
|
||||
field_validator,
|
||||
validate_call,
|
||||
)
|
||||
|
|
@ -25,6 +24,7 @@ from fastmcp.utilities.json_schema import compress_schema
|
|||
from fastmcp.utilities.types import (
|
||||
_convert_set_defaults,
|
||||
find_kwarg_by_type,
|
||||
get_cached_typeadapter,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -97,7 +97,7 @@ class ResourceTemplate(BaseModel):
|
|||
"""Create a template from a function."""
|
||||
from fastmcp.server.context import Context
|
||||
|
||||
func_name = name or fn.__name__
|
||||
func_name = name or getattr(fn, "__name__", None) or fn.__class__.__name__
|
||||
if func_name == "<lambda>":
|
||||
raise ValueError("You must provide a name for lambda functions")
|
||||
|
||||
|
|
@ -148,8 +148,13 @@ class ResourceTemplate(BaseModel):
|
|||
f"URI parameters {uri_params} must be a subset of the function arguments: {func_params}"
|
||||
)
|
||||
|
||||
# Get schema from TypeAdapter - will fail if function isn't properly typed
|
||||
parameters = TypeAdapter(fn).json_schema()
|
||||
description = description or fn.__doc__ or ""
|
||||
|
||||
if not inspect.isfunction(fn):
|
||||
fn = fn.__call__
|
||||
|
||||
type_adapter = get_cached_typeadapter(fn)
|
||||
parameters = type_adapter.json_schema()
|
||||
|
||||
# compress the schema
|
||||
prune_params = [context_kwarg] if context_kwarg else None
|
||||
|
|
@ -161,7 +166,7 @@ class ResourceTemplate(BaseModel):
|
|||
return cls(
|
||||
uri_template=uri_template,
|
||||
name=func_name,
|
||||
description=description or fn.__doc__ or "",
|
||||
description=description,
|
||||
mime_type=mime_type or "text/plain",
|
||||
fn=fn,
|
||||
parameters=parameters,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,16 @@ class Settings(BaseSettings):
|
|||
|
||||
test_mode: bool = False
|
||||
log_level: LOG_LEVEL = "INFO"
|
||||
enable_rich_tracebacks: Annotated[
|
||||
bool,
|
||||
Field(
|
||||
description=inspect.cleandoc(
|
||||
"""
|
||||
If True, will use rich tracebacks for logging.
|
||||
"""
|
||||
)
|
||||
),
|
||||
] = True
|
||||
|
||||
client_raise_first_exceptiongroup_error: Annotated[
|
||||
bool,
|
||||
|
|
@ -82,7 +92,9 @@ class Settings(BaseSettings):
|
|||
"""Finalize the settings."""
|
||||
from fastmcp.utilities.logging import configure_logging
|
||||
|
||||
configure_logging(self.log_level)
|
||||
configure_logging(
|
||||
self.log_level, enable_rich_tracebacks=self.enable_rich_tracebacks
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
|
|
|
|||
|
|
@ -69,13 +69,17 @@ class Tool(BaseModel):
|
|||
if param.kind == inspect.Parameter.VAR_KEYWORD:
|
||||
raise ValueError("Functions with **kwargs are not supported as tools")
|
||||
|
||||
func_name = name or fn.__name__
|
||||
func_name = name or getattr(fn, "__name__", None) or fn.__class__.__name__
|
||||
|
||||
if func_name == "<lambda>":
|
||||
raise ValueError("You must provide a name for lambda functions")
|
||||
|
||||
func_doc = description or fn.__doc__ or ""
|
||||
|
||||
# if the fn is a callable class, we need to get the __call__ method from here out
|
||||
if not inspect.isfunction(fn):
|
||||
fn = fn.__call__
|
||||
|
||||
type_adapter = get_cached_typeadapter(fn)
|
||||
schema = type_adapter.json_schema()
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ def get_logger(name: str) -> logging.Logger:
|
|||
def configure_logging(
|
||||
level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | int = "INFO",
|
||||
logger: logging.Logger | None = None,
|
||||
enable_rich_tracebacks: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Configure logging for FastMCP.
|
||||
|
|
@ -30,11 +31,15 @@ def configure_logging(
|
|||
logger: the logger to configure
|
||||
level: the log level to use
|
||||
"""
|
||||
|
||||
if logger is None:
|
||||
logger = logging.getLogger("FastMCP")
|
||||
|
||||
# Only configure the FastMCP logger namespace
|
||||
handler = RichHandler(console=Console(stderr=True), rich_tracebacks=True)
|
||||
handler = RichHandler(
|
||||
console=Console(stderr=True),
|
||||
rich_tracebacks=enable_rich_tracebacks,
|
||||
)
|
||||
formatter = logging.Formatter("%(message)s")
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,30 @@ class TestRenderPrompt:
|
|||
)
|
||||
]
|
||||
|
||||
async def test_callable_object(self):
|
||||
class MyPrompt:
|
||||
def __call__(self, name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
prompt = Prompt.from_function(MyPrompt())
|
||||
assert await prompt.render(arguments=dict(name="World")) == [
|
||||
PromptMessage(
|
||||
role="user", content=TextContent(type="text", text="Hello, World!")
|
||||
)
|
||||
]
|
||||
|
||||
async def test_async_callable_object(self):
|
||||
class MyPrompt:
|
||||
async def __call__(self, name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
prompt = Prompt.from_function(MyPrompt())
|
||||
assert await prompt.render(arguments=dict(name="World")) == [
|
||||
PromptMessage(
|
||||
role="user", content=TextContent(type="text", text="Hello, World!")
|
||||
)
|
||||
]
|
||||
|
||||
async def test_fn_with_invalid_kwargs(self):
|
||||
async def fn(name: str, age: int = 30) -> str:
|
||||
return f"Hello, {name}! You're {age} years old."
|
||||
|
|
|
|||
|
|
@ -141,6 +141,8 @@ class TestPromptManager:
|
|||
assert prompts["fn1"] == prompt1
|
||||
assert prompts["fn2"] == prompt2
|
||||
|
||||
|
||||
class TestRenderPrompt:
|
||||
async def test_render_prompt(self):
|
||||
"""Test rendering a prompt."""
|
||||
|
||||
|
|
@ -177,6 +179,48 @@ class TestPromptManager:
|
|||
)
|
||||
]
|
||||
|
||||
async def test_render_prompt_callable_object(self):
|
||||
"""Test rendering a prompt with a callable object."""
|
||||
|
||||
class MyPrompt:
|
||||
"""A callable object that can be used as a prompt."""
|
||||
|
||||
def __call__(self, name: str) -> str:
|
||||
"""ignore this"""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
manager = PromptManager()
|
||||
prompt = Prompt.from_function(MyPrompt())
|
||||
manager.add_prompt(prompt)
|
||||
result = await manager.render_prompt("MyPrompt", arguments={"name": "World"})
|
||||
assert result.description == "A callable object that can be used as a prompt."
|
||||
assert result.messages == [
|
||||
PromptMessage(
|
||||
role="user", content=TextContent(type="text", text="Hello, World!")
|
||||
)
|
||||
]
|
||||
|
||||
async def test_render_prompt_callable_object_async(self):
|
||||
"""Test rendering a prompt with a callable object."""
|
||||
|
||||
class MyPrompt:
|
||||
"""A callable object that can be used as a prompt."""
|
||||
|
||||
async def __call__(self, name: str) -> str:
|
||||
"""ignore this"""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
manager = PromptManager()
|
||||
prompt = Prompt.from_function(MyPrompt())
|
||||
manager.add_prompt(prompt)
|
||||
result = await manager.render_prompt("MyPrompt", arguments={"name": "World"})
|
||||
assert result.description == "A callable object that can be used as a prompt."
|
||||
assert result.messages == [
|
||||
PromptMessage(
|
||||
role="user", content=TextContent(type="text", text="Hello, World!")
|
||||
)
|
||||
]
|
||||
|
||||
async def test_render_unknown_prompt(self):
|
||||
"""Test rendering a non-existent prompt."""
|
||||
manager = PromptManager()
|
||||
|
|
|
|||
|
|
@ -368,6 +368,31 @@ class TestResourceTemplate:
|
|||
)
|
||||
assert template.uri_template == "test://{x}/{y}/{z}"
|
||||
|
||||
async def test_callable_object_as_template(self):
|
||||
"""Test that a callable object can be used as a template."""
|
||||
|
||||
class MyTemplate:
|
||||
"""This is my template"""
|
||||
|
||||
def __call__(self, x: str) -> str:
|
||||
"""ignore this"""
|
||||
return f"X was {x}"
|
||||
|
||||
template = ResourceTemplate.from_function(
|
||||
fn=MyTemplate(),
|
||||
uri_template="test://{x}",
|
||||
name="test",
|
||||
)
|
||||
|
||||
resource = await template.create_resource(
|
||||
"test://foo",
|
||||
{"x": "foo"},
|
||||
)
|
||||
|
||||
assert isinstance(resource, FunctionResource)
|
||||
content = await resource.read()
|
||||
assert content == "X was foo"
|
||||
|
||||
|
||||
class TestMatchUriTemplate:
|
||||
"""Test match_uri_template function."""
|
||||
|
|
|
|||
|
|
@ -709,6 +709,21 @@ class TestToolContextInjection:
|
|||
assert len(tools) == 1
|
||||
# Note: MCPTool from the client API doesn't expose tags
|
||||
|
||||
async def test_callable_object_with_context(self):
|
||||
"""Test that a callable object can be used as a tool with context."""
|
||||
mcp = FastMCP()
|
||||
|
||||
class MyTool:
|
||||
async def __call__(self, x: int, ctx: Context) -> int:
|
||||
return x + int(ctx.request_id)
|
||||
|
||||
mcp.add_tool(MyTool())
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("MyTool", {"x": 2})
|
||||
assert isinstance(result[0], TextContent)
|
||||
assert result[0].text == "4"
|
||||
|
||||
|
||||
class TestResource:
|
||||
async def test_text_resource(self):
|
||||
|
|
@ -1096,6 +1111,20 @@ class TestResourceTemplateContext:
|
|||
assert isinstance(result[0], TextResourceContents)
|
||||
assert result[0].text.startswith("Resource template: test 2")
|
||||
|
||||
async def test_resource_template_context_with_callable_object(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
class MyResource:
|
||||
def __call__(self, param: str, ctx: Context) -> str:
|
||||
return f"Resource template: {param} {ctx.request_id}"
|
||||
|
||||
mcp.add_resource_fn(MyResource(), uri="resource://{param}")
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource(AnyUrl("resource://test"))
|
||||
assert isinstance(result[0], TextResourceContents)
|
||||
assert result[0].text.startswith("Resource template: test 2")
|
||||
|
||||
|
||||
class TestPrompts:
|
||||
"""Test prompt functionality in FastMCP server."""
|
||||
|
|
@ -1298,3 +1327,20 @@ class TestPromptContext:
|
|||
assert len(result.messages) == 1
|
||||
message = result.messages[0]
|
||||
assert message.role == "user"
|
||||
|
||||
async def test_prompt_context_with_callable_object(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
class MyPrompt:
|
||||
def __call__(self, name: str, ctx: Context) -> str:
|
||||
return f"Hello, {name}! {ctx.request_id}"
|
||||
|
||||
mcp.add_prompt(MyPrompt(), name="my_prompt")
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.get_prompt("my_prompt", {"name": "World"})
|
||||
assert len(result.messages) == 1
|
||||
message = result.messages[0]
|
||||
assert message.role == "user"
|
||||
assert isinstance(message.content, TextContent)
|
||||
assert message.content.text == "Hello, World! 2"
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ class TestToolFromFunction:
|
|||
|
||||
assert tool.name == "add"
|
||||
assert tool.description == "Add two numbers."
|
||||
assert len(tool.parameters["properties"]) == 2
|
||||
assert tool.parameters["properties"]["a"]["type"] == "integer"
|
||||
assert tool.parameters["properties"]["b"]["type"] == "integer"
|
||||
|
||||
|
|
@ -37,6 +38,36 @@ class TestToolFromFunction:
|
|||
assert tool.description == "Fetch data from URL."
|
||||
assert tool.parameters["properties"]["url"]["type"] == "string"
|
||||
|
||||
def test_callable_object(self):
|
||||
class Adder:
|
||||
"""Adds two numbers."""
|
||||
|
||||
def __call__(self, x: int, y: int) -> int:
|
||||
"""ignore this"""
|
||||
return x + y
|
||||
|
||||
tool = Tool.from_function(Adder())
|
||||
assert tool.name == "Adder"
|
||||
assert tool.description == "Adds two numbers."
|
||||
assert len(tool.parameters["properties"]) == 2
|
||||
assert tool.parameters["properties"]["x"]["type"] == "integer"
|
||||
assert tool.parameters["properties"]["y"]["type"] == "integer"
|
||||
|
||||
def test_async_callable_object(self):
|
||||
class Adder:
|
||||
"""Adds two numbers."""
|
||||
|
||||
async def __call__(self, x: int, y: int) -> int:
|
||||
"""ignore this"""
|
||||
return x + y
|
||||
|
||||
tool = Tool.from_function(Adder())
|
||||
assert tool.name == "Adder"
|
||||
assert tool.description == "Adds two numbers."
|
||||
assert len(tool.parameters["properties"]) == 2
|
||||
assert tool.parameters["properties"]["x"]["type"] == "integer"
|
||||
assert tool.parameters["properties"]["y"]["type"] == "integer"
|
||||
|
||||
def test_pydantic_model_function(self):
|
||||
"""Test registering a function that takes a Pydantic model."""
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,44 @@ class TestAddTools:
|
|||
assert "age" in tool.parameters["$defs"]["UserInput"]["properties"]
|
||||
assert "flag" in tool.parameters["properties"]
|
||||
|
||||
def test_callable_object(self):
|
||||
class Adder:
|
||||
"""Adds two numbers."""
|
||||
|
||||
def __call__(self, x: int, y: int) -> int:
|
||||
"""ignore this"""
|
||||
return x + y
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool_from_fn(Adder())
|
||||
|
||||
tool = manager.get_tool("Adder")
|
||||
assert tool is not None
|
||||
assert tool.name == "Adder"
|
||||
assert tool.description == "Adds two numbers."
|
||||
assert len(tool.parameters["properties"]) == 2
|
||||
assert tool.parameters["properties"]["x"]["type"] == "integer"
|
||||
assert tool.parameters["properties"]["y"]["type"] == "integer"
|
||||
|
||||
def test_async_callable_object(self):
|
||||
class Adder:
|
||||
"""Adds two numbers."""
|
||||
|
||||
async def __call__(self, x: int, y: int) -> int:
|
||||
"""ignore this"""
|
||||
return x + y
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool_from_fn(Adder())
|
||||
|
||||
tool = manager.get_tool("Adder")
|
||||
assert tool is not None
|
||||
assert tool.name == "Adder"
|
||||
assert tool.description == "Adds two numbers."
|
||||
assert len(tool.parameters["properties"]) == 2
|
||||
assert tool.parameters["properties"]["x"]["type"] == "integer"
|
||||
assert tool.parameters["properties"]["y"]["type"] == "integer"
|
||||
|
||||
async def test_tool_with_image_return(self):
|
||||
def image_tool(data: bytes) -> Image:
|
||||
return Image(data=data)
|
||||
|
|
@ -303,6 +341,40 @@ class TestCallTools:
|
|||
assert result[0].text == "10"
|
||||
assert json.loads(result[0].text) == 10
|
||||
|
||||
async def test_call_tool_callable_object(self):
|
||||
class Adder:
|
||||
"""Adds two numbers."""
|
||||
|
||||
def __call__(self, x: int, y: int) -> int:
|
||||
"""ignore this"""
|
||||
return x + y
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool_from_fn(Adder())
|
||||
result = await manager.call_tool("Adder", {"x": 1, "y": 2})
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], TextContent)
|
||||
assert result[0].text == "3"
|
||||
assert json.loads(result[0].text) == 3
|
||||
|
||||
async def test_call_tool_callable_object_async(self):
|
||||
class Adder:
|
||||
"""Adds two numbers."""
|
||||
|
||||
async def __call__(self, x: int, y: int) -> int:
|
||||
"""ignore this"""
|
||||
return x + y
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool_from_fn(Adder())
|
||||
result = await manager.call_tool("Adder", {"x": 1, "y": 2})
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], TextContent)
|
||||
assert result[0].text == "3"
|
||||
assert json.loads(result[0].text) == 3
|
||||
|
||||
async def test_call_tool_with_default_args(self):
|
||||
def add(a: int, b: int = 1) -> int:
|
||||
"""Add two numbers."""
|
||||
|
|
|
|||
19
uv.lock
generated
19
uv.lock
generated
|
|
@ -329,6 +329,7 @@ dev = [
|
|||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-env" },
|
||||
{ name = "pytest-flakefinder" },
|
||||
{ name = "pytest-report" },
|
||||
{ name = "pytest-timeout" },
|
||||
|
|
@ -360,6 +361,7 @@ dev = [
|
|||
{ name = "pytest", specifier = ">=8.3.3" },
|
||||
{ name = "pytest-asyncio", specifier = ">=0.23.5" },
|
||||
{ name = "pytest-cov", specifier = ">=6.1.1" },
|
||||
{ name = "pytest-env", specifier = ">=1.1.5" },
|
||||
{ name = "pytest-flakefinder" },
|
||||
{ name = "pytest-report", specifier = ">=0.2.1" },
|
||||
{ name = "pytest-timeout", specifier = ">=2.4.0" },
|
||||
|
|
@ -586,9 +588,9 @@ dependencies = [
|
|||
{ name = "starlette" },
|
||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bc/8d/0f4468582e9e97b0a24604b585c651dfd2144300ecffd1c06a680f5c8861/mcp-1.9.0.tar.gz", hash = "sha256:905d8d208baf7e3e71d70c82803b89112e321581bcd2530f9de0fe4103d28749", size = 281432 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bc/8d/0f4468582e9e97b0a24604b585c651dfd2144300ecffd1c06a680f5c8861/mcp-1.9.0.tar.gz", hash = "sha256:905d8d208baf7e3e71d70c82803b89112e321581bcd2530f9de0fe4103d28749", size = 281432, upload-time = "2025-05-15T18:51:06.615Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/d5/22e36c95c83c80eb47c83f231095419cf57cf5cca5416f1c960032074c78/mcp-1.9.0-py3-none-any.whl", hash = "sha256:9dfb89c8c56f742da10a5910a1f64b0d2ac2c3ed2bd572ddb1cfab7f35957178", size = 125082 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/d5/22e36c95c83c80eb47c83f231095419cf57cf5cca5416f1c960032074c78/mcp-1.9.0-py3-none-any.whl", hash = "sha256:9dfb89c8c56f742da10a5910a1f64b0d2ac2c3ed2bd572ddb1cfab7f35957178", size = 125082, upload-time = "2025-05-15T18:51:04.916Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -941,6 +943,19 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841, upload-time = "2025-04-05T14:07:49.641Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-env"
|
||||
version = "1.1.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/31/27f28431a16b83cab7a636dce59cf397517807d247caa38ee67d65e71ef8/pytest_env-1.1.5.tar.gz", hash = "sha256:91209840aa0e43385073ac464a554ad2947cc2fd663a9debf88d03b01e0cc1cf", size = 8911, upload-time = "2024-09-17T22:39:18.566Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/b8/87cfb16045c9d4092cfcf526135d73b88101aac83bc1adcf82dfb5fd3833/pytest_env-1.1.5-py3-none-any.whl", hash = "sha256:ce90cf8772878515c24b31cd97c7fa1f4481cd68d588419fd45f10ecaee6bc30", size = 6141, upload-time = "2024-09-17T22:39:16.942Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-flakefinder"
|
||||
version = "1.1.0"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue