diff --git a/README.md b/README.md index 22b1be3..8d3ccfa 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,10 @@ ast-grep supports many programming languages including: - C# - And many more... +For a complete list of built-in supported languages, see the [ast-grep language support documentation](https://ast-grep.github.io/reference/languages.html). + +You can also add support for custom languages through the `sgconfig.yaml` configuration file. See the [custom language guide](https://ast-grep.github.io/guide/project/project-config.html#languagecustomlanguage) for details. + ## Troubleshooting ### Common Issues diff --git a/main.py b/main.py index 95a4bf8..1c0ba3c 100644 --- a/main.py +++ b/main.py @@ -5,6 +5,7 @@ import sys from typing import Any, List, Literal, Optional +import yaml from mcp.server.fastmcp import FastMCP from pydantic import Field @@ -59,168 +60,171 @@ def parse_args_and_get_config(): DumpFormat = Literal["pattern", "cst", "ast"] -@mcp.tool() -def dump_syntax_tree( - code: str = Field(description = "The code you need"), - language: str = Field(description = "The language of the code"), - format: DumpFormat = Field(description = "Code dump format. Available values: pattern, ast, cst", default = "cst"), -) -> str: - """ - Dump code's syntax structure or dump a query's pattern structure. - This is useful to discover correct syntax kind and syntax tree structure. Call it when debugging a rule. - The tool requires three arguments: code, language and format. The first two are self-explanatory. - `format` is the output format of the syntax tree. - use `format=cst` to inspect the code's concrete syntax tree structure, useful to debug target code. - use `format=pattern` to inspect how ast-grep interprets a pattern, useful to debug pattern rule. - - Internally calls: ast-grep run --pattern --lang --debug-query= - """ - result = run_ast_grep("run", ["--pattern", code, "--lang", language, f"--debug-query={format}"]) - return result.stderr.strip() # type: ignore[no-any-return] - -@mcp.tool() -def test_match_code_rule( - code: str = Field(description="The code to test against the rule"), - yaml: str = Field(description="The ast-grep YAML rule to search. It must have id, language, rule fields."), -) -> List[dict[str, Any]]: - """ - Test a code against an ast-grep YAML rule. - This is useful to test a rule before using it in a project. - - Internally calls: ast-grep scan --inline-rules --json --stdin - """ - result = run_ast_grep("scan", ["--inline-rules", yaml, "--json", "--stdin"], input_text = code) - matches = json.loads(result.stdout.strip()) - if not matches: - raise ValueError("No matches found for the given code and rule. Try adding `stopBy: end` to your inside/has rule.") - return matches # type: ignore[no-any-return] - -@mcp.tool() -def find_code( - project_folder: str = Field(description="The absolute path to the project folder. It must be absolute path."), - pattern: str = Field(description="The ast-grep pattern to search for. Note, the pattern must have valid AST structure."), - language: str = Field(description="The language of the query", default=""), - max_results: Optional[int] = Field(default=None, description="Maximum results to return"), - output_format: str = Field(default="text", description="'text' or 'json'"), -) -> str | List[dict[str, Any]]: - """ - Find code in a project folder that matches the given ast-grep pattern. - Pattern is good for simple and single-AST node result. - For more complex usage, please use YAML by `find_code_by_rule`. - - Internally calls: ast-grep run --pattern [--json] - - Output formats: - - text (default): Compact text format with file:line-range headers and complete match text - Example: - Found 2 matches: - - path/to/file.py:10-15 - def example_function(): - # function body - return result - - path/to/file.py:20-22 - def another_function(): - pass - - - json: Full match objects with metadata including ranges, meta-variables, etc. - - The max_results parameter limits the number of complete matches returned (not individual lines). - When limited, the header shows "Found X matches (showing first Y of Z)". - - Example usage: - find_code(pattern="class $NAME", max_results=20) # Returns text format - find_code(pattern="class $NAME", output_format="json") # Returns JSON with metadata - """ - if output_format not in ["text", "json"]: - raise ValueError(f"Invalid output_format: {output_format}. Must be 'text' or 'json'.") - - args = ["--pattern", pattern] - if language: - args.extend(["--lang", language]) - - # Always get JSON internally for accurate match limiting - result = run_ast_grep("run", args + ["--json", project_folder]) - matches = json.loads(result.stdout.strip() or "[]") - - # Apply max_results limit to complete matches - total_matches = len(matches) - if max_results is not None and total_matches > max_results: - matches = matches[:max_results] - - if output_format == "text": +def register_mcp_tools() -> None: + @mcp.tool() + def dump_syntax_tree( + code: str = Field(description = "The code you need"), + language: str = Field(description = f"The language of the code. Supported: {', '.join(get_supported_languages())}"), + format: DumpFormat = Field(description = "Code dump format. Available values: pattern, ast, cst", default = "cst"), + ) -> str: + """ + Dump code's syntax structure or dump a query's pattern structure. + This is useful to discover correct syntax kind and syntax tree structure. Call it when debugging a rule. + The tool requires three arguments: code, language and format. The first two are self-explanatory. + `format` is the output format of the syntax tree. + use `format=cst` to inspect the code's concrete syntax tree structure, useful to debug target code. + use `format=pattern` to inspect how ast-grep interprets a pattern, useful to debug pattern rule. + + Internally calls: ast-grep run --pattern --lang --debug-query= + """ + result = run_ast_grep("run", ["--pattern", code, "--lang", language, f"--debug-query={format}"]) + return result.stderr.strip() # type: ignore[no-any-return] + + @mcp.tool() + def test_match_code_rule( + code: str = Field(description = "The code to test against the rule"), + yaml: str = Field(description = "The ast-grep YAML rule to search. It must have id, language, rule fields."), + ) -> List[dict[str, Any]]: + """ + Test a code against an ast-grep YAML rule. + This is useful to test a rule before using it in a project. + + Internally calls: ast-grep scan --inline-rules --json --stdin + """ + result = run_ast_grep("scan", ["--inline-rules", yaml, "--json", "--stdin"], input_text = code) + matches = json.loads(result.stdout.strip()) if not matches: - return "No matches found" - text_output = format_matches_as_text(matches) - header = f"Found {len(matches)} matches" - if max_results is not None and total_matches > max_results: - header += f" (showing first {max_results} of {total_matches})" - return header + ":\n\n" + text_output - return matches # type: ignore[no-any-return] - -@mcp.tool() -def find_code_by_rule( - project_folder: str = Field(description="The absolute path to the project folder. It must be absolute path."), - yaml: str = Field(description="The ast-grep YAML rule to search. It must have id, language, rule fields."), - max_results: Optional[int] = Field(default=None, description="Maximum results to return"), - output_format: str = Field(default="text", description="'text' or 'json'"), + raise ValueError("No matches found for the given code and rule. Try adding `stopBy: end` to your inside/has rule.") + return matches # type: ignore[no-any-return] + + @mcp.tool() + def find_code( + project_folder: str = Field(description = "The absolute path to the project folder. It must be absolute path."), + pattern: str = Field(description = "The ast-grep pattern to search for. Note, the pattern must have valid AST structure."), + language: str = Field(description = f"The language of the code. Supported: {', '.join(get_supported_languages())}. " + "If not specified, will be auto-detected based on file extensions.", default = ""), + max_results: Optional[int] = Field(default = None, description = "Maximum results to return"), + output_format: str = Field(default = "text", description = "'text' or 'json'"), ) -> str | List[dict[str, Any]]: - """ - Find code using ast-grep's YAML rule in a project folder. - YAML rule is more powerful than simple pattern and can perform complex search like find AST inside/having another AST. - It is a more advanced search tool than the simple `find_code`. + """ + Find code in a project folder that matches the given ast-grep pattern. + Pattern is good for simple and single-AST node result. + For more complex usage, please use YAML by `find_code_by_rule`. - Tip: When using relational rules (inside/has), add `stopBy: end` to ensure complete traversal. + Internally calls: ast-grep run --pattern [--json] - Internally calls: ast-grep scan --inline-rules [--json] + Output formats: + - text (default): Compact text format with file:line-range headers and complete match text + Example: + Found 2 matches: - Output formats: - - text (default): Compact text format with file:line-range headers and complete match text - Example: - Found 2 matches: + path/to/file.py:10-15 + def example_function(): + # function body + return result - src/models.py:45-52 - class UserModel: - def __init__(self): - self.id = None - self.name = None + path/to/file.py:20-22 + def another_function(): + pass - src/views.py:12 - class SimpleView: pass + - json: Full match objects with metadata including ranges, meta-variables, etc. - - json: Full match objects with metadata including ranges, meta-variables, etc. + The max_results parameter limits the number of complete matches returned (not individual lines). + When limited, the header shows "Found X matches (showing first Y of Z)". - The max_results parameter limits the number of complete matches returned (not individual lines). - When limited, the header shows "Found X matches (showing first Y of Z)". + Example usage: + find_code(pattern="class $NAME", max_results=20) # Returns text format + find_code(pattern="class $NAME", output_format="json") # Returns JSON with metadata + """ + if output_format not in ["text", "json"]: + raise ValueError(f"Invalid output_format: {output_format}. Must be 'text' or 'json'.") - Example usage: - find_code_by_rule(yaml="id: x\\nlanguage: python\\nrule: {pattern: 'class $NAME'}", max_results=20) - find_code_by_rule(yaml="...", output_format="json") # For full metadata - """ - if output_format not in ["text", "json"]: - raise ValueError(f"Invalid output_format: {output_format}. Must be 'text' or 'json'.") + args = ["--pattern", pattern] + if language: + args.extend(["--lang", language]) - args = ["--inline-rules", yaml] + # Always get JSON internally for accurate match limiting + result = run_ast_grep("run", args + ["--json", project_folder]) + matches = json.loads(result.stdout.strip() or "[]") - # Always get JSON internally for accurate match limiting - result = run_ast_grep("scan", args + ["--json", project_folder]) - matches = json.loads(result.stdout.strip() or "[]") + # Apply max_results limit to complete matches + total_matches = len(matches) + if max_results is not None and total_matches > max_results: + matches = matches[:max_results] + + if output_format == "text": + if not matches: + return "No matches found" + text_output = format_matches_as_text(matches) + header = f"Found {len(matches)} matches" + if max_results is not None and total_matches > max_results: + header += f" (showing first {max_results} of {total_matches})" + return header + ":\n\n" + text_output + return matches # type: ignore[no-any-return] + + @mcp.tool() + def find_code_by_rule( + project_folder: str = Field(description = "The absolute path to the project folder. It must be absolute path."), + yaml: str = Field(description = "The ast-grep YAML rule to search. It must have id, language, rule fields."), + max_results: Optional[int] = Field(default = None, description = "Maximum results to return"), + output_format: str = Field(default = "text", description = "'text' or 'json'"), + ) -> str | List[dict[str, Any]]: + """ + Find code using ast-grep's YAML rule in a project folder. + YAML rule is more powerful than simple pattern and can perform complex search like find AST inside/having another AST. + It is a more advanced search tool than the simple `find_code`. + + Tip: When using relational rules (inside/has), add `stopBy: end` to ensure complete traversal. + + Internally calls: ast-grep scan --inline-rules [--json] + + Output formats: + - text (default): Compact text format with file:line-range headers and complete match text + Example: + Found 2 matches: + + src/models.py:45-52 + class UserModel: + def __init__(self): + self.id = None + self.name = None + + src/views.py:12 + class SimpleView: pass + + - json: Full match objects with metadata including ranges, meta-variables, etc. + + The max_results parameter limits the number of complete matches returned (not individual lines). + When limited, the header shows "Found X matches (showing first Y of Z)". + + Example usage: + find_code_by_rule(yaml="id: x\\nlanguage: python\\nrule: {pattern: 'class $NAME'}", max_results=20) + find_code_by_rule(yaml="...", output_format="json") # For full metadata + """ + if output_format not in ["text", "json"]: + raise ValueError(f"Invalid output_format: {output_format}. Must be 'text' or 'json'.") + + args = ["--inline-rules", yaml] + + # Always get JSON internally for accurate match limiting + result = run_ast_grep("scan", args + ["--json", project_folder]) + matches = json.loads(result.stdout.strip() or "[]") + + # Apply max_results limit to complete matches + total_matches = len(matches) + if max_results is not None and total_matches > max_results: + matches = matches[:max_results] - # Apply max_results limit to complete matches - total_matches = len(matches) - if max_results is not None and total_matches > max_results: - matches = matches[:max_results] + if output_format == "text": + if not matches: + return "No matches found" + text_output = format_matches_as_text(matches) + header = f"Found {len(matches)} matches" + if max_results is not None and total_matches > max_results: + header += f" (showing first {max_results} of {total_matches})" + return header + ":\n\n" + text_output + return matches # type: ignore[no-any-return] - if output_format == "text": - if not matches: - return "No matches found" - text_output = format_matches_as_text(matches) - header = f"Found {len(matches)} matches" - if max_results is not None and total_matches > max_results: - header += f" (showing first {max_results} of {total_matches})" - return header + ":\n\n" + text_output - return matches # type: ignore[no-any-return] def format_matches_as_text(matches: List[dict]) -> str: """Convert JSON matches to LLM-friendly text format. @@ -248,6 +252,29 @@ def format_matches_as_text(matches: List[dict]) -> str: return '\n\n'.join(output_blocks) +def get_supported_languages() -> List[str]: + """Get all supported languages as a field description string.""" + languages = [ # https://ast-grep.github.io/reference/languages.html + "bash", "c", "cpp", "csharp", "css", "elixir", "go", "haskell", + "html", "java", "javascript", "json", "jsx", "kotlin", "lua", + "nix", "php", "python", "ruby", "rust", "scala", "solidity", + "swift", "tsx", "typescript", "yaml" + ] + + # Check for custom languages in config file + # https://ast-grep.github.io/advanced/custom-language.html#register-language-in-sgconfig-yml + if CONFIG_PATH and os.path.exists(CONFIG_PATH): + try: + with open(CONFIG_PATH, 'r') as f: + config = yaml.safe_load(f) + if config and 'customLanguages' in config: + custom_langs = list(config['customLanguages'].keys()) + languages += custom_langs + except Exception: + pass + + return sorted(set(languages)) + def run_command(args: List[str], input_text: Optional[str] = None) -> subprocess.CompletedProcess: try: # On Windows, if ast-grep is installed via npm, it's a batch file @@ -281,7 +308,8 @@ def run_mcp_server() -> None: Run the MCP server. This function is used to start the MCP server when this script is run directly. """ - parse_args_and_get_config() + parse_args_and_get_config() # sets CONFIG_PATH + register_mcp_tools() # tools defined *after* CONFIG_PATH is known mcp.run(transport="stdio") if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 02e9d28..8b77bd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.13" dependencies = [ "pydantic>=2.11.0", "mcp[cli]>=1.6.0", + "pyyaml>=6.0.2", ] [project.optional-dependencies] @@ -16,6 +17,7 @@ dev = [ "pytest-mock>=3.14.0", "ruff>=0.7.0", "mypy>=1.13.0", + "types-pyyaml>=6.0.12.20250809", ] [project.scripts] @@ -55,3 +57,4 @@ warn_return_any = true warn_unused_configs = true disallow_untyped_defs = false ignore_missing_imports = true + diff --git a/tests/test_integration.py b/tests/test_integration.py index a631e6a..5b69c7f 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -17,11 +17,14 @@ class MockFastMCP: def __init__(self, name): self.name = name + self.tools = {} # Store registered tools def tool(self, **kwargs): """Decorator that returns the function unchanged""" def decorator(func): + # Store the function for later retrieval + self.tools[func.__name__] = func return func # Return original function without modification return decorator @@ -39,7 +42,14 @@ def mock_field(**kwargs): # Import with mocked decorators with patch("mcp.server.fastmcp.FastMCP", MockFastMCP): with patch("pydantic.Field", mock_field): - from main import find_code, find_code_by_rule + import main + + # Call register_mcp_tools to define the tool functions + main.register_mcp_tools() + + # Extract the tool functions from the mocked mcp instance + find_code = main.mcp.tools.get("find_code") + find_code_by_rule = main.mcp.tools.get("find_code_by_rule") @pytest.fixture diff --git a/tests/test_unit.py b/tests/test_unit.py index 99f790b..35c539c 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -18,11 +18,14 @@ class MockFastMCP: def __init__(self, name): self.name = name + self.tools = {} # Store registered tools def tool(self, **kwargs): """Decorator that returns the function unchanged""" def decorator(func): + # Store the function for later retrieval + self.tools[func.__name__] = func return func # Return original function without modification return decorator @@ -40,17 +43,21 @@ def mock_field(**kwargs): # Patch the imports before loading main with patch("mcp.server.fastmcp.FastMCP", MockFastMCP): with patch("pydantic.Field", mock_field): + import main from main import ( - dump_syntax_tree, - find_code, - find_code_by_rule, format_matches_as_text, run_ast_grep, run_command, ) - # Import with different name to avoid pytest treating it as a test - from main import test_match_code_rule as match_code_rule + # Call register_mcp_tools to define the tool functions + main.register_mcp_tools() + + # Extract the tool functions from the mocked mcp instance + dump_syntax_tree = main.mcp.tools.get("dump_syntax_tree") + find_code = main.mcp.tools.get("find_code") + find_code_by_rule = main.mcp.tools.get("find_code_by_rule") + match_code_rule = main.mcp.tools.get("test_match_code_rule") class TestDumpSyntaxTree: diff --git a/uv.lock b/uv.lock index 9eba6b5..dc53e03 100644 --- a/uv.lock +++ b/uv.lock @@ -395,6 +395,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256, upload-time = "2025-03-25T10:14:55.034Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + [[package]] name = "rich" version = "14.0.0" @@ -440,6 +457,7 @@ source = { virtual = "." } dependencies = [ { name = "mcp", extra = ["cli"] }, { name = "pydantic" }, + { name = "pyyaml" }, ] [package.optional-dependencies] @@ -449,6 +467,7 @@ dev = [ { name = "pytest-cov" }, { name = "pytest-mock" }, { name = "ruff" }, + { name = "types-pyyaml" }, ] [package.metadata] @@ -459,7 +478,9 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0.0" }, { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.14.0" }, + { name = "pyyaml", specifier = ">=6.0.2" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.7.0" }, + { name = "types-pyyaml", marker = "extra == 'dev'", specifier = ">=6.0.12.20250809" }, ] provides-extras = ["dev"] @@ -521,6 +542,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/fc/5b29fea8cee020515ca82cc68e3b8e1e34bb19a3535ad854cac9257b414c/typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc", size = 45061, upload-time = "2025-02-27T19:17:32.111Z" }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20250809" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/21/52ffdbddea3c826bc2758d811ccd7f766912de009c5cf096bd5ebba44680/types_pyyaml-6.0.12.20250809.tar.gz", hash = "sha256:af4a1aca028f18e75297da2ee0da465f799627370d74073e96fee876524f61b5", size = 17385, upload-time = "2025-08-09T03:14:34.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/3e/0346d09d6e338401ebf406f12eaf9d0b54b315b86f1ec29e34f1a0aedae9/types_pyyaml-6.0.12.20250809-py3-none-any.whl", hash = "sha256:032b6003b798e7de1a1ddfeefee32fac6486bdfe4845e0ae0e7fb3ee4512b52f", size = 20277, upload-time = "2025-08-09T03:14:34.055Z" }, +] + [[package]] name = "typing-extensions" version = "4.13.0"