refactor: address major code review findings

- Use importlib.metadata for dynamic version (single source in pyproject.toml)
- Clean up duplicate `import re` statements across modules
- Add missing type hints to all public methods
- Fix PATH auto-discovery for ilspycmd (~/.dotnet/tools)
- Add pytest test suite with 35 tests covering models, metadata reader, wrapper
- Bump version to 0.2.0, add CHANGELOG.md
This commit is contained in:
Ryan Malloy 2026-02-07 02:05:57 -07:00
parent 157d671d28
commit 7d784af17c
12 changed files with 771 additions and 54 deletions

47
CHANGELOG.md Normal file
View File

@ -0,0 +1,47 @@
# Changelog
All notable changes to mcilspy will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Calendar Versioning](https://calver.org/) for major releases.
## [0.2.0] - 2026-02-07
### Security
- **Fixed shell injection vulnerability** in SDK installation - replaced `create_subprocess_shell` with `create_subprocess_exec` using explicit argument lists
- **Added subprocess timeout** (5 minutes) to prevent hanging on malicious/corrupted assemblies
### Added
- **pytest test suite** with 35 tests covering models, metadata reader, and wrapper
- **PATH auto-discovery** for ilspycmd - now checks `~/.dotnet/tools` when not in PATH
- **FastMCP lifespan pattern** for proper dependency management
### Changed
- **Version handling** now uses `importlib.metadata` - single source of truth in pyproject.toml
- **Error messages** standardized across all tools using `_format_error()` helper
- **Type parsing** improved with proper handling for nested types (`Outer+Nested`)
- **Imports cleaned up** - removed duplicate `re` imports, added top-level import
### Fixed
- **Global mutable state** replaced with FastMCP lifespan context pattern
- **Type hints** added to all public methods
- **Regex parsing** now logs unparsed lines instead of silently ignoring
## [0.1.1] - 2026-02-06
### Added
- dnfile-based metadata tools (search_methods, search_fields, search_properties, list_events, list_resources, get_metadata_summary)
- Platform-aware installation tool with SDK detection
- Improved README with badges and quick start guide
### Fixed
- HeapItemBinary conversion in metadata reader
- Property and event listing with proper row index handling
## [0.1.0] - 2026-02-05
### Added
- Initial release
- FastMCP server wrapping ilspycmd
- Tools: decompile_assembly, list_types, generate_diagrammer, get_assembly_info
- check_ilspy_installation and install_ilspy helper tools

View File

@ -1,9 +1,9 @@
[project] [project]
name = "mcilspy" name = "mcilspy"
version = "0.1.1" version = "0.2.0"
description = "MCP Server for ILSpy .NET Decompiler" description = "MCP Server for ILSpy .NET Decompiler"
authors = [ authors = [
{name = "Borealin", email = "me@borealin.cn"} {name = "Ryan Malloy", email = "ryan@supported.systems"}
] ]
dependencies = [ dependencies = [
"mcp>=1.0.0", "mcp>=1.0.0",
@ -28,10 +28,17 @@ classifiers = [
"Topic :: System :: Software Distribution", "Topic :: System :: Software Distribution",
] ]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"ruff>=0.4.0",
]
[project.urls] [project.urls]
Homepage = "https://github.com/Borealin/ilspy-mcp-server" Homepage = "https://git.supported.systems/MCP/mcilspy"
Repository = "https://github.com/Borealin/ilspy-mcp-server.git" Repository = "https://git.supported.systems/MCP/mcilspy.git"
Issues = "https://github.com/Borealin/ilspy-mcp-server/issues" Issues = "https://git.supported.systems/MCP/mcilspy/issues"
[project.scripts] [project.scripts]
mcilspy = "mcilspy.server:main" mcilspy = "mcilspy.server:main"
@ -50,6 +57,11 @@ include = [
"/LICENSE", "/LICENSE",
] ]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
[tool.ruff] [tool.ruff]
target-version = "py310" target-version = "py310"
line-length = 100 line-length = 100

View File

@ -1,3 +1,8 @@
"""ILSpy MCP Server - Model Context Protocol server for .NET decompilation.""" """ILSpy MCP Server - Model Context Protocol server for .NET decompilation."""
__version__ = "0.1.1" try:
from importlib.metadata import version
__version__ = version("mcilspy")
except Exception:
__version__ = "0.0.0" # Fallback for editable installs without metadata

