From 25ead97ba062cf0809ee4dff88563f2335f4fd39 Mon Sep 17 00:00:00 2001 From: Baptiste <32564248+BaptisteGi@users.noreply.github.com> Date: Tue, 30 Dec 2025 09:10:49 +0100 Subject: [PATCH 1/2] Fix issue #713 and #712 (#714) * Fix issue #713 * Adjust documentation * Fix issue #712 * Raise error if we can't type for selection node * Add tests for ctl --- docs/docs/python-sdk/guides/python-typing.mdx | 49 +++- infrahub_sdk/ctl/graphql.py | 19 +- infrahub_sdk/graphql/utils.py | 85 ++++++ .../graphql/invalid_query.gql | 9 + .../graphql/query_with_typename.gql | 16 ++ .../graphql/test_schema.graphql | 47 ++++ .../test_infrahubctl/graphql/valid_query.gql | 15 + tests/unit/ctl/test_graphql_app.py | 266 ++++++++++++++++++ tests/unit/ctl/test_graphql_utils.py | 242 ++++++++++++++++ 9 files changed, 733 insertions(+), 15 deletions(-) create mode 100644 tests/fixtures/unit/test_infrahubctl/graphql/invalid_query.gql create mode 100644 tests/fixtures/unit/test_infrahubctl/graphql/query_with_typename.gql create mode 100644 tests/fixtures/unit/test_infrahubctl/graphql/test_schema.graphql create mode 100644 tests/fixtures/unit/test_infrahubctl/graphql/valid_query.gql create mode 100644 tests/unit/ctl/test_graphql_app.py create mode 100644 tests/unit/ctl/test_graphql_utils.py diff --git a/docs/docs/python-sdk/guides/python-typing.mdx b/docs/docs/python-sdk/guides/python-typing.mdx index d05b9394..9bc2c323 100644 --- a/docs/docs/python-sdk/guides/python-typing.mdx +++ b/docs/docs/python-sdk/guides/python-typing.mdx @@ -111,7 +111,7 @@ When working with GraphQL queries, you can generate type-safe Pydantic models th Generated Pydantic models from GraphQL queries offer several important benefits: - **Type Safety**: Catch type errors during development time instead of at runtime -- **IDE Support**: Get autocomplete, type hints, and better IntelliSense in your IDE +- **IDE Support**: Get autocomplete, type hints, and better IntelliSense in your IDE - **Documentation**: Generated models serve as living documentation of your GraphQL API - **Validation**: Automatic validation of query responses against the expected schema @@ -120,32 +120,59 @@ Generated Pydantic models from GraphQL queries offer several important benefits: Use the `infrahubctl graphql generate-return-types` command to create Pydantic models from your GraphQL queries: ```shell -# Generate models for queries in current directory -infrahubctl graphql generate-return-types +# Generate models for queries in a directory +infrahubctl graphql generate-return-types queries/ # Generate models for specific query files -infrahubctl graphql generate-return-types queries/get_devices.gql +infrahubctl graphql generate-return-types queries/get_tags.gql ``` -> You can also export the GraphQL schema first using the `infrahubctl graphql export-schema` command: +> You can also export the GraphQL schema first using the `infrahubctl graphql export-schema` command. ### Example workflow -1. **Create your GraphQL queries** in `.gql` files: +1. **Create your GraphQL queries** in `.gql` files preferably in a directory (e.g., `queries/`): + + ```graphql + # queries/get_tags.gql + query GetAllTags { + BuiltinTag { + edges { + node { + __typename + name { + value + } + } + } + } + } + ``` -2. **Generate the Pydantic models**: +2. **Export the GraphQL schema**: + + ```shell + infrahubctl graphql export-schema + ``` + +3. **Generate the Pydantic models**: ```shell infrahubctl graphql generate-return-types queries/ ``` - The command will generate the Python file per query based on the name of the query. + :::warning Query names + + Ensure each of your GraphQL queries has a unique name, as the generated Python files will be named based on these query names. + Two queries with the same name will land in the same file, leading to potential overrides. + + ::: -3. **Use the generated models** in your Python code +4. **Use the generated models** in your Python code ```python - from .queries.get_devices import GetDevicesQuery + from .queries.get_tags import GetAllTagsQuery response = await client.execute_graphql(query=MY_QUERY) - data = GetDevicesQuery(**response) + data = GetAllTagsQuery(**response) ``` diff --git a/infrahub_sdk/ctl/graphql.py b/infrahub_sdk/ctl/graphql.py index 6229d42a..ea0158ce 100644 --- a/infrahub_sdk/ctl/graphql.py +++ b/infrahub_sdk/ctl/graphql.py @@ -22,7 +22,12 @@ from ..async_typer import AsyncTyper from ..ctl.client import initialize_client from ..ctl.utils import catch_exception -from ..graphql.utils import insert_fragments_inline, remove_fragment_import +from ..graphql.utils import ( + insert_fragments_inline, + remove_fragment_import, + strip_typename_from_fragment, + strip_typename_from_operation, +) from .parameters import CONFIG_PARAM app = AsyncTyper() @@ -152,12 +157,18 @@ async def generate_return_types( queries = filter_operations_definitions(definitions) fragments = filter_fragments_definitions(definitions) + # Strip __typename fields from operations and fragments before code generation. + # __typename is a GraphQL introspection meta-field that isn't part of the schema's + # type definitions, causing ariadne-codegen to fail with "Redefinition of reserved type 'String'" + stripped_queries = [strip_typename_from_operation(q) for q in queries] + stripped_fragments = [strip_typename_from_fragment(f) for f in fragments] + package_generator = get_package_generator( schema=graphql_schema, - fragments=fragments, + fragments=stripped_fragments, settings=ClientSettings( schema_path=str(schema), - target_package_name=directory.name, + target_package_name=directory.name or "graphql_client", queries_path=str(directory), include_comments=CommentsStrategy.NONE, ), @@ -166,7 +177,7 @@ async def generate_return_types( parsing_failed = False try: - for query_operation in queries: + for query_operation in stripped_queries: package_generator.add_operation(query_operation) except ParsingError as exc: console.print(f"[red]Unable to process {gql_file.name}: {exc}") diff --git a/infrahub_sdk/graphql/utils.py b/infrahub_sdk/graphql/utils.py index 0756460d..39e4aa4e 100644 --- a/infrahub_sdk/graphql/utils.py +++ b/infrahub_sdk/graphql/utils.py @@ -1,5 +1,90 @@ import ast +from graphql import ( + FieldNode, + FragmentDefinitionNode, + FragmentSpreadNode, + InlineFragmentNode, + OperationDefinitionNode, + SelectionNode, + SelectionSetNode, +) + + +def strip_typename_from_selection_set(selection_set: SelectionSetNode | None) -> SelectionSetNode | None: + """Recursively strip __typename fields from a SelectionSetNode. + + The __typename meta-field is an introspection field that is not part of the schema's + type definitions. When code generation tools like ariadne-codegen try to look up + __typename in the schema, they fail because it's a reserved introspection field. + + This function removes all __typename fields from the selection set, allowing + code generation to proceed without errors. + """ + if selection_set is None: + return None + + new_selections: list[SelectionNode] = [] + for selection in selection_set.selections: + if isinstance(selection, FieldNode): + # Skip __typename fields + if selection.name.value == "__typename": + continue + # Recursively process nested selection sets + new_field = FieldNode( + alias=selection.alias, + name=selection.name, + arguments=selection.arguments, + directives=selection.directives, + selection_set=strip_typename_from_selection_set(selection.selection_set), + ) + new_selections.append(new_field) + elif isinstance(selection, InlineFragmentNode): + # Process inline fragments + new_inline = InlineFragmentNode( + type_condition=selection.type_condition, + directives=selection.directives, + selection_set=strip_typename_from_selection_set(selection.selection_set), + ) + new_selections.append(new_inline) + elif isinstance(selection, FragmentSpreadNode): + # FragmentSpread references a named fragment - keep as-is + new_selections.append(selection) + else: + raise TypeError(f"Unexpected GraphQL selection node type '{type(selection).__name__}'.") + + return SelectionSetNode(selections=tuple(new_selections)) + + +def strip_typename_from_operation(operation: OperationDefinitionNode) -> OperationDefinitionNode: + """Strip __typename fields from an operation definition. + + Returns a new OperationDefinitionNode with all __typename fields removed + from its selection set and any nested selection sets. + """ + return OperationDefinitionNode( + operation=operation.operation, + name=operation.name, + variable_definitions=operation.variable_definitions, + directives=operation.directives, + selection_set=strip_typename_from_selection_set(operation.selection_set), + ) + + +def strip_typename_from_fragment(fragment: FragmentDefinitionNode) -> FragmentDefinitionNode: + """Strip __typename fields from a fragment definition. + + Returns a new FragmentDefinitionNode with all __typename fields removed + from its selection set and any nested selection sets. + """ + return FragmentDefinitionNode( + name=fragment.name, + type_condition=fragment.type_condition, + variable_definitions=fragment.variable_definitions, + directives=fragment.directives, + selection_set=strip_typename_from_selection_set(fragment.selection_set), + ) + def get_class_def_index(module: ast.Module) -> int: """Get the index of the first class definition in the module. diff --git a/tests/fixtures/unit/test_infrahubctl/graphql/invalid_query.gql b/tests/fixtures/unit/test_infrahubctl/graphql/invalid_query.gql new file mode 100644 index 00000000..8e45a1d0 --- /dev/null +++ b/tests/fixtures/unit/test_infrahubctl/graphql/invalid_query.gql @@ -0,0 +1,9 @@ +query InvalidQuery { + NonExistentType { + edges { + node { + id + } + } + } +} diff --git a/tests/fixtures/unit/test_infrahubctl/graphql/query_with_typename.gql b/tests/fixtures/unit/test_infrahubctl/graphql/query_with_typename.gql new file mode 100644 index 00000000..43c6d7c3 --- /dev/null +++ b/tests/fixtures/unit/test_infrahubctl/graphql/query_with_typename.gql @@ -0,0 +1,16 @@ +query GetTagsWithTypename($name: String!) { + BuiltinTag(name__value: $name) { + __typename + edges { + __typename + node { + __typename + id + name { + __typename + value + } + } + } + } +} diff --git a/tests/fixtures/unit/test_infrahubctl/graphql/test_schema.graphql b/tests/fixtures/unit/test_infrahubctl/graphql/test_schema.graphql new file mode 100644 index 00000000..e2a631f0 --- /dev/null +++ b/tests/fixtures/unit/test_infrahubctl/graphql/test_schema.graphql @@ -0,0 +1,47 @@ +"""Attribute of type Text""" +type TextAttribute implements AttributeInterface { + is_default: Boolean + is_inherited: Boolean + is_protected: Boolean + is_visible: Boolean + updated_at: DateTime + id: String + value: String +} + +interface AttributeInterface { + is_default: Boolean + is_inherited: Boolean + is_protected: Boolean + is_visible: Boolean + updated_at: DateTime +} + +scalar DateTime + +type BuiltinTag implements CoreNode { + """Unique identifier""" + id: String! + display_label: String + """Description""" + description: TextAttribute + """Name (required)""" + name: TextAttribute +} + +interface CoreNode { + id: String! +} + +type EdgedBuiltinTag { + node: BuiltinTag +} + +type PaginatedBuiltinTag { + count: Int! + edges: [EdgedBuiltinTag!]! +} + +type Query { + BuiltinTag(name__value: String, ids: [ID]): PaginatedBuiltinTag +} diff --git a/tests/fixtures/unit/test_infrahubctl/graphql/valid_query.gql b/tests/fixtures/unit/test_infrahubctl/graphql/valid_query.gql new file mode 100644 index 00000000..574e06d5 --- /dev/null +++ b/tests/fixtures/unit/test_infrahubctl/graphql/valid_query.gql @@ -0,0 +1,15 @@ +query GetTags($name: String!) { + BuiltinTag(name__value: $name) { + edges { + node { + id + name { + value + } + description { + value + } + } + } + } +} diff --git a/tests/unit/ctl/test_graphql_app.py b/tests/unit/ctl/test_graphql_app.py new file mode 100644 index 00000000..07af1d20 --- /dev/null +++ b/tests/unit/ctl/test_graphql_app.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +import os +import shutil +from pathlib import Path + +import pytest +from ariadne_codegen.schema import get_graphql_schema_from_path +from typer.testing import CliRunner + +from infrahub_sdk.ctl.graphql import app, find_gql_files, get_graphql_query +from tests.helpers.cli import remove_ansi_color + +runner = CliRunner() + +FIXTURES_DIR = Path(__file__).parent.parent.parent / "fixtures" / "unit" / "test_infrahubctl" / "graphql" + + +class TestFindGqlFiles: + """Tests for find_gql_files helper function.""" + + def test_find_gql_files_single_file(self, tmp_path: Path) -> None: + """Test finding a single .gql file when path points to a file.""" + query_file = tmp_path / "query.gql" + query_file.write_text("query Test { field }") + + result = find_gql_files(query_file) + + assert len(result) == 1 + assert result[0] == query_file + + def test_find_gql_files_directory(self, tmp_path: Path) -> None: + """Test finding multiple .gql files in a directory.""" + (tmp_path / "query1.gql").write_text("query Test1 { field }") + (tmp_path / "query2.gql").write_text("query Test2 { field }") + (tmp_path / "not_a_query.txt").write_text("not a query") + + result = find_gql_files(tmp_path) + + assert len(result) == 2 + assert all(f.suffix == ".gql" for f in result) + + def test_find_gql_files_nested_directory(self, tmp_path: Path) -> None: + """Test finding .gql files in nested directories.""" + subdir = tmp_path / "subdir" + subdir.mkdir() + (tmp_path / "query1.gql").write_text("query Test1 { field }") + (subdir / "query2.gql").write_text("query Test2 { field }") + + result = find_gql_files(tmp_path) + + assert len(result) == 2 + + def test_find_gql_files_nonexistent_path(self, tmp_path: Path) -> None: + """Test that FileNotFoundError is raised for non-existent path.""" + nonexistent = tmp_path / "nonexistent" + + with pytest.raises(FileNotFoundError, match="File or directory not found"): + find_gql_files(nonexistent) + + def test_find_gql_files_empty_directory(self, tmp_path: Path) -> None: + """Test finding no .gql files in an empty directory.""" + result = find_gql_files(tmp_path) + + assert len(result) == 0 + + +class TestGetGraphqlQuery: + """Tests for get_graphql_query helper function.""" + + def test_get_graphql_query_valid(self) -> None: + """Test parsing a valid GraphQL query.""" + schema = get_graphql_schema_from_path(str(FIXTURES_DIR / "test_schema.graphql")) + query_file = FIXTURES_DIR / "valid_query.gql" + + definitions = get_graphql_query(query_file, schema) + + assert len(definitions) == 1 + assert definitions[0].name.value == "GetTags" + + def test_get_graphql_query_invalid(self) -> None: + """Test that invalid query raises ValueError.""" + schema = get_graphql_schema_from_path(str(FIXTURES_DIR / "test_schema.graphql")) + query_file = FIXTURES_DIR / "invalid_query.gql" + + with pytest.raises(ValueError, match="Cannot query field"): + get_graphql_query(query_file, schema) + + def test_get_graphql_query_nonexistent_file(self) -> None: + """Test that FileNotFoundError is raised for non-existent file.""" + schema = get_graphql_schema_from_path(str(FIXTURES_DIR / "test_schema.graphql")) + nonexistent = FIXTURES_DIR / "nonexistent.gql" + + with pytest.raises(FileNotFoundError, match="File not found"): + get_graphql_query(nonexistent, schema) + + def test_get_graphql_query_directory_instead_of_file(self) -> None: + """Test that ValueError is raised when path is a directory.""" + schema = get_graphql_schema_from_path(str(FIXTURES_DIR / "test_schema.graphql")) + + with pytest.raises(ValueError, match="is not a file"): + get_graphql_query(FIXTURES_DIR, schema) + + +class TestGenerateReturnTypesCommand: + """Tests for the generate-return-types CLI command.""" + + def test_generate_return_types_success(self, tmp_path: Path) -> None: + """Test successful generation of return types from a valid query.""" + # Copy fixtures to temp directory + schema_file = tmp_path / "schema.graphql" + query_file = tmp_path / "query.gql" + + shutil.copy(FIXTURES_DIR / "test_schema.graphql", schema_file) + shutil.copy(FIXTURES_DIR / "valid_query.gql", query_file) + + # Run the command + result = runner.invoke( + app, ["generate-return-types", str(query_file), "--schema", str(schema_file)], catch_exceptions=False + ) + + assert result.exit_code == 0 + clean_output = remove_ansi_color(result.stdout) + assert "Generated" in clean_output + + # Check that a file was generated + generated_files = list(tmp_path.glob("*.py")) + assert len(generated_files) >= 1 + + def test_generate_return_types_directory(self, tmp_path: Path) -> None: + """Test generation when providing a directory of queries.""" + # Copy fixtures to temp directory + schema_file = tmp_path / "schema.graphql" + query_dir = tmp_path / "queries" + query_dir.mkdir() + + shutil.copy(FIXTURES_DIR / "test_schema.graphql", schema_file) + shutil.copy(FIXTURES_DIR / "valid_query.gql", query_dir / "query.gql") + + # Run the command with directory + result = runner.invoke( + app, ["generate-return-types", str(query_dir), "--schema", str(schema_file)], catch_exceptions=False + ) + + assert result.exit_code == 0 + clean_output = remove_ansi_color(result.stdout) + assert "Generated" in clean_output + + def test_generate_return_types_missing_schema(self, tmp_path: Path) -> None: + """Test error when schema file is missing.""" + query_file = tmp_path / "query.gql" + shutil.copy(FIXTURES_DIR / "valid_query.gql", query_file) + + result = runner.invoke(app, ["generate-return-types", str(query_file), "--schema", "nonexistent.graphql"]) + + assert result.exit_code == 1 + clean_output = remove_ansi_color(result.stdout) + assert "not found" in clean_output.lower() + + def test_generate_return_types_invalid_query(self, tmp_path: Path) -> None: + """Test handling of invalid query (should print error and continue).""" + schema_file = tmp_path / "schema.graphql" + query_file = tmp_path / "query.gql" + + shutil.copy(FIXTURES_DIR / "test_schema.graphql", schema_file) + shutil.copy(FIXTURES_DIR / "invalid_query.gql", query_file) + + result = runner.invoke(app, ["generate-return-types", str(query_file), "--schema", str(schema_file)]) + + # Should exit successfully but print error message for invalid query + assert result.exit_code == 0 + clean_output = remove_ansi_color(result.stdout) + assert "Error" in clean_output + + def test_generate_return_types_with_typename(self, tmp_path: Path) -> None: + """Test that __typename fields are properly stripped during generation.""" + schema_file = tmp_path / "schema.graphql" + query_file = tmp_path / "query.gql" + + shutil.copy(FIXTURES_DIR / "test_schema.graphql", schema_file) + shutil.copy(FIXTURES_DIR / "query_with_typename.gql", query_file) + + result = runner.invoke( + app, ["generate-return-types", str(query_file), "--schema", str(schema_file)], catch_exceptions=False + ) + + assert result.exit_code == 0 + clean_output = remove_ansi_color(result.stdout) + assert "Generated" in clean_output + + def test_generate_return_types_default_cwd(self, tmp_path: Path) -> None: + """Test that command defaults to current directory when no query path provided.""" + # Copy fixtures to temp directory + schema_file = tmp_path / "schema.graphql" + query_file = tmp_path / "query.gql" + + shutil.copy(FIXTURES_DIR / "test_schema.graphql", schema_file) + shutil.copy(FIXTURES_DIR / "valid_query.gql", query_file) + + # Change to temp directory and run without specifying query path + original_dir = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, ["generate-return-types", "--schema", str(schema_file)], catch_exceptions=False) + finally: + os.chdir(original_dir) + + assert result.exit_code == 0 + clean_output = remove_ansi_color(result.stdout) + assert "Generated" in clean_output + + def test_generate_return_types_no_gql_files(self, tmp_path: Path) -> None: + """Test when directory has no .gql files.""" + schema_file = tmp_path / "schema.graphql" + shutil.copy(FIXTURES_DIR / "test_schema.graphql", schema_file) + + empty_dir = tmp_path / "empty" + empty_dir.mkdir() + + result = runner.invoke(app, ["generate-return-types", str(empty_dir), "--schema", str(schema_file)]) + + # Should exit successfully with no output + assert result.exit_code == 0 + + def test_generate_return_types_multiple_queries_same_dir(self, tmp_path: Path) -> None: + """Test generation with multiple query files in the same directory.""" + schema_file = tmp_path / "schema.graphql" + shutil.copy(FIXTURES_DIR / "test_schema.graphql", schema_file) + + # Create multiple valid queries + query1 = tmp_path / "query1.gql" + query2 = tmp_path / "query2.gql" + + query1.write_text(""" +query GetAllTags { + BuiltinTag { + edges { + node { + id + name { value } + } + } + } +} +""") + query2.write_text(""" +query GetTagByName($name: String!) { + BuiltinTag(name__value: $name) { + edges { + node { + id + description { value } + } + } + } +} +""") + + result = runner.invoke( + app, ["generate-return-types", str(tmp_path), "--schema", str(schema_file)], catch_exceptions=False + ) + + assert result.exit_code == 0 + clean_output = remove_ansi_color(result.stdout) + # Should generate files for both queries + assert clean_output.count("Generated") >= 2 diff --git a/tests/unit/ctl/test_graphql_utils.py b/tests/unit/ctl/test_graphql_utils.py new file mode 100644 index 00000000..d67549aa --- /dev/null +++ b/tests/unit/ctl/test_graphql_utils.py @@ -0,0 +1,242 @@ +from graphql import parse, print_ast + +from infrahub_sdk.graphql.utils import ( + strip_typename_from_fragment, + strip_typename_from_operation, + strip_typename_from_selection_set, +) + + +class TestStripTypename: + def test_strip_typename_from_simple_query(self) -> None: + query = """ + query Test { + BuiltinTag { + __typename + name + } + } + """ + doc = parse(query) + operation = doc.definitions[0] + result = strip_typename_from_operation(operation) + + result_str = print_ast(result) + assert "__typename" not in result_str + assert "name" in result_str + assert "BuiltinTag" in result_str + + def test_strip_typename_from_nested_query(self) -> None: + query = """ + query Test { + BuiltinTag { + edges { + node { + __typename + name { + value + } + } + } + } + } + """ + doc = parse(query) + operation = doc.definitions[0] + result = strip_typename_from_operation(operation) + + result_str = print_ast(result) + assert "__typename" not in result_str + assert "name" in result_str + assert "value" in result_str + assert "edges" in result_str + assert "node" in result_str + + def test_strip_typename_from_inline_fragment(self) -> None: + query = """ + query Test { + BuiltinTag { + edges { + node { + __typename + ... on Tag { + __typename + name { + value + } + } + } + } + } + } + """ + doc = parse(query) + operation = doc.definitions[0] + result = strip_typename_from_operation(operation) + + result_str = print_ast(result) + assert "__typename" not in result_str + assert "... on Tag" in result_str + assert "name" in result_str + + def test_strip_typename_from_fragment_definition(self) -> None: + query = """ + fragment TagFields on Tag { + __typename + name { + value + } + description { + value + } + } + """ + doc = parse(query) + fragment = doc.definitions[0] + result = strip_typename_from_fragment(fragment) + + result_str = print_ast(result) + assert "__typename" not in result_str + assert "name" in result_str + assert "description" in result_str + assert "TagFields" in result_str + + def test_strip_typename_preserves_fragment_spread(self) -> None: + query = """ + query Test { + BuiltinTag { + ...TagFields + __typename + } + } + """ + doc = parse(query) + operation = doc.definitions[0] + result = strip_typename_from_operation(operation) + + result_str = print_ast(result) + assert "__typename" not in result_str + assert "...TagFields" in result_str + + def test_strip_typename_from_empty_selection_set(self) -> None: + result = strip_typename_from_selection_set(None) + assert result is None + + def test_strip_typename_multiple_occurrences(self) -> None: + query = """ + query Test { + __typename + BuiltinTag { + __typename + edges { + __typename + node { + __typename + name { + __typename + value + } + } + } + } + } + """ + doc = parse(query) + operation = doc.definitions[0] + result = strip_typename_from_operation(operation) + + result_str = print_ast(result) + assert "__typename" not in result_str + # Should still have the actual fields + assert "BuiltinTag" in result_str + assert "edges" in result_str + assert "node" in result_str + assert "name" in result_str + assert "value" in result_str + + def test_strip_typename_preserves_aliases(self) -> None: + query = """ + query Test { + tags: BuiltinTag { + __typename + tagName: name { + value + } + } + } + """ + doc = parse(query) + operation = doc.definitions[0] + result = strip_typename_from_operation(operation) + + result_str = print_ast(result) + assert "__typename" not in result_str + assert "tags: BuiltinTag" in result_str + assert "tagName: name" in result_str + + def test_strip_typename_preserves_arguments(self) -> None: + query = """ + query Test { + BuiltinTag(first: 10, name__value: "test") { + __typename + edges { + node { + name { + value + } + } + } + } + } + """ + doc = parse(query) + operation = doc.definitions[0] + result = strip_typename_from_operation(operation) + + result_str = print_ast(result) + assert "__typename" not in result_str + assert "first: 10" in result_str + assert 'name__value: "test"' in result_str + + def test_strip_typename_preserves_directives(self) -> None: + query = """ + query Test { + BuiltinTag @include(if: true) { + __typename + name { + value + } + } + } + """ + doc = parse(query) + operation = doc.definitions[0] + result = strip_typename_from_operation(operation) + + result_str = print_ast(result) + assert "__typename" not in result_str + assert "@include(if: true)" in result_str + + def test_query_without_typename_unchanged(self) -> None: + query = """ + query Test { + BuiltinTag { + edges { + node { + name { + value + } + } + } + } + } + """ + doc = parse(query) + operation = doc.definitions[0] + result = strip_typename_from_operation(operation) + + # The structure should be effectively the same (modulo formatting) + original_str = print_ast(operation) + result_str = print_ast(result) + # Normalize whitespace for comparison + assert original_str.split() == result_str.split() From 627bae0dbee032edc1f95119d9d97302f0515f30 Mon Sep 17 00:00:00 2001 From: Patrick Ogenstad Date: Tue, 30 Dec 2025 10:33:42 +0100 Subject: [PATCH 2/2] Upgrade ruff=0.14.10 and fix new violation --- infrahub_sdk/schema/repository.py | 3 +- pyproject.toml | 2 +- uv.lock | 48 +++++++++++++++---------------- 3 files changed, 26 insertions(+), 27 deletions(-) diff --git a/infrahub_sdk/schema/repository.py b/infrahub_sdk/schema/repository.py index 9bd770cc..f079baac 100644 --- a/infrahub_sdk/schema/repository.py +++ b/infrahub_sdk/schema/repository.py @@ -151,8 +151,7 @@ class InfrahubRepositoryGraphQLConfig(InfrahubRepositoryConfigElement): def load_query(self, relative_path: str = ".") -> str: file_name = Path(f"{relative_path}/{self.file_path}") - with file_name.open("r", encoding="UTF-8") as file: - return file.read() + return file_name.read_text(encoding="UTF-8") class InfrahubObjectConfig(InfrahubRepositoryConfigElement): diff --git a/pyproject.toml b/pyproject.toml index a2fc1411..f55beb2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,7 +80,7 @@ tests = [ lint = [ "yamllint", "mypy==1.11.2", - "ruff==0.14.5", + "ruff==0.14.10", "astroid>=3.1,<4.0", "ty==0.0.4", ] diff --git a/uv.lock b/uv.lock index e2254991..f216a202 100644 --- a/uv.lock +++ b/uv.lock @@ -866,7 +866,7 @@ dev = [ { name = "pytest-httpx", specifier = ">=0.30" }, { name = "pytest-xdist", specifier = ">=3.3.1" }, { name = "requests" }, - { name = "ruff", specifier = "==0.14.5" }, + { name = "ruff", specifier = "==0.14.10" }, { name = "towncrier", specifier = ">=24.8.0" }, { name = "ty", specifier = "==0.0.4" }, { name = "types-python-slugify", specifier = ">=8.0.0.3" }, @@ -877,7 +877,7 @@ dev = [ lint = [ { name = "astroid", specifier = ">=3.1,<4.0" }, { name = "mypy", specifier = "==1.11.2" }, - { name = "ruff", specifier = "==0.14.5" }, + { name = "ruff", specifier = "==0.14.10" }, { name = "ty", specifier = "==0.0.4" }, { name = "yamllint" }, ] @@ -2569,28 +2569,28 @@ wheels = [ [[package]] name = "ruff" -version = "0.14.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944, upload-time = "2025-11-13T19:58:51.155Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630, upload-time = "2025-11-13T19:57:54.894Z" }, - { url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925, upload-time = "2025-11-13T19:57:59.181Z" }, - { url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040, upload-time = "2025-11-13T19:58:02.056Z" }, - { url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755, upload-time = "2025-11-13T19:58:05.172Z" }, - { url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641, upload-time = "2025-11-13T19:58:08.345Z" }, - { url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854, upload-time = "2025-11-13T19:58:11.419Z" }, - { url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088, upload-time = "2025-11-13T19:58:14.551Z" }, - { url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717, upload-time = "2025-11-13T19:58:17.518Z" }, - { url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812, upload-time = "2025-11-13T19:58:20.533Z" }, - { url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656, upload-time = "2025-11-13T19:58:23.345Z" }, - { url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922, upload-time = "2025-11-13T19:58:26.537Z" }, - { url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501, upload-time = "2025-11-13T19:58:29.822Z" }, - { url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319, upload-time = "2025-11-13T19:58:32.923Z" }, - { url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209, upload-time = "2025-11-13T19:58:35.952Z" }, - { url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709, upload-time = "2025-11-13T19:58:39.002Z" }, - { url = "https://files.pythonhosted.org/packages/cc/f1/7119e42aa1d3bf036ffc9478885c2e248812b7de9abea4eae89163d2929d/ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b", size = 12925808, upload-time = "2025-11-13T19:58:42.779Z" }, - { url = "https://files.pythonhosted.org/packages/3b/9d/7c0a255d21e0912114784e4a96bf62af0618e2190cae468cd82b13625ad2/ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621", size = 14331546, upload-time = "2025-11-13T19:58:45.691Z" }, - { url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload-time = "2025-11-13T19:58:48.434Z" }, +version = "0.14.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, + { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, + { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, + { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, + { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, + { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, ] [[package]]