diff --git a/README.md b/README.md index 8139156d..5a073228 100644 --- a/README.md +++ b/README.md @@ -50,9 +50,10 @@ Required settings: - `queries_path` - path to file/directory with queries (Can be optional if `enable_custom_operations` is used) -One of the following 2 parameters is required, in case of providing both of them `schema_path` is prioritized: +Exactly one of the following 3 parameters is required. They are mutually exclusive - providing more than one raises a configuration error: - `schema_path` - path to file/directory with graphql schema +- `schema_paths` - list of schema sources; each entry can be a local path (file or directory) or a dotted Python attribute path (resolved at codegen time). See [Loading schema from installed packages](#loading-schema-from-installed-packages). - `remote_schema_url` - url to graphql server, where introspection query can be perfomed Optional settings: @@ -88,6 +89,30 @@ These options control which fields are included in the GraphQL introspection que - `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 +## Loading schema from installed packages + +`schema_paths` lets you pull type definitions from installed Python packages alongside your local schema files, so codegen can resolve types that live in a shared library without copying them manually. + +Each entry in `schema_paths` is resolved in order and can be one of: + +- **Local path** — a file (`./shared/types.graphql`) or a directory (`./my_schemas/`). Directories are searched recursively for `.graphql`, `.graphqls`, and `.gql` files. +- **Dotted Python attribute** — `some_package.SCHEMA_DIR` or `some_package.get_schema_files`. The attribute is looked up via `importlib` at codegen time: + - If it is **callable**, it is called and expected to return a list of file paths. + - If it is a **string or `Path`**, it is treated as a directory and searched recursively. + +```toml +[tool.ariadne-codegen] +schema_paths = [ + "some_gql_commontypes.get_schema_files", # callable → returns list of paths + "other_pkg.SCHEMA_DIR", # Path attribute → directory + "./my_other_packages/", # local directory + "./foo/bar.graphql", # local file +] +queries_path = "queries.graphql" +``` + +`schema_path`, `schema_paths` and `remote_schema_url` are mutually exclusive - only one schema source may be used at a time. + ## Custom operation builder The custom operation builder allows you to create complex GraphQL queries in a structured and intuitive way. @@ -416,7 +441,7 @@ Instead of generating a client, you can generate a file with a copy of a GraphQL ariadne-codegen graphqlschema ``` -`graphqlschema` mode reads configuration from the same place as [`client`](#configuration) but uses only `schema_path`, `remote_schema_url`, `remote_schema_headers`, `remote_schema_verify_ssl`, `remote_schema_timeout` options to retrieve the schema and `plugins` option to load plugins. +`graphqlschema` mode reads configuration from the same place as [`client`](#configuration) but uses only `schema_path`, `schema_paths`, `remote_schema_url`, `remote_schema_headers`, `remote_schema_verify_ssl`, `remote_schema_timeout` options to retrieve the schema and `plugins` option to load plugins. In addition to the above, `graphqlschema` mode also accepts additional settings specific to it: diff --git a/ariadne_codegen/main.py b/ariadne_codegen/main.py index 910bd474..045f50c8 100644 --- a/ariadne_codegen/main.py +++ b/ariadne_codegen/main.py @@ -17,6 +17,7 @@ filter_operations_definitions, get_graphql_queries, get_graphql_schema_from_path, + get_graphql_schema_from_paths, get_graphql_schema_from_url, ) from .settings import Strategy, get_validation_rule @@ -45,6 +46,8 @@ def client(config_dict): if settings.schema_path: schema = get_graphql_schema_from_path(settings.schema_path) + elif settings.schema_paths: + schema = get_graphql_schema_from_paths(settings.schema_paths) else: schema = get_graphql_schema_from_url( url=settings.remote_schema_url, @@ -92,17 +95,18 @@ def client(config_dict): def graphql_schema(config_dict): settings = get_graphql_schema_settings(config_dict) - schema = ( - get_graphql_schema_from_path(settings.schema_path) - if settings.schema_path - else get_graphql_schema_from_url( + if settings.schema_path: + schema = get_graphql_schema_from_path(settings.schema_path) + elif settings.schema_paths: + schema = get_graphql_schema_from_paths(settings.schema_paths) + else: + schema = get_graphql_schema_from_url( url=settings.remote_schema_url, 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( schema=schema, config_dict=config_dict, diff --git a/ariadne_codegen/schema.py b/ariadne_codegen/schema.py index f66723b9..d7638964 100644 --- a/ariadne_codegen/schema.py +++ b/ariadne_codegen/schema.py @@ -1,3 +1,4 @@ +import importlib from collections.abc import Generator, Sequence from dataclasses import asdict from pathlib import Path @@ -141,6 +142,53 @@ def get_graphql_schema_from_path(schema_path: str) -> GraphQLSchema: return schema +def resolve_schema_paths(sources: list[str]) -> list[Path]: + """Resolve a list of schema sources to concrete file paths. + + Each entry is tried as a dotted Python import path first (e.g. + ``pkg.SCHEMA_DIR`` or ``pkg.get_schema_files``). If the import fails the + entry is treated as a local filesystem path instead. + """ + result: list[Path] = [] + for source in sources: + if ( + "." in source + and "/" not in source + and not source.endswith((".graphql", ".graphqls", ".gql")) + ): + try: + module_path, attr = source.rsplit(".", 1) + module = importlib.import_module(module_path) + obj = getattr(module, attr) + if callable(obj): + result.extend(Path(f) for f in obj()) + elif isinstance(obj, (str, Path)): + dir_path = Path(obj) + if dir_path.is_dir(): + result.extend(sorted(walk_graphql_files(dir_path))) + else: + result.append(dir_path) + continue + except (ImportError, ModuleNotFoundError): + pass + + local_path = Path(source) + if local_path.is_dir(): + result.extend(sorted(walk_graphql_files(local_path))) + else: + result.append(local_path) + return result + + +def get_graphql_schema_from_paths(schema_paths: list[str]) -> GraphQLSchema: + """Get graphql schema built from multiple path sources.""" + resolved = resolve_schema_paths(schema_paths) + schema_str = "\n".join(read_graphql_file(p) for p in resolved) + graphql_ast = parse(schema_str) + schema: GraphQLSchema = build_ast_schema(graphql_ast, assume_valid=True) + return schema + + def load_graphql_files_from_path(path: Path) -> str: """ Get schema from given path. diff --git a/ariadne_codegen/settings.py b/ariadne_codegen/settings.py index 709bfa2b..dc16cfae 100644 --- a/ariadne_codegen/settings.py +++ b/ariadne_codegen/settings.py @@ -66,6 +66,7 @@ class IntrospectionSettings: @dataclass class BaseSettings: schema_path: str = "" + schema_paths: list[str] = field(default_factory=list) remote_schema_url: str = "" remote_schema_headers: dict = field(default_factory=dict) remote_schema_verify_ssl: bool = True @@ -80,9 +81,25 @@ class BaseSettings: introspection_input_object_one_of: bool = False def __post_init__(self): - if not self.schema_path and not self.remote_schema_url: + provided_sources = [ + name + for name, value in ( + ("schema_path", self.schema_path), + ("schema_paths", self.schema_paths), + ("remote_schema_url", self.remote_schema_url), + ) + if value + ] + if not provided_sources: + raise InvalidConfiguration( + "Schema source not provided. Use one of: schema_path, schema_paths," + " or remote_schema_url." + ) + if len(provided_sources) > 1: raise InvalidConfiguration( - "Schema source not provided. Use schema_path or remote_schema_url" + "Cannot use more than one schema source at the same time. " + f"Provided: {', '.join(provided_sources)}. Use only one of: " + "schema_path, schema_paths, or remote_schema_url." ) if self.schema_path: @@ -97,7 +114,7 @@ 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) + return bool(self.remote_schema_url) @property def introspection_settings(self) -> IntrospectionSettings: @@ -220,7 +237,11 @@ def _set_default_base_client_data(self): @property def schema_source(self) -> str: - return self.schema_path if self.schema_path else self.remote_schema_url + if self.schema_path: + return self.schema_path + if self.schema_paths: + return ", ".join(self.schema_paths) + return self.remote_schema_url @property def used_settings_message(self) -> str: @@ -257,7 +278,7 @@ def used_settings_message(self) -> str: return dedent( f"""\ Selected strategy: {Strategy.CLIENT} - Using schema from '{self.schema_path or self.remote_schema_url}'. + Using schema from '{self.schema_source}'. {introspection_msg} Reading queries from '{self.queries_path}'. Using '{self.target_package_name}' as package name. @@ -303,11 +324,16 @@ def used_settings_message(self): self._introspection_settings_message() if self.using_remote_schema else "" ) + schema_source = self.schema_path or ( + ", ".join(self.schema_paths) + if self.schema_paths + else self.remote_schema_url + ) 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} + Using schema from {schema_source} {introspection_msg} Saving graphql schema to: {self.target_file_path} Using {self.schema_variable_name} as variable name for schema. @@ -319,7 +345,7 @@ def used_settings_message(self): return dedent( f"""\ Selected strategy: {Strategy.GRAPHQL_SCHEMA} - Using schema from {self.schema_path or self.remote_schema_url} + Using schema from {schema_source} {introspection_msg} Saving graphql schema to: {self.target_file_path} {plugins_msg} diff --git a/docs/02-configuration.md b/docs/02-configuration.md index f0f21c00..2ed84b91 100644 --- a/docs/02-configuration.md +++ b/docs/02-configuration.md @@ -18,11 +18,32 @@ queries_path = "queries.graphql" - `queries_path` - path to file/directory with queries (Can be optional if `enable_custom_operations` is used) -One of the following 2 parameters is required, in case of providing both of them `schema_path` is prioritized: +Exactly one of the following 3 parameters is required. They are mutually exclusive - providing more than one raises a configuration error: - `schema_path` - path to file/directory with graphql schema +- `schema_paths` - list of schema sources resolved at codegen time; each entry may be a local path (file or directory) or a dotted Python attribute path (`pkg.ATTR` or `pkg.callable`). See details below. - `remote_schema_url` - url to graphql server, where introspection query can be perfomed +### `schema_paths` resolution + +Each entry is resolved in order: + +1. **Dotted Python attribute** (no `/` in the string, not ending with a graphql extension) — the attribute is imported via `importlib`: + - **callable** → called, expected to return a list of file paths + - **string / `Path`** → treated as a directory and searched recursively for `.graphql`, `.graphqls`, `.gql` files +2. **Local path** (fallback when import fails, or when the entry contains `/` or has a graphql extension) — a file or directory searched recursively. + +```toml +[tool.ariadne-codegen] +schema_paths = [ + "some_gql_commontypes.get_schema_files", + "other_pkg.SCHEMA_DIR", + "./my_other_packages/", + "./foo/bar.graphql", +] +queries_path = "queries.graphql" +``` + ## Optional settings: - `remote_schema_headers` - extra headers that are passed along with introspection query, eg. `{"Authorization" = "Bearer: token"}`. To include an environment variable in a header value, prefix the variable with `$`, eg. `{"Authorization" = "$AUTH_TOKEN"}` diff --git a/tests/client_generators/package_generator/test_generator_generation.py b/tests/client_generators/package_generator/test_generator_generation.py index 5ea7d6c1..ca63d60b 100644 --- a/tests/client_generators/package_generator/test_generator_generation.py +++ b/tests/client_generators/package_generator/test_generator_generation.py @@ -42,7 +42,6 @@ def test_get_package_generator_without_default_settings(tmp_path: Path): settings_without_defaults = ClientSettings( schema_path=schema_path.as_posix(), - remote_schema_url="remote_schema_url", remote_schema_headers={"header": "header"}, remote_schema_verify_ssl=False, remote_schema_timeout=5, diff --git a/tests/test_schema.py b/tests/test_schema.py index fd130e0f..e3d8fcf4 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -12,9 +12,11 @@ from ariadne_codegen.schema import ( get_graphql_queries, get_graphql_schema_from_path, + get_graphql_schema_from_paths, introspect_remote_schema, load_graphql_files_from_path, read_graphql_file, + resolve_schema_paths, walk_graphql_files, ) from ariadne_codegen.settings import IntrospectionSettings, get_validation_rule @@ -652,3 +654,158 @@ def test_introspect_remote_schema_uses_default_introspection_settings_when_not_p input_value_deprecation=False, input_object_one_of=False, ) + + +def test_resolve_schema_paths_with_single_file(tmp_path, schema_str): + schema_file = tmp_path / "schema.graphql" + schema_file.write_text(schema_str, encoding="utf-8") + + result = resolve_schema_paths([schema_file.as_posix()]) + + assert result == [schema_file] + + +def test_resolve_schema_paths_with_directory(schemas_directory): + result = resolve_schema_paths([schemas_directory.as_posix()]) + + names = {p.name for p in result} + assert "schema.graphql" in names + assert "user.graphql" in names + + +def test_resolve_schema_paths_with_multiple_local_sources( + tmp_path, schema_str, extra_type_str +): + file1 = tmp_path / "schema.graphql" + file1.write_text(schema_str, encoding="utf-8") + file2 = tmp_path / "user.graphql" + file2.write_text(extra_type_str, encoding="utf-8") + + result = resolve_schema_paths([file1.as_posix(), file2.as_posix()]) + + assert file1 in result + assert file2 in result + assert len(result) == 2 + + +def test_resolve_schema_paths_with_callable_python_attribute( + tmp_path, schema_str, mocker +): + schema_file = tmp_path / "schema.graphql" + schema_file.write_text(schema_str, encoding="utf-8") + + mock_callable = mocker.Mock(return_value=[schema_file.as_posix()]) + mock_module = mocker.Mock() + mock_module.get_files = mock_callable + mocker.patch( + "ariadne_codegen.schema.importlib.import_module", return_value=mock_module + ) + + result = resolve_schema_paths(["some_pkg.get_files"]) + + assert result == [Path(schema_file.as_posix())] + mock_callable.assert_called_once() + + +def test_resolve_schema_paths_with_path_attribute_pointing_to_directory( + schemas_directory, mocker +): + mock_module = mocker.Mock() + mock_module.SCHEMA_DIR = schemas_directory.as_posix() + mocker.patch( + "ariadne_codegen.schema.importlib.import_module", return_value=mock_module + ) + + result = resolve_schema_paths(["some_pkg.SCHEMA_DIR"]) + + names = {p.name for p in result} + assert "schema.graphql" in names + assert "user.graphql" in names + + +def test_resolve_schema_paths_falls_back_to_local_path_on_import_error(mocker): + mocker.patch( + "ariadne_codegen.schema.importlib.import_module", + side_effect=ImportError("module not found"), + ) + + result = resolve_schema_paths(["nonexistent_pkg.attr"]) + + assert result == [Path("nonexistent_pkg.attr")] + + +def test_resolve_schema_paths_with_slash_skips_importlib(tmp_path, schema_str, mocker): + schema_file = tmp_path / "schema.graphql" + schema_file.write_text(schema_str, encoding="utf-8") + mock_import = mocker.patch("ariadne_codegen.schema.importlib.import_module") + + resolve_schema_paths([schema_file.as_posix()]) + + mock_import.assert_not_called() + + +def test_resolve_schema_paths_with_graphql_extension_skips_importlib( + tmp_path, schema_str, mocker +): + schema_file = tmp_path / "schema.graphql" + schema_file.write_text(schema_str, encoding="utf-8") + mock_import = mocker.patch("ariadne_codegen.schema.importlib.import_module") + + resolve_schema_paths(["schema.graphql"]) + + mock_import.assert_not_called() + + +def test_resolve_schema_paths_with_graphqls_extension_skips_importlib(mocker): + mock_import = mocker.patch("ariadne_codegen.schema.importlib.import_module") + + resolve_schema_paths(["types.graphqls"]) + + mock_import.assert_not_called() + + +def test_get_graphql_schema_from_paths_returns_graphql_schema( + tmp_path, schema_str, extra_type_str +): + file1 = tmp_path / "schema.graphql" + file1.write_text(schema_str, encoding="utf-8") + file2 = tmp_path / "user.graphql" + file2.write_text(extra_type_str, encoding="utf-8") + + result = get_graphql_schema_from_paths([file1.as_posix(), file2.as_posix()]) + + assert isinstance(result, GraphQLSchema) + assert "Custom" in result.type_map + assert "User" in result.type_map + + +def test_get_graphql_schema_from_paths_with_directory_source(schemas_directory): + result = get_graphql_schema_from_paths([schemas_directory.as_posix()]) + + assert isinstance(result, GraphQLSchema) + assert "Custom" in result.type_map + assert "User" in result.type_map + + +def test_get_graphql_schema_from_paths_merges_types_from_all_sources( + tmp_path, schema_str, extra_type_str +): + dir1 = tmp_path / "base" + dir1.mkdir() + (dir1 / "schema.graphql").write_text(schema_str, encoding="utf-8") + + dir2 = tmp_path / "extra" + dir2.mkdir() + (dir2 / "user.graphql").write_text(extra_type_str, encoding="utf-8") + + result = get_graphql_schema_from_paths([dir1.as_posix(), dir2.as_posix()]) + + assert "Custom" in result.type_map + assert "User" in result.type_map + + +def test_get_graphql_schema_from_paths_invalid_syntax_raises_invalid_graphql_syntax( + invalid_syntax_schema_file, +): + with pytest.raises(InvalidGraphqlSyntax): + get_graphql_schema_from_paths([invalid_syntax_schema_file.as_posix()]) diff --git a/tests/test_settings.py b/tests/test_settings.py index 5f36d347..4ea2aa32 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -522,32 +522,24 @@ class BaseClient: assert settings.using_remote_schema is False -def test_using_remote_schema_false_when_both_provided(tmp_path): +def test_client_settings_schema_path_and_remote_url_raises_invalid_configuration( + tmp_path, +): """ - Test that using_remote_schema is False when both schema_path and remote_schema_url - are provided. + Test that providing both schema_path and remote_schema_url is rejected: + only one schema source may be selected at a time. """ 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 + with pytest.raises(InvalidConfiguration): + ClientSettings( + schema_path=schema_path.as_posix(), + remote_schema_url="http://testserver/graphql/", + queries_path=queries_path.as_posix(), + ) def test_introspection_settings_defaults(tmp_path): @@ -660,9 +652,123 @@ def test_graphql_schema_settings_used_settings_message_includes_introspection( ) 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, + +def test_client_settings_with_schema_paths_is_valid(tmp_path): + queries_path = tmp_path / "queries.graphql" + queries_path.touch() + + settings = ClientSettings( + schema_paths=["pkg.get_files", "./schemas/"], + queries_path=queries_path.as_posix(), ) - assert "Introspection settings:" not in both_settings.used_settings_message + + assert settings.schema_paths == ["pkg.get_files", "./schemas/"] + + +def test_graphql_schema_settings_with_schema_paths_is_valid(): + settings = GraphQLSchemaSettings(schema_paths=["pkg.SCHEMA_DIR"]) + + assert settings.schema_paths == ["pkg.SCHEMA_DIR"] + + +def test_client_settings_with_schema_path_and_schema_paths_raises_invalid_configuration( + tmp_path, +): + schema_path = tmp_path / "schema.graphql" + schema_path.touch() + queries_path = tmp_path / "queries.graphql" + queries_path.touch() + + with pytest.raises(InvalidConfiguration): + ClientSettings( + schema_path=schema_path.as_posix(), + schema_paths=["pkg.get_files"], + queries_path=queries_path.as_posix(), + ) + + +def test_graphql_schema_settings_schema_path_and_paths_raises_invalid_configuration( + tmp_path, +): + schema_path = tmp_path / "schema.graphql" + schema_path.touch() + + with pytest.raises(InvalidConfiguration): + GraphQLSchemaSettings( + schema_path=schema_path.as_posix(), + schema_paths=["pkg.get_files"], + ) + + +def test_client_settings_schema_paths_and_remote_url_raises_invalid_configuration( + tmp_path, +): + queries_path = tmp_path / "queries.graphql" + queries_path.touch() + + with pytest.raises(InvalidConfiguration): + ClientSettings( + schema_paths=["pkg.get_files"], + remote_schema_url="http://testserver/graphql/", + queries_path=queries_path.as_posix(), + ) + + +def test_client_settings_without_any_schema_source_raises_invalid_configuration( + tmp_path, +): + queries_path = tmp_path / "queries.graphql" + queries_path.touch() + + with pytest.raises(InvalidConfiguration): + ClientSettings(queries_path=queries_path.as_posix()) + + +def test_graphql_schema_settings_without_schema_source_raises_invalid_configuration(): + with pytest.raises(InvalidConfiguration): + GraphQLSchemaSettings() + + +def test_using_remote_schema_false_when_schema_paths_provided(tmp_path): + queries_path = tmp_path / "queries.graphql" + queries_path.touch() + + settings = ClientSettings( + schema_paths=["pkg.get_files"], + queries_path=queries_path.as_posix(), + ) + + assert settings.using_remote_schema is False + + +def test_client_settings_schema_source_returns_schema_paths_joined(tmp_path): + queries_path = tmp_path / "queries.graphql" + queries_path.touch() + + settings = ClientSettings( + schema_paths=["pkg.get_files", "./extra/"], + queries_path=queries_path.as_posix(), + ) + + assert settings.schema_source == "pkg.get_files, ./extra/" + + +def test_client_settings_used_settings_message_includes_schema_paths(tmp_path): + queries_path = tmp_path / "queries.graphql" + queries_path.touch() + + settings = ClientSettings( + schema_paths=["pkg.get_files", "./schemas/"], + queries_path=queries_path.as_posix(), + ) + + result = settings.used_settings_message + assert "pkg.get_files" in result + assert "./schemas/" in result + + +def test_graphql_schema_settings_used_settings_message_includes_schema_paths(): + settings = GraphQLSchemaSettings(schema_paths=["pkg.SCHEMA_DIR"]) + + result = settings.used_settings_message + assert "pkg.SCHEMA_DIR" in result