View File

@ -26,7 +26,7 @@ logger = logging.getLogger(__name__)
class ILSpyWrapper: class ILSpyWrapper:
"""Wrapper class for ILSpy command line tool.""" """Wrapper class for ILSpy command line tool."""
def __init__(self, ilspycmd_path: str | None = None): def __init__(self, ilspycmd_path: str | None = None) -> None:
"""Initialize the wrapper. """Initialize the wrapper.
Args: Args:
@ -39,12 +39,39 @@ class ILSpyWrapper:
) )
def _find_ilspycmd(self) -> str | None: def _find_ilspycmd(self) -> str | None:
"""Find ilspycmd executable in PATH.""" """Find ilspycmd executable in PATH or common install locations.
# Try common names
Checks:
1. Standard PATH (via shutil.which)
2. ~/.dotnet/tools (default location for 'dotnet tool install --global')
3. Platform-specific locations
"""
# Try standard PATH first
for cmd_name in ["ilspycmd", "ilspycmd.exe"]: for cmd_name in ["ilspycmd", "ilspycmd.exe"]:
path = shutil.which(cmd_name) path = shutil.which(cmd_name)
if path: if path:
return path return path
# Check common dotnet tools locations (not always in PATH)
home = os.path.expanduser("~")
dotnet_tools_paths = [
os.path.join(home, ".dotnet", "tools", "ilspycmd"),
os.path.join(home, ".dotnet", "tools", "ilspycmd.exe"),
]
# Windows-specific paths
if os.name == "nt":
userprofile = os.environ.get("USERPROFILE", "")
if userprofile:
dotnet_tools_paths.extend([
os.path.join(userprofile, ".dotnet", "tools", "ilspycmd.exe"),
])
for tool_path in dotnet_tools_paths:
if os.path.isfile(tool_path) and os.access(tool_path, os.X_OK):
logger.info(f"Found ilspycmd at {tool_path} (not in PATH)")
return tool_path
return None return None
async def _run_command( async def _run_command(
@ -79,7 +106,7 @@ class ILSpyWrapper:
timeout=300.0 # 5 minutes timeout=300.0 # 5 minutes
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
logger.warning(f"Command timed out after 5 minutes, killing process") logger.warning("Command timed out after 5 minutes, killing process")
process.kill() process.kill()
await process.wait() # Ensure process is cleaned up await process.wait() # Ensure process is cleaned up
return -1, "", "Command timed out after 5 minutes. The assembly may be corrupted or too complex." return -1, "", "Command timed out after 5 minutes. The assembly may be corrupted or too complex."

View File

@ -11,6 +11,7 @@ rather than traditional IntFlag enums, so we use those directly.
import logging import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any
import dnfile import dnfile
from dnfile.mdtable import TypeDefRow from dnfile.mdtable import TypeDefRow
@ -103,7 +104,7 @@ class AssemblyMetadata:
class MetadataReader: class MetadataReader:
"""Read .NET assembly metadata directly using dnfile.""" """Read .NET assembly metadata directly using dnfile."""
def __init__(self, assembly_path: str): def __init__(self, assembly_path: str) -> None:
"""Initialize the metadata reader. """Initialize the metadata reader.
Args: Args:
@ -232,7 +233,7 @@ class MetadataReader:
referenced_assemblies=referenced_assemblies, referenced_assemblies=referenced_assemblies,
) )
def _get_row_index(self, reference) -> int: def _get_row_index(self, reference: Any) -> int:
"""Safely extract row_index from a metadata reference. """Safely extract row_index from a metadata reference.
dnfile references can be either objects with .row_index attribute dnfile references can be either objects with .row_index attribute
@ -582,15 +583,15 @@ class MetadataReader:
return resources return resources
def close(self): def close(self) -> None:
"""Close the PE file.""" """Close the PE file."""
if self._pe: if self._pe:
self._pe.close() self._pe.close()
self._pe = None self._pe = None
def __enter__(self): def __enter__(self) -> "MetadataReader":
return self return self
def __exit__(self, exc_type, exc_val, exc_tb): def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
self.close() self.close()
return False return False

View File

