diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5f07841 --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 17b485c..a619f38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,9 @@ [project] name = "mcilspy" -version = "0.1.1" +version = "0.2.0" description = "MCP Server for ILSpy .NET Decompiler" authors = [ - {name = "Borealin", email = "me@borealin.cn"} + {name = "Ryan Malloy", email = "ryan@supported.systems"} ] dependencies = [ "mcp>=1.0.0", @@ -28,10 +28,17 @@ classifiers = [ "Topic :: System :: Software Distribution", ] +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "ruff>=0.4.0", +] + [project.urls] -Homepage = "https://github.com/Borealin/ilspy-mcp-server" -Repository = "https://github.com/Borealin/ilspy-mcp-server.git" -Issues = "https://github.com/Borealin/ilspy-mcp-server/issues" +Homepage = "https://git.supported.systems/MCP/mcilspy" +Repository = "https://git.supported.systems/MCP/mcilspy.git" +Issues = "https://git.supported.systems/MCP/mcilspy/issues" [project.scripts] mcilspy = "mcilspy.server:main" @@ -50,6 +57,11 @@ include = [ "/LICENSE", ] +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + [tool.ruff] target-version = "py310" line-length = 100 @@ -60,4 +72,4 @@ select = ["E", "F", "W", "I", "UP", "B", "SIM"] ignore = ["E501"] # line length handled by formatter [tool.ruff.format] -quote-style = "double" \ No newline at end of file +quote-style = "double" diff --git a/src/mcilspy/__init__.py b/src/mcilspy/__init__.py index 985683b..f0ff9fa 100644 --- a/src/mcilspy/__init__.py +++ b/src/mcilspy/__init__.py @@ -1,3 +1,8 @@ """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 diff --git a/src/mcilspy/ilspy_wrapper.py b/src/mcilspy/ilspy_wrapper.py index be10f8a..a353357 100644 --- a/src/mcilspy/ilspy_wrapper.py +++ b/src/mcilspy/ilspy_wrapper.py @@ -26,7 +26,7 @@ logger = logging.getLogger(__name__) class ILSpyWrapper: """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. Args: @@ -39,12 +39,39 @@ class ILSpyWrapper: ) def _find_ilspycmd(self) -> str | None: - """Find ilspycmd executable in PATH.""" - # Try common names + """Find ilspycmd executable in PATH or common install locations. + + 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"]: path = shutil.which(cmd_name) if 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 async def _run_command( @@ -79,7 +106,7 @@ class ILSpyWrapper: timeout=300.0 # 5 minutes ) 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() await process.wait() # Ensure process is cleaned up return -1, "", "Command timed out after 5 minutes. The assembly may be corrupted or too complex." diff --git a/src/mcilspy/metadata_reader.py b/src/mcilspy/metadata_reader.py index f2e7ebe..2a2e1c6 100644 --- a/src/mcilspy/metadata_reader.py +++ b/src/mcilspy/metadata_reader.py @@ -11,6 +11,7 @@ rather than traditional IntFlag enums, so we use those directly. import logging from dataclasses import dataclass, field from pathlib import Path +from typing import Any import dnfile from dnfile.mdtable import TypeDefRow @@ -103,7 +104,7 @@ class AssemblyMetadata: class MetadataReader: """Read .NET assembly metadata directly using dnfile.""" - def __init__(self, assembly_path: str): + def __init__(self, assembly_path: str) -> None: """Initialize the metadata reader. Args: @@ -232,7 +233,7 @@ class MetadataReader: 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. dnfile references can be either objects with .row_index attribute @@ -582,15 +583,15 @@ class MetadataReader: return resources - def close(self): + def close(self) -> None: """Close the PE file.""" if self._pe: self._pe.close() self._pe = None - def __enter__(self): + def __enter__(self) -> "MetadataReader": 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() return False diff --git a/src/mcilspy/server.py b/src/mcilspy/server.py index af5a31a..7807c6e 100644 --- a/src/mcilspy/server.py +++ b/src/mcilspy/server.py @@ -2,6 +2,7 @@ import asyncio import logging import os import platform +import re import shutil from contextlib import asynccontextmanager from dataclasses import dataclass @@ -95,6 +96,33 @@ def _format_error(error: Exception, context: str = "") -> str: 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: """Check status of dotnet CLI and ilspycmd tool.""" result = { @@ -122,14 +150,14 @@ async def _check_dotnet_tools() -> dict: except Exception: pass - # Check if ilspycmd is available - ilspy_path = shutil.which("ilspycmd") + # Check if ilspycmd is available (check PATH and common locations) + ilspy_path = _find_ilspycmd_path() if ilspy_path: result["ilspycmd_available"] = True result["ilspycmd_path"] = ilspy_path try: proc = await asyncio.create_subprocess_exec( - "ilspycmd", + ilspy_path, "--version", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, @@ -817,13 +845,11 @@ async def search_types( return response.error_message or "Failed to list types" # Compile regex if needed - import re as regex_module - if use_regex: try: - flags = 0 if case_sensitive else regex_module.IGNORECASE - search_pattern = regex_module.compile(pattern, flags) - except regex_module.error as e: + flags = 0 if case_sensitive else re.IGNORECASE + search_pattern = re.compile(pattern, flags) + except re.error as e: return f"Invalid regex pattern: {e}" else: search_pattern = None @@ -933,13 +959,11 @@ async def search_strings( source_code = response.source_code or "" # Compile regex if needed - import re as regex_module - if use_regex: try: - flags = 0 if case_sensitive else regex_module.IGNORECASE - search_pattern = regex_module.compile(pattern, flags) - except regex_module.error as e: + flags = 0 if case_sensitive else re.IGNORECASE + search_pattern = re.compile(pattern, flags) + except re.error as e: return f"Invalid regex pattern: {e}" # Search for string literals containing the pattern @@ -952,14 +976,14 @@ async def search_strings( lines = source_code.split("\n") for i, line in enumerate(lines): # 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+)", line, ) if type_match: 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*\(", line, ) @@ -1061,8 +1085,6 @@ async def search_methods( await ctx.info(f"Searching for methods matching '{pattern}' in: {assembly_path}") try: - import re as regex_module - from .metadata_reader import MetadataReader with MetadataReader(assembly_path) as reader: @@ -1078,9 +1100,9 @@ async def search_methods( # Compile regex if needed if use_regex: try: - flags = 0 if case_sensitive else regex_module.IGNORECASE - search_pattern = regex_module.compile(pattern, flags) - except regex_module.error as e: + flags = 0 if case_sensitive else re.IGNORECASE + search_pattern = re.compile(pattern, flags) + except re.error as e: return f"Invalid regex pattern: {e}" else: search_pattern = None @@ -1180,8 +1202,6 @@ async def search_fields( await ctx.info(f"Searching for fields matching '{pattern}' in: {assembly_path}") try: - import re as regex_module - from .metadata_reader import MetadataReader with MetadataReader(assembly_path) as reader: @@ -1198,9 +1218,9 @@ async def search_fields( # Compile regex if needed if use_regex: try: - flags = 0 if case_sensitive else regex_module.IGNORECASE - search_pattern = regex_module.compile(pattern, flags) - except regex_module.error as e: + flags = 0 if case_sensitive else re.IGNORECASE + search_pattern = re.compile(pattern, flags) + except re.error as e: return f"Invalid regex pattern: {e}" else: search_pattern = None @@ -1291,8 +1311,6 @@ async def search_properties( await ctx.info(f"Searching for properties matching '{pattern}' in: {assembly_path}") try: - import re as regex_module - from .metadata_reader import MetadataReader with MetadataReader(assembly_path) as reader: @@ -1307,9 +1325,9 @@ async def search_properties( # Compile regex if needed if use_regex: try: - flags = 0 if case_sensitive else regex_module.IGNORECASE - search_pattern = regex_module.compile(pattern, flags) - except regex_module.error as e: + flags = 0 if case_sensitive else re.IGNORECASE + search_pattern = re.compile(pattern, flags) + except re.error as e: return f"Invalid regex pattern: {e}" else: search_pattern = None @@ -1564,16 +1582,11 @@ def main(): """Entry point for the MCP server.""" import sys - try: - from importlib.metadata import version - - package_version = version("mcilspy") - except Exception: - package_version = "0.1.1" + from . import __version__ # Print banner to stderr (stdout is reserved for MCP protocol) print( - f"🔬 mcilspy v{package_version} - .NET Assembly Decompiler MCP Server", + f"🔬 mcilspy v{__version__} - .NET Assembly Decompiler MCP Server", file=sys.stderr, ) mcp.run(transport="stdio") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..dee9233 --- /dev/null +++ b/tests/conftest.py @@ -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" diff --git a/tests/test_ilspy_wrapper.py b/tests/test_ilspy_wrapper.py new file mode 100644 index 0000000..22d5f30 --- /dev/null +++ b/tests/test_ilspy_wrapper.py @@ -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") diff --git a/tests/test_metadata_reader.py b/tests/test_metadata_reader.py new file mode 100644 index 0000000..9139407 --- /dev/null +++ b/tests/test_metadata_reader.py @@ -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") diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..1c30c12 --- /dev/null +++ b/tests/test_models.py @@ -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 diff --git a/uv.lock b/uv.lock index 598e2ba..c9e2f75 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, ] +[[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]] name = "certifi" 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" }, ] +[[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]] name = "jsonschema" version = "4.26.0" @@ -314,7 +332,7 @@ wheels = [ [[package]] name = "mcilspy" -version = "0.1.1" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "dnfile" }, @@ -322,12 +340,23 @@ dependencies = [ { name = "pydantic" }, ] +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + [package.metadata] requires-dist = [ { name = "dnfile", specifier = ">=0.15.0" }, { name = "mcp", specifier = ">=1.0.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]] 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" }, ] +[[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]] name = "pefile" 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" }, ] +[[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 = "pycparser" 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" }, ] +[[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 = "pyjwt" version = "2.11.0" @@ -533,6 +589,38 @@ crypto = [ { 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]] name = "python-dotenv" 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" }, ] +[[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]] name = "sse-starlette" 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" }, ] +[[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]] name = "typing-extensions" version = "4.15.0"