From d2cbc4ada3e0a7cf683041f1ddc9d5fe10fcf0ab Mon Sep 17 00:00:00 2001 From: Dan Avner Date: Fri, 6 Feb 2026 21:08:21 -0800 Subject: [PATCH 1/2] Add introspection parameters to available settings. - Refactor logic unpacking response from endpoint to access errors. - Add tests for settings and schema to cover new parameters. - Update README to include new parameters. --- README.md | 19 +++- ariadne_codegen/main.py | 2 + ariadne_codegen/schema.py | 19 +++- ariadne_codegen/settings.py | 62 +++++++++++- tests/test_schema.py | 84 +++++++++++++++- tests/test_settings.py | 187 ++++++++++++++++++++++++++++++++++++ 6 files changed, 360 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 06595173..3d23a9f7 100644 --- a/README.md +++ b/README.md @@ -84,10 +84,19 @@ Optional settings: - `plugins` (defaults to `[]`) - list of plugins to use during generation - `enable_custom_operations` (defaults to `false`) - enables building custom operations. Generates additional files that contains all the classes and methods for generation. +These options control which fields are included in the GraphQL introspection query when using `remote_schema_url`. + +- `introspection_descriptions` (defaults to `false`) – include descriptions in the introspection result +- `introspection_input_value_deprecation` (defaults to `false`) – include deprecation information for input values +- `introspection_specified_by_url` (defaults to `false`) – include `specifiedByUrl` for custom scalars +- `introspection_schema_description` (defaults to `false`) – include schema description +- `introspection_directive_is_repeatable` (defaults to `false`) – include `isRepeatable` information for directives +- `introspection_input_object_one_of` (defaults to `false`) – include `oneOf` information for input objects + ## Custom operation builder -The custom operation builder allows you to create complex GraphQL queries in a structured and intuitive way. +The custom operation builder allows you to create complex GraphQL queries in a structured and intuitive way. ### Example Code @@ -154,11 +163,11 @@ asyncio.run(get_products()) 3. .alias("aliased_edges") renames the edges field to aliased_edges. 4. .on("ProductTranslatableContent", ...) specifies the fields to retrieve if the node is of type ProductTranslatableContent: id, product_id, and name. 3. Executing the Queries: - 1. The client.query(...) method is called with the built queries and an operation name "get_products". + 1. The client.query(...) method is called with the built queries and an operation name "get_products". 2. This method sends the queries to the server and retrieves the response. -### Example pyproject.toml configuration. +### Example pyproject.toml configuration. `Note: queries_path is optional when enable_custom_operations is set to true` @@ -188,7 +197,7 @@ Ariadne Codegen ships with optional plugins importable from the `ariadne_codegen [tool.ariadne-codegen] ... plugins = ["ariadne_codegen.contrib.extract_operations.ExtractOperationsPlugin"] - + [tool.ariadne-codegen.extract_operations] operations_module_name = "custom_operations_module_name" ``` @@ -234,7 +243,7 @@ To handle subscriptions, default `AsyncBaseClient` uses [websockets](https://git Default base client (`AsyncBaseClient` or `BaseClient`) checks if any part of `variables` dictionary is an instance of `Upload`. If at least one instance is found then client sends multipart request according to [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec). -Class `Upload` is included in generated client and can be imported from it: +Class `Upload` is included in generated client and can be imported from it: ```py from {target_package_name} import Upload diff --git a/ariadne_codegen/main.py b/ariadne_codegen/main.py index ea71f152..b37179f0 100644 --- a/ariadne_codegen/main.py +++ b/ariadne_codegen/main.py @@ -51,6 +51,7 @@ def client(config_dict): headers=settings.remote_schema_headers, verify_ssl=settings.remote_schema_verify_ssl, timeout=settings.remote_schema_timeout, + introspection_settings=settings.introspection_settings, ) plugin_manager = PluginManager( @@ -95,6 +96,7 @@ def graphql_schema(config_dict): headers=settings.remote_schema_headers, verify_ssl=settings.remote_schema_verify_ssl, timeout=settings.remote_schema_timeout, + introspection_settings=settings.introspection_settings, ) ) plugin_manager = PluginManager( diff --git a/ariadne_codegen/schema.py b/ariadne_codegen/schema.py index 53cf28ab..f5ed291c 100644 --- a/ariadne_codegen/schema.py +++ b/ariadne_codegen/schema.py @@ -1,4 +1,5 @@ from collections.abc import Generator +from dataclasses import asdict from pathlib import Path from typing import Optional, cast @@ -29,6 +30,7 @@ InvalidGraphqlSyntax, InvalidOperationForSchema, ) +from .settings import IntrospectionSettings def filter_operations_definitions( @@ -68,10 +70,15 @@ def get_graphql_schema_from_url( headers: Optional[dict[str, str]] = None, verify_ssl: bool = True, timeout: float = 5, + introspection_settings: Optional[IntrospectionSettings] = None, ) -> GraphQLSchema: return build_client_schema( introspect_remote_schema( - url=url, headers=headers, verify_ssl=verify_ssl, timeout=timeout + url=url, + headers=headers, + verify_ssl=verify_ssl, + timeout=timeout, + introspection_settings=introspection_settings, ), assume_valid=True, ) @@ -82,11 +89,15 @@ def introspect_remote_schema( headers: Optional[dict[str, str]] = None, verify_ssl: bool = True, timeout: float = 5, + introspection_settings: Optional[IntrospectionSettings] = None, ) -> IntrospectionQuery: + # If introspection settings are not provided, use default values. + settings = introspection_settings or IntrospectionSettings() + query = get_introspection_query(**asdict(settings)) try: response = httpx.post( url, - json={"query": get_introspection_query(descriptions=False)}, + json={"query": query}, headers=headers, verify=verify_ssl, timeout=timeout, @@ -105,14 +116,14 @@ def introspect_remote_schema( except ValueError as exc: raise IntrospectionError("Introspection result is not a valid json.") from exc - if (not isinstance(response_json, dict)) or ("data" not in response_json): + if not isinstance(response_json, dict): raise IntrospectionError("Invalid introspection result format.") errors = response_json.get("errors") if errors: raise IntrospectionError(f"Introspection errors: {errors}") - data = response_json["data"] + data = response_json.get("data") if not isinstance(data, dict): raise IntrospectionError("Invalid data key in introspection result.") diff --git a/ariadne_codegen/settings.py b/ariadne_codegen/settings.py index 37f5e0c6..f43f1caf 100644 --- a/ariadne_codegen/settings.py +++ b/ariadne_codegen/settings.py @@ -1,6 +1,6 @@ import enum import os -from dataclasses import dataclass, field +from dataclasses import asdict, dataclass, field from keyword import iskeyword from pathlib import Path from textwrap import dedent @@ -30,6 +30,20 @@ class Strategy(str, enum.Enum): GRAPHQL_SCHEMA = "graphqlschema" +@dataclass +class IntrospectionSettings: + """ + Introspection settings for schema generation. + """ + + descriptions: bool = False + input_value_deprecation: bool = False + specified_by_url: bool = False + schema_description: bool = False + directive_is_repeatable: bool = False + input_object_one_of: bool = False + + @dataclass class BaseSettings: schema_path: str = "" @@ -39,6 +53,12 @@ class BaseSettings: remote_schema_timeout: float = 5 enable_custom_operations: bool = False plugins: list[str] = field(default_factory=list) + introspection_descriptions: bool = False + introspection_input_value_deprecation: bool = False + introspection_specified_by_url: bool = False + introspection_schema_description: bool = False + introspection_directive_is_repeatable: bool = False + introspection_input_object_one_of: bool = False def __post_init__(self): if not self.schema_path and not self.remote_schema_url: @@ -51,6 +71,37 @@ def __post_init__(self): self.remote_schema_headers = resolve_headers(self.remote_schema_headers) + @property + def using_remote_schema(self) -> bool: + """ + Return true if remote schema is used as source, false otherwise. + """ + return bool(self.remote_schema_url) and not bool(self.schema_path) + + @property + def introspection_settings(self) -> IntrospectionSettings: + """ + Return ``IntrospectionSettings`` instance build from provided configuration. + """ + return IntrospectionSettings( + descriptions=self.introspection_descriptions, + input_value_deprecation=self.introspection_input_value_deprecation, + specified_by_url=self.introspection_specified_by_url, + schema_description=self.introspection_schema_description, + directive_is_repeatable=self.introspection_directive_is_repeatable, + input_object_one_of=self.introspection_input_object_one_of, + ) + + def _introspection_settings_message(self) -> str: + """ + Return human readable message with introspection settings values. + """ + formatted = ", ".join( + f"{key}={str(value).lower()}" + for key, value in asdict(self.introspection_settings).items() + ) + return f"Introspection settings: {formatted}" + @dataclass class ClientSettings(BaseSettings): @@ -173,10 +224,14 @@ def used_settings_message(self) -> str: if self.include_typename else "Not including __typename fields in generated queries." ) + introspection_msg = ( + self._introspection_settings_message() if self.using_remote_schema else "" + ) return dedent( f"""\ Selected strategy: {Strategy.CLIENT} Using schema from '{self.schema_path or self.remote_schema_url}'. + {introspection_msg} Reading queries from '{self.queries_path}'. Using '{self.target_package_name}' as package name. Generating package into '{self.target_package_path}'. @@ -217,12 +272,16 @@ def used_settings_message(self): if self.plugins else "No plugin is being used." ) + introspection_msg = ( + self._introspection_settings_message() if self.using_remote_schema else "" + ) if self.target_file_format == "py": return dedent( f"""\ Selected strategy: {Strategy.GRAPHQL_SCHEMA} Using schema from {self.schema_path or self.remote_schema_url} + {introspection_msg} Saving graphql schema to: {self.target_file_path} Using {self.schema_variable_name} as variable name for schema. Using {self.type_map_variable_name} as variable name for type map. @@ -234,6 +293,7 @@ def used_settings_message(self): f"""\ Selected strategy: {Strategy.GRAPHQL_SCHEMA} Using schema from {self.schema_path or self.remote_schema_url} + {introspection_msg} Saving graphql schema to: {self.target_file_path} {plugins_msg} """ diff --git a/tests/test_schema.py b/tests/test_schema.py index d165e637..85a84f46 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -15,6 +15,7 @@ read_graphql_file, walk_graphql_files, ) +from ariadne_codegen.settings import IntrospectionSettings @pytest.fixture @@ -294,9 +295,11 @@ def test_introspect_remote_schema_raises_introspection_error_for_not_dict_respon return_value=httpx.Response(status_code=200, content="[]"), ) - with pytest.raises(IntrospectionError): + with pytest.raises(IntrospectionError) as exc: introspect_remote_schema("http://testserver/graphql/") + assert "Invalid introspection result format." in str(exc.value) + def test_introspect_remote_schema_raises_introspection_error_for_json_without_data_key( mocker, @@ -306,9 +309,11 @@ def test_introspect_remote_schema_raises_introspection_error_for_json_without_da return_value=httpx.Response(status_code=200, content='{"not_data": null}'), ) - with pytest.raises(IntrospectionError): + with pytest.raises(IntrospectionError) as exc: introspect_remote_schema("http://testserver/graphql/") + assert "Invalid data key in introspection result." in str(exc.value) + def test_introspect_remote_schema_raises_introspection_error_for_graphql_errors(mocker): mocker.patch( @@ -345,9 +350,11 @@ def test_introspect_remote_schema_raises_introspection_error_for_invalid_data_va ), ) - with pytest.raises(IntrospectionError): + with pytest.raises(IntrospectionError) as exc: introspect_remote_schema("http://testserver/graphql/") + assert "Invalid data key in introspection result." in str(exc.value) + def test_introspect_remote_schema_returns_introspection_result(mocker): mocker.patch( @@ -436,3 +443,74 @@ def test_get_graphql_queries_with_invalid_query_for_schema_raises_invalid_operat get_graphql_queries( invalid_query_for_schema_file.as_posix(), build_schema(schema_str) ) + + +def test_introspect_remote_schema_passes_introspection_settings_to_introspection_query( + mocker, +): + """ + Test that the introspection settings are passed to the get_introspection_query + function when introspecting the remote schema. + """ + mocked_get_query = mocker.patch( + "ariadne_codegen.schema.get_introspection_query", + return_value="query { __schema { queryType { name } } }", + ) + mocker.patch( + "ariadne_codegen.schema.httpx.post", + return_value=httpx.Response( + status_code=200, content='{"data": {"__schema": {}}}' + ), + ) + + settings = IntrospectionSettings( + descriptions=True, + input_value_deprecation=True, + specified_by_url=True, + schema_description=True, + directive_is_repeatable=True, + input_object_one_of=True, + ) + + introspect_remote_schema( + "http://testserver/graphql/", introspection_settings=settings + ) + + mocked_get_query.assert_called_once_with( + descriptions=True, + specified_by_url=True, + directive_is_repeatable=True, + schema_description=True, + input_value_deprecation=True, + input_object_one_of=True, + ) + + +def test_introspect_remote_schema_uses_default_introspection_settings_when_not_provided( + mocker, +): + """ + Test that when introspection settings are not provided, the default values are used + in the get_introspection_query call. + """ + mocked_get_query = mocker.patch( + "ariadne_codegen.schema.get_introspection_query", + return_value="query { __schema { queryType { name } } }", + ) + mocker.patch( + "ariadne_codegen.schema.httpx.post", + return_value=httpx.Response( + status_code=200, content='{"data": {"__schema": {}}}' + ), + ) + + introspect_remote_schema("http://testserver/graphql/") + + mocked_get_query.assert_called_once_with( + descriptions=False, + specified_by_url=False, + directive_is_repeatable=False, + schema_description=False, + input_value_deprecation=False, + input_object_one_of=False, + ) diff --git a/tests/test_settings.py b/tests/test_settings.py index ed39e8d3..4d60a1e0 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -352,3 +352,190 @@ def test_client_settings_include_typename_can_be_set_to_true(tmp_path): ) assert settings.include_typename is True + + +def test_using_remote_schema_true_when_only_remote_schema_url_is_provided(tmp_path): + """ + Test that using_remote_schema is True when only remote_schema_url is provided. + """ + queries_path = tmp_path / "queries.graphql" + queries_path.touch() + + settings = ClientSettings( + remote_schema_url="http://testserver/graphql/", + queries_path=queries_path.as_posix(), + ) + + assert settings.using_remote_schema is True + + +def test_using_remote_schema_false_when_only_schema_path_is_provided(tmp_path): + """ + Test that using_remote_schema is False when only schema_path is provided. + """ + schema_path = tmp_path / "schema.graphql" + schema_path.touch() + queries_path = tmp_path / "queries.graphql" + queries_path.touch() + + base_client_file_content = """ + class BaseClient: + pass + """ + base_client_file_path = tmp_path / "base_client.py" + base_client_file_path.write_text(dedent(base_client_file_content)) + + settings = ClientSettings( + schema_path=schema_path.as_posix(), + queries_path=queries_path.as_posix(), + base_client_name="BaseClient", + base_client_file_path=base_client_file_path.as_posix(), + ) + + assert settings.using_remote_schema is False + + +def test_using_remote_schema_false_when_both_provided(tmp_path): + """ + Test that using_remote_schema is False when both schema_path and remote_schema_url + are provided. + """ + schema_path = tmp_path / "schema.graphql" + schema_path.touch() + queries_path = tmp_path / "queries.graphql" + queries_path.touch() + + base_client_file_content = """ + class BaseClient: + pass + """ + base_client_file_path = tmp_path / "base_client.py" + base_client_file_path.write_text(dedent(base_client_file_content)) + + settings = ClientSettings( + schema_path=schema_path.as_posix(), + remote_schema_url="http://testserver/graphql/", + queries_path=queries_path.as_posix(), + base_client_name="BaseClient", + base_client_file_path=base_client_file_path.as_posix(), + ) + + assert settings.using_remote_schema is False + + +def test_introspection_settings_defaults(tmp_path): + """ + Test that introspection settings have the correct default values. + """ + queries_path = tmp_path / "queries.graphql" + queries_path.touch() + + settings = ClientSettings( + remote_schema_url="http://testserver/graphql/", + queries_path=queries_path.as_posix(), + ) + + opts = settings.introspection_settings + assert opts.descriptions is False + assert opts.input_value_deprecation is False + assert opts.specified_by_url is False + assert opts.schema_description is False + assert opts.directive_is_repeatable is False + assert opts.input_object_one_of is False + + +def test_introspection_settings_overrides_are_mapped(tmp_path): + """ + Test that introspection settings overrides are correctly mapped. + """ + queries_path = tmp_path / "queries.graphql" + queries_path.touch() + + settings = ClientSettings( + remote_schema_url="http://testserver/graphql/", + queries_path=queries_path.as_posix(), + introspection_descriptions=True, + introspection_input_value_deprecation=True, + introspection_specified_by_url=True, + introspection_schema_description=True, + introspection_directive_is_repeatable=True, + introspection_input_object_one_of=True, + ) + + opts = settings.introspection_settings + assert opts.descriptions is True + assert opts.input_value_deprecation is True + assert opts.specified_by_url is True + assert opts.schema_description is True + assert opts.directive_is_repeatable is True + assert opts.input_object_one_of is True + + +def test_client_settings_used_settings_message_includes_introspection( + tmp_path, +): + """ + Test that used_settings_message includes introspection settings when remote schema + is used. + """ + queries_path = tmp_path / "queries.graphql" + queries_path.touch() + + remote_settings = ClientSettings( + remote_schema_url="http://testserver/graphql/", + queries_path=queries_path.as_posix(), + introspection_schema_description=True, + ) + assert "Introspection settings:" in remote_settings.used_settings_message + assert "schema_description=true" in remote_settings.used_settings_message + + schema_path = tmp_path / "schema.graphql" + schema_path.touch() + + base_client_file_content = """ + class BaseClient: + pass + """ + base_client_file_path = tmp_path / "base_client.py" + base_client_file_path.write_text(dedent(base_client_file_content)) + + local_settings = ClientSettings( + schema_path=schema_path.as_posix(), + queries_path=queries_path.as_posix(), + base_client_name="BaseClient", + base_client_file_path=base_client_file_path.as_posix(), + introspection_schema_description=True, + ) + assert "Introspection settings:" not in local_settings.used_settings_message + + +def test_graphql_schema_settings_used_settings_message_includes_introspection( + tmp_path, +): + """ + Test that used_settings_message includes introspection settings when remote schema + is used. + """ + remote_settings = GraphQLSchemaSettings( + remote_schema_url="http://testserver/graphql/", + introspection_specified_by_url=True, + ) + assert "Introspection settings:" in remote_settings.used_settings_message + assert "specified_by_url=true" in remote_settings.used_settings_message + assert "descriptions=false" in remote_settings.used_settings_message + + schema_path = tmp_path / "schema.graphql" + schema_path.touch() + + local_settings = GraphQLSchemaSettings( + schema_path=schema_path.as_posix(), + introspection_specified_by_url=True, + ) + assert "Introspection settings:" not in local_settings.used_settings_message + + both_settings = GraphQLSchemaSettings( + schema_path=schema_path.as_posix(), + remote_schema_url="http://testserver/graphql/", + introspection_specified_by_url=True, + ) + assert "Introspection settings:" not in both_settings.used_settings_message From c56aacbd53dc0b4112847273ae88215a7befee60 Mon Sep 17 00:00:00 2001 From: Damian Czajkowski Date: Thu, 12 Mar 2026 14:11:36 +0100 Subject: [PATCH 2/2] add comment on input_object_one_of to remind to rename when bumping ariadne-core --- ariadne_codegen/settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ariadne_codegen/settings.py b/ariadne_codegen/settings.py index dcad79d4..d87877b6 100644 --- a/ariadne_codegen/settings.py +++ b/ariadne_codegen/settings.py @@ -42,6 +42,7 @@ class IntrospectionSettings: specified_by_url: bool = False schema_description: bool = False directive_is_repeatable: bool = False + # graphql-core will rename this to one_of in a future version (update when bumping) input_object_one_of: bool = False