@ -2,6 +2,7 @@ import asyncio
import logging import logging
import os import os
import platform import platform
import re
import shutil import shutil
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from dataclasses import dataclass from dataclasses import dataclass
@ -95,6 +96,33 @@ def _format_error(error: Exception, context: str = "") -> str:
return f"**Error**: {error_msg}" return f"**Error**: {error_msg}"
def _find_ilspycmd_path() -> str | None:
"""Find ilspycmd in PATH or common install locations."""
# Check PATH first
path = shutil.which("ilspycmd")
if path:
return path
# Check common dotnet tools locations (often not in PATH for MCP servers)
home = os.path.expanduser("~")
candidates = [
os.path.join(home, ".dotnet", "tools", "ilspycmd"),
os.path.join(home, ".dotnet", "tools", "ilspycmd.exe"),
]
# Windows-specific
if os.name == "nt":
userprofile = os.environ.get("USERPROFILE", "")
if userprofile:
candidates.append(os.path.join(userprofile, ".dotnet", "tools", "ilspycmd.exe"))
for candidate in candidates:
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
return None
async def _check_dotnet_tools() -> dict: async def _check_dotnet_tools() -> dict:
"""Check status of dotnet CLI and ilspycmd tool.""" """Check status of dotnet CLI and ilspycmd tool."""
result = { result = {
@ -122,14 +150,14 @@ async def _check_dotnet_tools() -> dict:
except Exception: except Exception:
pass pass
# Check if ilspycmd is available # Check if ilspycmd is available (check PATH and common locations)
ilspy_path = shutil.which("ilspycmd") ilspy_path = _find_ilspycmd_path()
if ilspy_path: if ilspy_path:
result["ilspycmd_available"] = True result["ilspycmd_available"] = True
result["ilspycmd_path"] = ilspy_path result["ilspycmd_path"] = ilspy_path
try: try:
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
"ilspycmd", ilspy_path,
"--version", "--version",
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
@ -817,13 +845,11 @@ async def search_types(
return response.error_message or "Failed to list types" return response.error_message or "Failed to list types"
# Compile regex if needed # Compile regex if needed
import re as regex_module
if use_regex: if use_regex:
try: try:
flags = 0 if case_sensitive else regex_module.IGNORECASE flags = 0 if case_sensitive else re.IGNORECASE
search_pattern = regex_module.compile(pattern, flags) search_pattern = re.compile(pattern, flags)
except regex_module.error as e: except re.error as e:
return f"Invalid regex pattern: {e}" return f"Invalid regex pattern: {e}"
else: else:
search_pattern = None search_pattern = None
@ -933,13 +959,11 @@ async def search_strings(
source_code = response.source_code or "" source_code = response.source_code or ""
# Compile regex if needed # Compile regex if needed
import re as regex_module
if use_regex: if use_regex:
try: try:
flags = 0 if case_sensitive else regex_module.IGNORECASE flags = 0 if case_sensitive else re.IGNORECASE
search_pattern = regex_module.compile(pattern, flags) search_pattern = re.compile(pattern, flags)
except regex_module.error as e: except re.error as e:
return f"Invalid regex pattern: {e}" return f"Invalid regex pattern: {e}"
# Search for string literals containing the pattern # Search for string literals containing the pattern
@ -952,14 +976,14 @@ async def search_strings(
lines = source_code.split("\n") lines = source_code.split("\n")
for i, line in enumerate(lines): for i, line in enumerate(lines):
# Track current type/method context # Track current type/method context
type_match = regex_module.match( type_match = re.match(
r"^\s*(?:public|private|internal|protected)?\s*(?:class|struct|interface)\s+(\w+)", r"^\s*(?:public|private|internal|protected)?\s*(?:class|struct|interface)\s+(\w+)",
line, line,
) )
if type_match: if type_match:
current_type = type_match.group(1) current_type = type_match.group(1)
method_match = regex_module.match( method_match = re.match(
r"^\s*(?:public|private|internal|protected)?\s*(?:static\s+)?(?:\w+\s+)+(\w+)\s*\(", r"^\s*(?:public|private|internal|protected)?\s*(?:static\s+)?(?:\w+\s+)+(\w+)\s*\(",
line, line,
) )
@ -1061,8 +1085,6 @@ async def search_methods(
await ctx.info(f"Searching for methods matching '{pattern}' in: {assembly_path}") await ctx.info(f"Searching for methods matching '{pattern}' in: {assembly_path}")
try: try:
import re as regex_module
from .metadata_reader import MetadataReader from .metadata_reader import MetadataReader
with MetadataReader(assembly_path) as reader: with MetadataReader(assembly_path) as reader:
@ -1078,9 +1100,9 @@ async def search_methods(
# Compile regex if needed # Compile regex if needed
if use_regex: if use_regex:
try: try:
flags = 0 if case_sensitive else regex_module.IGNORECASE flags = 0 if case_sensitive else re.IGNORECASE
search_pattern = regex_module.compile(pattern, flags) search_pattern = re.compile(pattern, flags)
except regex_module.error as e: except re.error as e:
return f"Invalid regex pattern: {e}" return f"Invalid regex pattern: {e}"
else: else:
search_pattern = None search_pattern = None
@ -1180,8 +1202,6 @@ async def search_fields(
await ctx.info(f"Searching for fields matching '{pattern}' in: {assembly_path}") await ctx.info(f"Searching for fields matching '{pattern}' in: {assembly_path}")
try: try:
import re as regex_module
from .metadata_reader import MetadataReader from .metadata_reader import MetadataReader
with MetadataReader(assembly_path) as reader: with MetadataReader(assembly_path) as reader:
@ -1198,9 +1218,9 @@ async def search_fields(
# Compile regex if needed # Compile regex if needed
if use_regex: if use_regex:
try: try:
flags = 0 if case_sensitive else regex_module.IGNORECASE flags = 0 if case_sensitive else re.IGNORECASE
search_pattern = regex_module.compile(pattern, flags) search_pattern = re.compile(pattern, flags)
except regex_module.error as e: except re.error as e:
return f"Invalid regex pattern: {e}" return f"Invalid regex pattern: {e}"
else: else:
search_pattern = None search_pattern = None
@ -1291,8 +1311,6 @@ async def search_properties(
await ctx.info(f"Searching for properties matching '{pattern}' in: {assembly_path}") await ctx.info(f"Searching for properties matching '{pattern}' in: {assembly_path}")
try: try:
import re as regex_module
from .metadata_reader import MetadataReader from .metadata_reader import MetadataReader
with MetadataReader(assembly_path) as reader: with MetadataReader(assembly_path) as reader:
@ -1307,9 +1325,9 @@ async def search_properties(
# Compile regex if needed # Compile regex if needed
if use_regex: if use_regex:
try: try:
flags = 0 if case_sensitive else regex_module.IGNORECASE flags = 0 if case_sensitive else re.IGNORECASE
search_pattern = regex_module.compile(pattern, flags) search_pattern = re.compile(pattern, flags)
except regex_module.error as e: except re.error as e:
return f"Invalid regex pattern: {e}" return f"Invalid regex pattern: {e}"
else: else:
search_pattern = None search_pattern = None
@ -1564,16 +1582,11 @@ def main():
"""Entry point for the MCP server.""" """Entry point for the MCP server."""
import sys import sys
try: from . import __version__
from importlib.metadata import version
package_version = version("mcilspy")
except Exception:
package_version = "0.1.1"
# Print banner to stderr (stdout is reserved for MCP protocol) # Print banner to stderr (stdout is reserved for MCP protocol)
print( print(
f"🔬 mcilspy v{package_version} - .NET Assembly Decompiler MCP Server", f"🔬 mcilspy v{__version__} - .NET Assembly Decompiler MCP Server",
file=sys.stderr, file=sys.stderr,
) )
mcp.run(transport="stdio") mcp.run(transport="stdio")

0
tests/__init__.py Normal file
View File

41
tests/conftest.py Normal file
View File

@ -0,0 +1,41 @@
"""Shared pytest fixtures for mcilspy tests."""
import os
from pathlib import Path
import pytest
@pytest.fixture
def sample_assembly_path() -> str:
"""Return path to a .NET assembly for testing.
Uses a known .NET SDK assembly that should exist on systems with dotnet installed.
"""
# Try to find a .NET SDK assembly
dotnet_base = Path("/usr/share/dotnet/sdk")
if dotnet_base.exists():
# Find any SDK version
for sdk_dir in dotnet_base.iterdir():
test_dll = sdk_dir / "Sdks" / "Microsoft.NET.Sdk" / "tools" / "net10.0" / "Microsoft.NET.Build.Tasks.dll"
if test_dll.exists():
return str(test_dll)
# Try older paths
for net_version in ["net9.0", "net8.0", "net7.0", "net6.0"]:
test_dll = sdk_dir / "Sdks" / "Microsoft.NET.Sdk" / "tools" / net_version / "Microsoft.NET.Build.Tasks.dll"
if test_dll.exists():
return str(test_dll)
# Fallback: any .dll in dotnet directory
for root, dirs, files in os.walk("/usr/share/dotnet"):
for f in files:
if f.endswith(".dll"):
return os.path.join(root, f)
pytest.skip("No .NET assembly found for testing")
@pytest.fixture
def nonexistent_path() -> str:
"""Return a path that doesn't exist."""
return "/nonexistent/path/to/assembly.dll"

176
tests/test_ilspy_wrapper.py Normal file
View File

@ -0,0 +1,176 @@
"""Tests for mcilspy.ilspy_wrapper module."""
import pytest
from mcilspy.ilspy_wrapper import ILSpyWrapper
from mcilspy.models import DecompileRequest, ListTypesRequest
class TestILSpyWrapperInit:
"""Tests for ILSpyWrapper initialization."""
def test_init_finds_ilspycmd_or_raises(self):
"""Test that init either finds ilspycmd or raises RuntimeError."""
try:
wrapper = ILSpyWrapper()
# If we get here, ilspycmd was found
assert wrapper.ilspycmd_path is not None
except RuntimeError as e:
# ilspycmd not installed - that's okay for testing
assert "ILSpyCmd not found" in str(e)
class TestILSpyWrapperTypeParsing:
"""Tests for type name parsing utilities."""
def test_parse_types_output_empty(self):
"""Test parsing empty output."""
# Create wrapper only if ilspycmd is available
try:
wrapper = ILSpyWrapper()
except RuntimeError:
pytest.skip("ilspycmd not installed")
types = wrapper._parse_types_output("")
assert types == []
def test_parse_types_output_single_type(self):
"""Test parsing single type."""
try:
wrapper = ILSpyWrapper()
except RuntimeError:
pytest.skip("ilspycmd not installed")
output = "Class: MyNamespace.MyClass"
types = wrapper._parse_types_output(output)
assert len(types) == 1
assert types[0].name == "MyClass"
assert types[0].namespace == "MyNamespace"
assert types[0].kind == "Class"
assert types[0].full_name == "MyNamespace.MyClass"
def test_parse_types_output_multiple_types(self):
"""Test parsing multiple types."""
try:
wrapper = ILSpyWrapper()
except RuntimeError:
pytest.skip("ilspycmd not installed")
output = """Class: NS.ClassA
Interface: NS.IService
Struct: NS.MyStruct
Enum: NS.MyEnum
Delegate: NS.MyDelegate"""
types = wrapper._parse_types_output(output)
assert len(types) == 5
# Check kinds
kinds = [t.kind for t in types]
assert "Class" in kinds
assert "Interface" in kinds
assert "Struct" in kinds
assert "Enum" in kinds
assert "Delegate" in kinds
def test_parse_types_output_nested_type(self):
"""Test parsing nested types."""
try:
wrapper = ILSpyWrapper()
except RuntimeError:
pytest.skip("ilspycmd not installed")
output = "Class: MyNamespace.Outer+Nested"
types = wrapper._parse_types_output(output)
assert len(types) == 1
assert types[0].name == "Outer+Nested"
assert types[0].namespace == "MyNamespace"
def test_parse_types_output_no_namespace(self):
"""Test parsing type with no namespace."""
try:
wrapper = ILSpyWrapper()
except RuntimeError:
pytest.skip("ilspycmd not installed")
output = "Class: MyClass"
types = wrapper._parse_types_output(output)
assert len(types) == 1
assert types[0].name == "MyClass"
assert types[0].namespace is None
def test_parse_types_output_skips_invalid_lines(self):
"""Test that invalid lines are skipped."""
try:
wrapper = ILSpyWrapper()
except RuntimeError:
pytest.skip("ilspycmd not installed")
output = """Class: NS.Valid
This is not a valid line
Another invalid line
Interface: NS.AlsoValid"""
types = wrapper._parse_types_output(output)
assert len(types) == 2
assert types[0].full_name == "NS.Valid"
assert types[1].full_name == "NS.AlsoValid"
class TestILSpyWrapperDecompile:
"""Tests for decompilation functionality."""
@pytest.mark.asyncio
async def test_decompile_nonexistent_assembly(self):
"""Test decompiling nonexistent assembly."""
try:
wrapper = ILSpyWrapper()
except RuntimeError:
pytest.skip("ilspycmd not installed")
request = DecompileRequest(assembly_path="/nonexistent/assembly.dll")
response = await wrapper.decompile(request)
assert response.success is False
assert "not found" in response.error_message.lower()
@pytest.mark.asyncio
async def test_list_types_nonexistent_assembly(self):
"""Test listing types from nonexistent assembly."""
try:
wrapper = ILSpyWrapper()
except RuntimeError:
pytest.skip("ilspycmd not installed")
request = ListTypesRequest(assembly_path="/nonexistent/assembly.dll")
response = await wrapper.list_types(request)
assert response.success is False
assert "not found" in response.error_message.lower()
class TestILSpyWrapperHelpers:
"""Tests for ILSpyWrapper helper methods."""
def test_split_type_name_simple(self):
"""Test splitting simple type names."""
assert ILSpyWrapper._split_type_name("MyClass") == ("MyClass", None)
assert ILSpyWrapper._split_type_name("NS.MyClass") == ("MyClass", "NS")
assert ILSpyWrapper._split_type_name("Deep.NS.MyClass") == ("MyClass", "Deep.NS")
def test_split_type_name_nested(self):
"""Test splitting nested type names."""
assert ILSpyWrapper._split_type_name("NS.Outer+Nested") == ("Outer+Nested", "NS")
assert ILSpyWrapper._split_type_name("Outer+Nested") == ("Outer+Nested", None)
assert ILSpyWrapper._split_type_name("NS.A+B+C") == ("A+B+C", "NS")
def test_split_type_name_deep_namespace(self):
"""Test splitting types with deep namespaces."""
assert ILSpyWrapper._split_type_name("A.B.C.D.MyClass") == ("MyClass", "A.B.C.D")
def test_split_type_name_nested_with_deep_namespace(self):
"""Test nested types with deep namespaces."""
assert ILSpyWrapper._split_type_name("A.B.C.Outer+Inner") == ("Outer+Inner", "A.B.C")

View File

@ -0,0 +1,112 @@
"""Tests for mcilspy.metadata_reader module."""
import pytest
from mcilspy.metadata_reader import MetadataReader
class TestMetadataReader:
"""Tests for MetadataReader class."""
def test_init_with_nonexistent_file(self, nonexistent_path):
"""Test that initializing with nonexistent file raises error."""
with pytest.raises(FileNotFoundError):
MetadataReader(nonexistent_path)
def test_context_manager(self, sample_assembly_path):
"""Test using MetadataReader as context manager."""
with MetadataReader(sample_assembly_path) as reader:
assert reader is not None
# Should be able to read metadata
meta = reader.get_assembly_metadata()
assert meta.name is not None
def test_get_assembly_metadata(self, sample_assembly_path):
"""Test reading assembly metadata."""
with MetadataReader(sample_assembly_path) as reader:
meta = reader.get_assembly_metadata()
# Basic fields should be populated
assert meta.name is not None
assert len(meta.name) > 0
assert meta.version is not None
assert meta.type_count >= 0
assert meta.method_count >= 0
def test_list_methods(self, sample_assembly_path):
"""Test listing methods from assembly."""
with MetadataReader(sample_assembly_path) as reader:
methods = reader.list_methods()
# Should find some methods
assert len(methods) > 0
# Each method should have required fields
for method in methods[:5]: # Check first 5
assert method.name is not None
assert method.declaring_type is not None
def test_list_methods_with_filter(self, sample_assembly_path):
"""Test listing methods with type filter."""
with MetadataReader(sample_assembly_path) as reader:
# First get unfiltered list
all_methods = reader.list_methods()
if len(all_methods) > 0:
# Get a type name from the first method
declaring_type = all_methods[0].declaring_type
# Filter by that type
filtered = reader.list_methods(type_filter=declaring_type)
# Filtered should be subset
assert len(filtered) <= len(all_methods)
# All filtered methods should contain the type name
for method in filtered:
assert declaring_type.lower() in method.declaring_type.lower()
def test_list_fields(self, sample_assembly_path):
"""Test listing fields from assembly."""
with MetadataReader(sample_assembly_path) as reader:
fields = reader.list_fields()
# Fields list may be empty for some assemblies
assert isinstance(fields, list)
def test_list_properties(self, sample_assembly_path):
"""Test listing properties from assembly."""
with MetadataReader(sample_assembly_path) as reader:
props = reader.list_properties()
assert isinstance(props, list)
def test_list_events(self, sample_assembly_path):
"""Test listing events from assembly."""
with MetadataReader(sample_assembly_path) as reader:
events = reader.list_events()
assert isinstance(events, list)
def test_list_resources(self, sample_assembly_path):
"""Test listing embedded resources from assembly."""
with MetadataReader(sample_assembly_path) as reader:
resources = reader.list_resources()
assert isinstance(resources, list)
class TestMetadataReaderHelpers:
"""Tests for MetadataReader helper methods."""
def test_split_type_name_simple(self):
"""Test splitting simple type names."""
# Import the wrapper to access the static method
from mcilspy.ilspy_wrapper import ILSpyWrapper
assert ILSpyWrapper._split_type_name("MyClass") == ("MyClass", None)
assert ILSpyWrapper._split_type_name("NS.MyClass") == ("MyClass", "NS")
assert ILSpyWrapper._split_type_name("Deep.NS.MyClass") == ("MyClass", "Deep.NS")
def test_split_type_name_nested(self):
"""Test splitting nested type names."""
from mcilspy.ilspy_wrapper import ILSpyWrapper
assert ILSpyWrapper._split_type_name("NS.Outer+Nested") == ("Outer+Nested", "NS")
assert ILSpyWrapper._split_type_name("Outer+Nested") == ("Outer+Nested", None)
assert ILSpyWrapper._split_type_name("NS.A+B+C") == ("A+B+C", "NS")

116
tests/test_models.py Normal file
View File

@ -0,0 +1,116 @@
"""Tests for mcilspy.models module."""
import pytest
from mcilspy.models import (
EntityType,
LanguageVersion,
DecompileRequest,
ListTypesRequest,
TypeInfo,
)
class TestEntityType:
"""Tests for EntityType enum."""
def test_from_string_full_names(self):
"""Test parsing full entity type names."""
assert EntityType.from_string("class") == EntityType.CLASS
assert EntityType.from_string("interface") == EntityType.INTERFACE
assert EntityType.from_string("struct") == EntityType.STRUCT
assert EntityType.from_string("delegate") == EntityType.DELEGATE
assert EntityType.from_string("enum") == EntityType.ENUM
def test_from_string_short_names(self):
"""Test parsing short (single letter) entity type names."""
assert EntityType.from_string("c") == EntityType.CLASS
assert EntityType.from_string("i") == EntityType.INTERFACE
assert EntityType.from_string("s") == EntityType.STRUCT
assert EntityType.from_string("d") == EntityType.DELEGATE
assert EntityType.from_string("e") == EntityType.ENUM
def test_from_string_case_insensitive(self):
"""Test that parsing is case insensitive."""
assert EntityType.from_string("CLASS") == EntityType.CLASS
assert EntityType.from_string("Class") == EntityType.CLASS
assert EntityType.from_string("C") == EntityType.CLASS
def test_from_string_invalid(self):
"""Test that invalid strings raise ValueError."""
with pytest.raises(ValueError):
EntityType.from_string("invalid")
with pytest.raises(ValueError):
EntityType.from_string("x")
class TestLanguageVersion:
"""Tests for LanguageVersion enum."""
def test_common_versions(self):
"""Test common C# version values."""
assert LanguageVersion.LATEST.value == "Latest"
assert LanguageVersion.PREVIEW.value == "Preview"
assert LanguageVersion.CSHARP12_0.value == "CSharp12_0"
def test_version_count(self):
"""Ensure we have all expected versions."""
# Should have CSharp1 through CSharp12, plus Latest and Preview
assert len(LanguageVersion) >= 14
class TestDecompileRequest:
"""Tests for DecompileRequest model."""
def test_minimal_request(self):
"""Test creating request with only required fields."""
request = DecompileRequest(assembly_path="/path/to/assembly.dll")
assert request.assembly_path == "/path/to/assembly.dll"
assert request.language_version == LanguageVersion.LATEST
assert request.type_name is None
assert request.output_dir is None
def test_full_request(self):
"""Test creating request with all fields."""
request = DecompileRequest(
assembly_path="/path/to/assembly.dll",
language_version=LanguageVersion.CSHARP10_0,
type_name="MyNamespace.MyClass",
output_dir="/output",
create_project=True,
show_il_code=True,
)
assert request.language_version == LanguageVersion.CSHARP10_0
assert request.type_name == "MyNamespace.MyClass"
assert request.create_project is True
class TestListTypesRequest:
"""Tests for ListTypesRequest model."""
def test_default_entity_types(self):
"""Test that default entity types include common types."""
request = ListTypesRequest(assembly_path="/path/to/assembly.dll")
# Should have some default entity types
assert len(request.entity_types) > 0
assert EntityType.CLASS in request.entity_types
class TestTypeInfo:
"""Tests for TypeInfo model."""
def test_type_info_creation(self):
"""Test creating TypeInfo objects."""
info = TypeInfo(
name="MyClass",
full_name="MyNamespace.MyClass",
kind="Class",
namespace="MyNamespace",
)
assert info.name == "MyClass"
assert info.namespace == "MyNamespace"
def test_type_info_no_namespace(self):
"""Test TypeInfo with no namespace."""
info = TypeInfo(name="MyClass", full_name="MyClass", kind="Class")
assert info.namespace is None

169
uv.lock generated
View File

@ -34,6 +34,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
] ]
[[package]]
name = "backports-asyncio-runner"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.1.4" version = "2026.1.4"
@ -285,6 +294,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
] ]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]] [[package]]
name = "jsonschema" name = "jsonschema"
version = "4.26.0" version = "4.26.0"
@ -314,7 +332,7 @@ wheels = [
[[package]] [[package]]
name = "mcilspy" name = "mcilspy"
version = "0.1.1" version = "0.2.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "dnfile" }, { name = "dnfile" },
@ -322,12 +340,23 @@ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
] ]
[package.optional-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "dnfile", specifier = ">=0.15.0" }, { name = "dnfile", specifier = ">=0.15.0" },
{ name = "mcp", specifier = ">=1.0.0" }, { name = "mcp", specifier = ">=1.0.0" },
{ name = "pydantic", specifier = ">=2.7.0" }, { name = "pydantic", specifier = ">=2.7.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" },
] ]
provides-extras = ["dev"]
[[package]] [[package]]
name = "mcp" name = "mcp"
@ -354,6 +383,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" },
] ]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]] [[package]]
name = "pefile" name = "pefile"
version = "2024.8.26" version = "2024.8.26"
@ -363,6 +401,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" },
] ]
[[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]] [[package]]
name = "pycparser" name = "pycparser"
version = "3.0" version = "3.0"
@ -519,6 +566,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" },
] ]
[[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]] [[package]]
name = "pyjwt" name = "pyjwt"
version = "2.11.0" version = "2.11.0"
@ -533,6 +589,38 @@ crypto = [
{ name = "cryptography" }, { name = "cryptography" },
] ]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.1" version = "1.2.1"
@ -709,6 +797,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" },
] ]
[[package]]
name = "ruff"
version = "0.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" },
{ url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" },
{ url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" },
{ url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" },
{ url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" },
{ url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" },
{ url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" },
{ url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" },
{ url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" },
{ url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" },
{ url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" },
{ url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" },
{ url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" },
{ url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" },
{ url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" },
{ url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" },
{ url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" },
]
[[package]] [[package]]
name = "sse-starlette" name = "sse-starlette"
version = "3.2.0" version = "3.2.0"
@ -735,6 +848,60 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
] ]
[[package]]
name = "tomli"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" },
{ url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" },
{ url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" },
{ url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" },
{ url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" },
{ url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" },
{ url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" },
{ url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" },
{ url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" },
{ url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" },
{ url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" },
{ url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" },
{ url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" },
{ url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" },
{ url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" },
{ url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" },
{ url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" },
{ url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" },
{ url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" },
{ url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" },
{ url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" },
{ url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" },
{ url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" },
{ url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" },
{ url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" },
{ url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" },
{ url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" },
{ url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" },
{ url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" },
{ url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" },
{ url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" },
{ url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" },
{ url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" },
{ url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" },
{ url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" },
{ url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" },
{ url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" },
{ url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" },
{ url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" },
{ url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" },
{ url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" },
{ url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" },
{ url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" },
{ url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.15.0" version = "4.15.0"