From c49af4ff3433790e781b6c7a2369c043d9adb2d6 Mon Sep 17 00:00:00 2001 From: Tyler Payne Date: Sat, 11 Oct 2025 11:13:42 -0400 Subject: [PATCH 1/2] Add Tool Schema Flatness (TSF) constraint The "flatness" (i.e. lack of nested structured objects/lists) of a tool's inputSchema can dramatically impact an agent's tool-calling accuracy. This constraint reports a warning if any of your server's tools have nested inputSchemas. See: https://composio.dev/blog/gpt-4-function-calling-example --- pyproject.toml | 1 + src/mcp_interviewer/cli.py | 2 +- src/mcp_interviewer/constraints/__init__.py | 3 + .../constraints/tool_schema_flatness.py | 133 ++++++++++++++++++ uv.lock | 2 + 5 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 src/mcp_interviewer/constraints/tool_schema_flatness.py diff --git a/pyproject.toml b/pyproject.toml index a857d86..d7eb95a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "mcp>=1.10.1", "openai>=1.93.3", "tiktoken>=0.11.0", + "jsonschema>=4.0.0", ] [dependency-groups] diff --git a/src/mcp_interviewer/cli.py b/src/mcp_interviewer/cli.py index 90b3ae1..86130b5 100644 --- a/src/mcp_interviewer/cli.py +++ b/src/mcp_interviewer/cli.py @@ -74,7 +74,7 @@ def cli(): parser.add_argument( "--constraints", nargs="+", - help="Specify which constraint violations to check (all enabled by default). Can use full names (e.g., openai-tool-count, openai-name-length) or shorthand codes (e.g., OTC, ONL, ONP, OTL, OA)", + help="Specify which constraint violations to check (all enabled by default). Can use full names (e.g., openai-tool-count, openai-name-length, tool-schema-flatness) or shorthand codes (e.g., OTC, ONL, ONP, OTL, TSF, OA)", ) parser.add_argument( "--test", diff --git a/src/mcp_interviewer/constraints/__init__.py b/src/mcp_interviewer/constraints/__init__.py index aa026d5..820d154 100644 --- a/src/mcp_interviewer/constraints/__init__.py +++ b/src/mcp_interviewer/constraints/__init__.py @@ -13,6 +13,7 @@ OpenAIToolNamePatternConstraint, OpenAIToolResultTokenLengthConstraint, ) +from .tool_schema_flatness import ToolInputSchemaFlatnessConstraint class AllConstraints(CompositeConstraint): @@ -26,6 +27,7 @@ def __init__(self): """Initialize with all available constraint sets.""" super().__init__( OpenAIConstraints(), + ToolInputSchemaFlatnessConstraint(), ) @@ -35,6 +37,7 @@ def __init__(self): OpenAIToolNameLengthConstraint, OpenAIToolNamePatternConstraint, OpenAIToolResultTokenLengthConstraint, + ToolInputSchemaFlatnessConstraint, ] # Create mappings for names and codes diff --git a/src/mcp_interviewer/constraints/tool_schema_flatness.py b/src/mcp_interviewer/constraints/tool_schema_flatness.py new file mode 100644 index 0000000..7268dc6 --- /dev/null +++ b/src/mcp_interviewer/constraints/tool_schema_flatness.py @@ -0,0 +1,133 @@ +from collections.abc import Generator +from typing import Any + +from jsonschema import RefResolver +from mcp import Tool + +from mcp_interviewer.constraints.base import ConstraintViolation, Severity + +from .base import ToolConstraint + + +class ToolInputSchemaFlatnessConstraint(ToolConstraint): + """Validates that tool input schemas are flat (no nested objects or arrays). + + Nested structures in tool schemas can make them difficult to understand and use. + This constraint ensures that the inputSchema doesn't contain: + - Nested "properties" fields (objects within objects) + - Nested arrays (arrays of arrays) + + A flat schema has all parameters at the top level. Arrays of primitives and + unions (oneOf/anyOf/allOf) are allowed. + """ + + @classmethod + def cli_name(cls) -> str: + """Return the CLI-friendly name for this constraint.""" + return "tool-schema-flatness" + + @classmethod + def cli_code(cls) -> str: + """Return the shorthand code for this constraint.""" + return "TSF" + + def test_tool(self, tool: Tool) -> Generator[ConstraintViolation, None, None]: + """Test if the tool's inputSchema has nested properties fields. + + Args: + tool: The tool to validate + + Yields: + ConstraintViolation: Warning if inputSchema contains nested "properties" fields + """ + # Create a resolver for handling $ref references + resolver = RefResolver.from_schema(tool.inputSchema) + + def has_nested_structure( + obj: Any, + resolver: RefResolver, + depth: int = 0, + inside_array: bool = False, + visited: set[str] | None = None, + ) -> bool: + """Check if an object contains nested "properties" fields or nested arrays. + + Args: + obj: The object to check + resolver: JSON Schema reference resolver + depth: Current depth (0 = top level properties) + inside_array: Whether we're currently inside an array's items + visited: Set of visited $ref URLs to prevent infinite loops + + Returns: + True if nested structures found, False otherwise + """ + if not isinstance(obj, dict): + return False + + if visited is None: + visited = set() + + # If we're already inside a property definition and we find another "properties" field + if depth > 0 and "properties" in obj: + return True + + # If we're inside an array and we find another array type + if inside_array and obj.get("type") == "array": + return True + + # Check $ref + if "$ref" in obj: + ref_url = obj["$ref"] + # Prevent infinite loops from circular references + if ref_url in visited: + return False + visited.add(ref_url) + + try: + _, resolved = resolver.resolve(ref_url) + if isinstance(resolved, dict) and has_nested_structure( + resolved, resolver, depth, inside_array, visited + ): + return True + except Exception: + # If resolution fails, skip this ref + pass + + # Recursively check all values in the current object + for key, value in obj.items(): + if key == "properties" and depth == 0: + # This is the top-level properties, check its children at depth 1 + if isinstance(value, dict): + for prop_value in value.values(): + if has_nested_structure( + prop_value, resolver, depth + 1, inside_array, visited + ): + return True + elif key == "items": + # Check array items - set inside_array=True + if isinstance(value, dict): + if has_nested_structure(value, resolver, depth, True, visited): + return True + elif isinstance(value, dict): + # Check nested structures (like oneOf, anyOf, allOf, etc.) + if has_nested_structure( + value, resolver, depth, inside_array, visited + ): + return True + elif isinstance(value, list): + # Check each item in arrays (like oneOf, anyOf, allOf) + for item in value: + if isinstance(item, dict) and has_nested_structure( + item, resolver, depth, inside_array, visited + ): + return True + + return False + + if has_nested_structure(tool.inputSchema, resolver): + yield ConstraintViolation( + self, + f"Tool '{tool.name}': inputSchema contains nested structures (nested objects or arrays). Tool parameters should be flat.", + severity=Severity.WARNING, + ) diff --git a/uv.lock b/uv.lock index fc4ec69..ec7d780 100644 --- a/uv.lock +++ b/uv.lock @@ -325,6 +325,7 @@ name = "mcp-interviewer" version = "0.0.12" source = { editable = "." } dependencies = [ + { name = "jsonschema" }, { name = "mcp" }, { name = "openai" }, { name = "tiktoken" }, @@ -340,6 +341,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "jsonschema", specifier = ">=4.0.0" }, { name = "mcp", specifier = ">=1.10.1" }, { name = "openai", specifier = ">=1.93.3" }, { name = "tiktoken", specifier = ">=0.11.0" }, From ea302e2e79f1f6f33e9b24a6e3da5cf50ffdca41 Mon Sep 17 00:00:00 2001 From: Tyler Payne Date: Sat, 11 Oct 2025 11:16:56 -0400 Subject: [PATCH 2/2] Add tests for Tool Schema Flatness constraint --- pyproject.toml | 1 + .../constraints/test_tool_schema_flatness.py | 393 ++++++++++++++++++ uv.lock | 54 +++ 3 files changed, 448 insertions(+) create mode 100644 tests/constraints/test_tool_schema_flatness.py diff --git a/pyproject.toml b/pyproject.toml index d7eb95a..650dd07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dev = [ "pre-commit>=4.3.0", "pyright>=1.1.403", "poethepoet>=0.37.0", + "pytest>=8.4.2", ] [project.scripts] diff --git a/tests/constraints/test_tool_schema_flatness.py b/tests/constraints/test_tool_schema_flatness.py new file mode 100644 index 0000000..a9c2fe1 --- /dev/null +++ b/tests/constraints/test_tool_schema_flatness.py @@ -0,0 +1,393 @@ +"""Tests for ToolInputSchemaFlatnessConstraint.""" + +import pytest +from mcp import Tool + +from mcp_interviewer.constraints.tool_schema_flatness import ( + ToolInputSchemaFlatnessConstraint, +) + + +def test_flat_schema_passes(): + """Test that a flat schema with no nested properties passes.""" + tool = Tool( + name="test_tool", + description="A test tool", + inputSchema={ + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + "email": {"type": "string"}, + }, + }, + ) + + constraint = ToolInputSchemaFlatnessConstraint() + violations = list(constraint.test_tool(tool)) + assert len(violations) == 0 + + +def test_nested_properties_fails(): + """Test that a schema with nested properties fails.""" + tool = Tool( + name="test_tool", + description="A test tool", + inputSchema={ + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"}, + }, + }, + }, + }, + ) + + constraint = ToolInputSchemaFlatnessConstraint() + violations = list(constraint.test_tool(tool)) + assert len(violations) == 1 + assert "nested structures" in violations[0].message + + +def test_array_of_flat_objects_passes(): + """Test that an array of flat objects passes.""" + tool = Tool( + name="test_tool", + description="A test tool", + inputSchema={ + "type": "object", + "properties": { + "users": { + "type": "array", + "items": { + "type": "string", + }, + }, + }, + }, + ) + + constraint = ToolInputSchemaFlatnessConstraint() + violations = list(constraint.test_tool(tool)) + assert len(violations) == 0 + + +def test_array_with_nested_properties_fails(): + """Test that an array containing items with nested properties fails.""" + tool = Tool( + name="test_tool", + description="A test tool", + inputSchema={ + "type": "object", + "properties": { + "users": { + "type": "array", + "items": { + "type": "object", + "properties": { + "profile": { + "type": "object", + "properties": { + "name": {"type": "string"}, + }, + }, + }, + }, + }, + }, + }, + ) + + constraint = ToolInputSchemaFlatnessConstraint() + violations = list(constraint.test_tool(tool)) + assert len(violations) == 1 + + +def test_ref_to_object_with_properties_fails(): + """Test that a $ref to an object with properties is detected as nested.""" + tool = Tool( + name="test_tool", + description="A test tool", + inputSchema={ + "type": "object", + "properties": { + "user": {"$ref": "#/definitions/User"}, + }, + "definitions": { + "User": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"}, + }, + }, + }, + }, + ) + + constraint = ToolInputSchemaFlatnessConstraint() + violations = list(constraint.test_tool(tool)) + assert len(violations) == 1 + + +def test_ref_to_primitive_passes(): + """Test that a $ref to a primitive type definition passes.""" + tool = Tool( + name="test_tool", + description="A test tool", + inputSchema={ + "type": "object", + "properties": { + "userId": {"$ref": "#/definitions/UserId"}, + "status": {"$ref": "#/definitions/Status"}, + }, + "definitions": { + "UserId": {"type": "string", "format": "uuid"}, + "Status": {"type": "string", "enum": ["active", "inactive"]}, + }, + }, + ) + + constraint = ToolInputSchemaFlatnessConstraint() + violations = list(constraint.test_tool(tool)) + assert len(violations) == 0 + + +def test_ref_to_nested_definition_fails(): + """Test that a $ref to a definition with nested properties fails.""" + tool = Tool( + name="test_tool", + description="A test tool", + inputSchema={ + "type": "object", + "properties": { + "user": {"$ref": "#/definitions/User"}, + }, + "definitions": { + "User": { + "type": "object", + "properties": { + "profile": { + "type": "object", + "properties": { + "name": {"type": "string"}, + }, + }, + }, + }, + }, + }, + ) + + constraint = ToolInputSchemaFlatnessConstraint() + violations = list(constraint.test_tool(tool)) + assert len(violations) == 1 + + +def test_defs_keyword(): + """Test that $defs keyword (JSON Schema Draft 2019-09+) is handled.""" + tool = Tool( + name="test_tool", + description="A test tool", + inputSchema={ + "type": "object", + "properties": { + "user": {"$ref": "#/$defs/User"}, + }, + "$defs": { + "User": { + "type": "object", + "properties": { + "profile": { + "type": "object", + "properties": { + "name": {"type": "string"}, + }, + }, + }, + }, + }, + }, + ) + + constraint = ToolInputSchemaFlatnessConstraint() + violations = list(constraint.test_tool(tool)) + assert len(violations) == 1 + + +@pytest.mark.parametrize("union_keyword", ["oneOf", "anyOf", "allOf"]) +def test_union_with_flat_schemas_passes(union_keyword): + """Test that unions (oneOf/anyOf/allOf) with flat schemas pass.""" + tool = Tool( + name="test_tool", + description="A test tool", + inputSchema={ + "type": "object", + "properties": { + "value": { + union_keyword: [ + {"type": "string"}, + {"type": "integer"}, + ], + }, + }, + }, + ) + + constraint = ToolInputSchemaFlatnessConstraint() + violations = list(constraint.test_tool(tool)) + assert len(violations) == 0 + + +@pytest.mark.parametrize("union_keyword", ["oneOf", "anyOf", "allOf"]) +def test_union_with_nested_properties_fails(union_keyword): + """Test that unions (oneOf/anyOf/allOf) containing nested properties fail.""" + tool = Tool( + name="test_tool", + description="A test tool", + inputSchema={ + "type": "object", + "properties": { + "value": { + union_keyword: [ + { + "type": "object", + "properties": { + "nested": { + "type": "object", + "properties": { + "field": {"type": "string"}, + }, + }, + }, + }, + {"type": "string"}, + ], + }, + }, + }, + ) + + constraint = ToolInputSchemaFlatnessConstraint() + violations = list(constraint.test_tool(tool)) + assert len(violations) == 1 + + +def test_circular_ref_handled_gracefully(): + """Test that circular references are handled without infinite loops.""" + tool = Tool( + name="test_tool", + description="A test tool", + inputSchema={ + "type": "object", + "properties": { + "node": {"$ref": "#/definitions/Node"}, + }, + "definitions": { + "Node": { + "type": "object", + "properties": { + "value": {"type": "string"}, + "next": {"$ref": "#/definitions/Node"}, + }, + }, + }, + }, + ) + + constraint = ToolInputSchemaFlatnessConstraint() + # Should not hang or error, should detect nested properties + violations = list(constraint.test_tool(tool)) + assert len(violations) == 1 + + +def test_empty_schema_passes(): + """Test that an empty schema passes.""" + tool = Tool( + name="test_tool", + description="A test tool", + inputSchema={}, + ) + + constraint = ToolInputSchemaFlatnessConstraint() + violations = list(constraint.test_tool(tool)) + assert len(violations) == 0 + + +def test_no_properties_field_passes(): + """Test that a schema without properties passes.""" + tool = Tool( + name="test_tool", + description="A test tool", + inputSchema={ + "type": "object", + "additionalProperties": {"type": "string"}, + }, + ) + + constraint = ToolInputSchemaFlatnessConstraint() + violations = list(constraint.test_tool(tool)) + assert len(violations) == 0 + + +def test_nested_arrays_fails(): + """Test that nested arrays (arrays of arrays) fail.""" + tool = Tool( + name="test_tool", + description="A test tool", + inputSchema={ + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "array", + "items": {"type": "string"}, + }, + }, + }, + }, + }, + ) + + constraint = ToolInputSchemaFlatnessConstraint() + violations = list(constraint.test_tool(tool)) + assert len(violations) == 1 + assert "nested structures" in violations[0].message + + +def test_complex_schema_with_nested_properties(): + """Test a complex real-world-like schema with nested properties.""" + tool = Tool( + name="create_user", + description="Create a new user", + inputSchema={ + "type": "object", + "properties": { + "username": {"type": "string"}, + "metadata": { + "type": "object", + "properties": { + "preferences": { + "type": "object", + "properties": { + "theme": {"type": "string"}, + }, + }, + }, + }, + }, + "required": ["username"], + }, + ) + + constraint = ToolInputSchemaFlatnessConstraint() + violations = list(constraint.test_tool(tool)) + assert len(violations) == 1 + assert "create_user" in violations[0].message diff --git a/uv.lock b/uv.lock index ec7d780..1fc5882 100644 --- a/uv.lock +++ b/uv.lock @@ -212,6 +212,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + [[package]] name = "jiter" version = "0.10.0" @@ -336,6 +345,7 @@ dev = [ { name = "poethepoet" }, { name = "pre-commit" }, { name = "pyright" }, + { name = "pytest" }, { name = "ruff" }, ] @@ -352,6 +362,7 @@ dev = [ { name = "poethepoet", specifier = ">=0.37.0" }, { name = "pre-commit", specifier = ">=4.3.0" }, { name = "pyright", specifier = ">=1.1.403" }, + { name = "pytest", specifier = ">=8.4.2" }, { name = "ruff", specifier = ">=0.1.0" }, ] @@ -383,6 +394,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/b9/0df6351b25c6bd494c534d2a8191dc9460fb5bb09c88b1427775d49fde05/openai-1.93.3-py3-none-any.whl", hash = "sha256:41aaa7594c7d141b46eed0a58dcd75d20edcc809fdd2c931ecbb4957dc98a892", size = 755132, upload-time = "2025-07-09T14:08:25.533Z" }, ] +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + [[package]] name = "pastel" version = "0.2.1" @@ -401,6 +421,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "poethepoet" version = "0.37.0" @@ -524,6 +553,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, ] +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + [[package]] name = "pyright" version = "1.1.403" @@ -537,6 +575,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/b6/b04e5c2f41a5ccad74a1a4759da41adb20b4bc9d59a5e08d29ba60084d07/pyright-1.1.403-py3-none-any.whl", hash = "sha256:c0eeca5aa76cbef3fcc271259bbd785753c7ad7bcac99a9162b4c4c7daed23b3", size = 5684504, upload-time = "2025-07-09T07:15:50.958Z" }, ] +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + [[package]] name = "python-dotenv" version = "1.1.1"