Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,20 @@ A string with a name for type map variable, must be valid python identifier.

Defaults to `"type_map"`. Used only if target is a Python file.

## Generating models only

If you only need Pydantic models for a schema (enums, input types, per-query result types and fragments) without a generated `Client`, base client, or `exceptions.py`, call `ariadne-codegen` with the `models_only` argument:

```
ariadne-codegen models_only
```

`models_only` mode reads configuration from the same `[tool.ariadne-codegen]` section as [`client`](#configuration). Any schema source that `client` accepts is supported: `schema_path`, `schema_paths`, `remote_schema_url` (with `remote_schema_headers`, `remote_schema_verify_ssl`, `remote_schema_timeout`, `remote_schema_http_client_path`) and `plugins`.

`queries_path` is optional in this mode; when omitted, only enums, input types and (if any) fragments are generated. When provided, an additional result-type module is emitted for each operation.

Client-specific settings (`client_name`, `client_file_name`, `base_client_name`, `base_client_file_path`, `async_client`, `opentelemetry_client`, `enable_custom_operations`, ...) are ignored — no client, base client, or `exceptions.py` file is generated.

---

## Versioning policy ##
Expand Down
110 changes: 70 additions & 40 deletions ariadne_codegen/client_generators/package.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ def __init__(
default_optional_fields_to_none: bool = False,
include_typename: bool = True,
ignore_extra_fields: bool = True,
models_only: bool = False,
) -> None:
self.package_path = Path(target_path) / package_name

Expand Down Expand Up @@ -161,21 +162,23 @@ def __init__(
self._unpacked_fragments: set[str] = set()
self._used_enums: list[str] = []

self.models_only = models_only
self.enable_custom_operations = enable_custom_operations
if self.enable_custom_operations:
self.files_to_include.append(self.base_schema_root_file_path)

def generate(self) -> list[str]:
"""Generate package with graphql client."""
self._include_exceptions()
if not self.models_only:
self._include_exceptions()
self._validate_unique_file_names()
if not self.package_path.exists():
self.package_path.mkdir()
self._generate_input_types()
self._generate_result_types()
self._generate_fragments()
self._copy_files()
if self.enable_custom_operations:
if not self.models_only and self.enable_custom_operations:
self._generate_custom_fields_typing()
self._generate_custom_fields()
self.client_generator.add_execute_custom_operation_method(self.async_client)
Expand All @@ -190,7 +193,8 @@ def generate(self) -> list[str]:
"mutation", OperationType.MUTATION.value.upper(), self.async_client
)

self._generate_client()
if not self.models_only:
self._generate_client()
self._generate_enums()
self._generate_init()

Expand Down Expand Up @@ -234,14 +238,15 @@ def add_operation(self, definition: OperationDefinitionNode):
query_types_generator.get_generated_public_names(), module_name, 1
)

self.client_generator.add_method(
definition=definition,
name=method_name,
return_type=return_type_name,
return_type_module=module_name,
operation_str=operation_str,
async_=self.async_client,
)
if not self.models_only:
self.client_generator.add_method(
definition=definition,
name=method_name,
return_type=return_type_name,
return_type_module=module_name,
operation_str=operation_str,
async_=self.async_client,
)

def _include_exceptions(self):
if self.base_client_file_path in (
Expand All @@ -262,18 +267,19 @@ def _include_exceptions(self):
)

def _validate_unique_file_names(self):
file_names = (
[
file_names = [
"base_model.py",
f"{self.enums_module_name}.py",
f"{self.input_types_module_name}.py",
f"{self.fragments_module_name}.py",
]
if not self.models_only:
file_names += [
f"{self.client_file_name}.py",
f"{self.base_client_module_name}.py",
"base_model.py",
f"{self.enums_module_name}.py",
f"{self.input_types_module_name}.py",
f"{self.fragments_module_name}.py",
]
+ list(self._result_types_files.keys())
+ [f.name for f in self.files_to_include]
)
file_names += list(self._result_types_files.keys())
file_names += [f.name for f in self.files_to_include]

if len(file_names) != len(set(file_names)):
seen = set()
Expand Down Expand Up @@ -325,7 +331,7 @@ def _generate_enums(self):
)

def _generate_input_types(self):
if self.include_all_inputs:
if self.include_all_inputs or self.models_only:
module = self.input_types_generator.generate()
else:
used_inputs = self.client_generator.arguments_generator.get_used_inputs()
Expand Down Expand Up @@ -375,10 +381,13 @@ def _generate_fragments(self):

def _copy_files(self):
files_to_copy = {
**{source_path: source_path.name for source_path in self.files_to_include},
self.base_client_file_path: f"{self.base_client_module_name}.py",
self.base_model_file_path: "base_model.py",
source_path: source_path.name for source_path in self.files_to_include
}
files_to_copy[self.base_model_file_path] = "base_model.py"
if not self.models_only:
files_to_copy[self.base_client_file_path] = (
f"{self.base_client_module_name}.py"
)
for source_path, target_name in files_to_copy.items():
code = self._add_comments_to_code(source_path.read_text(encoding="utf-8"))
is_base_model = source_path == self.base_model_file_path
Expand All @@ -390,11 +399,12 @@ def _copy_files(self):
target_path.write_text(code)
self._generated_files.append(target_path.name)

self.init_generator.add_import(
names=[self.base_client_name],
from_=self.base_client_module_name,
level=1,
)
if not self.models_only:
self.init_generator.add_import(
names=[self.base_client_name],
from_=self.base_client_module_name,
level=1,
)
base_model_names = [BASE_MODEL_CLASS_NAME]
if self.multipart_uploads:
base_model_names.append(UPLOAD_CLASS_NAME)
Expand Down Expand Up @@ -451,6 +461,7 @@ def get_package_generator(
fragments: list[FragmentDefinitionNode],
settings: ClientSettings,
plugin_manager: PluginManager,
models_only: bool = False,
) -> PackageGenerator:
upload_import = UPLOAD_IMPORT if settings.multipart_uploads else None
base_model_path = (
Expand All @@ -460,23 +471,41 @@ def get_package_generator(
)

init_generator = InitFileGenerator(plugin_manager=plugin_manager)
client_generator = ClientGenerator(
base_client_import=generate_import_from(
if models_only:
base_client_import = generate_import_from(
names=["object"], from_="builtins", level=0
)
client_name = "Client"
base_client = "object"
async_client = True
base_client_file_path = ""
base_client_module_name = ""
client_file_name = "client"
Comment on lines +475 to +483

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You create a fake objects here just to satisfy the requirement to provide client_generator to PackageGenerator.

However, the strategy you implemented does not need to know anything about client at all - it's the mode without it.

This part of the code and multiple checks if not self.models_only in the generator methods suggest there could be a solution easier to maintain. There should be separate PackageGenerator subclasses: for the client mode and models only mode. Key concepts:

  • most of the logic can be shared in the base class
  • generate method, as the most important function that describes the steps to perform, should be implemented in the subclasses
  • add_operation could live in the base class, the "client mode" class can call super().add_operation and then call the additional self.client_generator.add_method()
  • _validate_unique_file_names can live in the base class, but the subclasses should implement a method to return expected file names
  • similarly, _copy_files can live in the base class, but the subclasses can implement a method to return files to copy
  • finally, the __init__ can share most of the logic, but the "client mode" class can expect additional arguments like client_generator and other client-related settings, unused for "models only" mode.

else:
base_client_import = generate_import_from(
names=[settings.base_client_name],
from_=(
settings.base_client_module_name
or Path(settings.base_client_file_path).stem
),
level=1,
),
)
client_name = settings.client_name
base_client = settings.base_client_name
async_client = settings.async_client
base_client_file_path = settings.base_client_file_path
base_client_module_name = settings.base_client_module_name
client_file_name = settings.client_file_name
client_generator = ClientGenerator(
base_client_import=base_client_import,
arguments_generator=ArgumentsGenerator(
schema=schema,
convert_to_snake_case=settings.convert_to_snake_case,
custom_scalars=settings.scalars,
plugin_manager=plugin_manager,
),
name=settings.client_name,
base_client=settings.base_client_name,
name=client_name,
base_client=base_client,
enums_module_name=settings.enums_module_name,
input_types_module_name=settings.input_types_module_name,
unset_import=UNSET_IMPORT,
Expand Down Expand Up @@ -558,11 +587,11 @@ def get_package_generator(
input_types_generator=input_types_generator,
fragments_generator=fragments_generator,
fragments_definitions=fragments_definitions,
client_name=settings.client_name,
async_client=settings.async_client,
base_client_name=settings.base_client_name,
base_client_file_path=settings.base_client_file_path,
client_file_name=settings.client_file_name,
client_name=client_name,
async_client=async_client,
base_client_name=base_client,
base_client_file_path=base_client_file_path,
client_file_name=client_file_name,
enums_module_name=settings.enums_module_name,
input_types_module_name=settings.input_types_module_name,
fragments_module_name=settings.fragments_module_name,
Expand All @@ -576,7 +605,7 @@ def get_package_generator(
convert_to_snake_case=settings.convert_to_snake_case,
include_all_inputs=settings.include_all_inputs,
include_all_enums=settings.include_all_enums,
base_client_module_name=settings.base_client_module_name,
base_client_module_name=base_client_module_name,
base_model_file_path=base_model_path.as_posix(),
base_model_import=BASE_MODEL_IMPORT,
upload_import=upload_import,
Expand All @@ -589,4 +618,5 @@ def get_package_generator(
default_optional_fields_to_none=settings.default_optional_fields_to_none,
include_typename=settings.include_typename,
ignore_extra_fields=settings.ignore_extra_fields,
models_only=models_only,
)
58 changes: 57 additions & 1 deletion ariadne_codegen/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@

from .client_generators.scalars import ScalarData
from .exceptions import ConfigFileNotFound, MissingConfiguration
from .settings import ClientSettings, CommentsStrategy, GraphQLSchemaSettings
from .settings import (
ClientSettings,
CommentsStrategy,
GraphQLSchemaSettings,
ModelsOnlySettings,
)

simplefilter("default", DeprecationWarning)

Expand Down Expand Up @@ -103,6 +108,57 @@ def get_section(config_dict: dict) -> dict:
raise MissingConfiguration(f"Config has no [{tool_key}.{codegen_key}] section.")


def get_models_only_settings(config_dict: dict) -> ModelsOnlySettings:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please correct me if I'm wrong, but this function is a copy of get_client_settings with the following changes:

  • return typing
  • settings_fields_names is set based on ModelsOnlySettings
  • returned object is constructed as ModelsOnlySettings instance

I believe there could be a single function which takes the model as parameter and is used for both the client settings and ModelsOnlySettings. Then, there could be two facade functions get_client_settings and get_models_only_settings that returns foo(config_dict, ModelsOnlySettings) or foo(config_dict, ClientSettings).

"""Parse configuration dict and return ModelsOnlySettings instance."""
section = get_section(config_dict).copy()
settings_fields_names = {f.name for f in fields(ModelsOnlySettings)}
try:
section["scalars"] = {
name: ScalarData(
type_=data["type"],
serialize=data.get("serialize"),
parse=data.get("parse"),
import_=data.get("import"),
)
for name, data in section.get("scalars", {}).items()
}
except KeyError as exc:
raise MissingConfiguration(
"Missing 'type' field for scalar definition"
) from exc

try:
if "include_comments" in section and isinstance(
section["include_comments"], bool
):
section["include_comments"] = (
CommentsStrategy.TIMESTAMP.value
if section["include_comments"]
else CommentsStrategy.NONE.value
)
options = ", ".join(strategy.value for strategy in CommentsStrategy)
warn(
"Support for boolean 'include_comments' value has been deprecated "
"and will be dropped in future release. "
f"Instead use one of following options: {options}",
DeprecationWarning,
stacklevel=2,
)

return ModelsOnlySettings(
**{
key: value
for key, value in section.items()
if key in settings_fields_names
}
)
except TypeError as exc:
missing_fields = settings_fields_names.difference(section)
raise MissingConfiguration(
f"Missing configuration fields: {', '.join(missing_fields)}"
) from exc


def get_graphql_schema_settings(config_dict: dict) -> GraphQLSchemaSettings:
"""Parse configuration dict and return GraphQLSchemaSettings instance."""
section = get_section(config_dict)
Expand Down
47 changes: 46 additions & 1 deletion ariadne_codegen/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@
from graphql import assert_valid_schema

from .client_generators.package import get_package_generator
from .config import get_client_settings, get_config_dict, get_graphql_schema_settings
from .config import (
get_client_settings,
get_config_dict,
get_graphql_schema_settings,
get_models_only_settings,
)
from .graphql_schema_generators.schema import (
generate_graphql_schema_graphql_file,
generate_graphql_schema_python_file,
Expand Down Expand Up @@ -38,6 +43,9 @@ def main(strategy=Strategy.CLIENT.value, config=None):
if strategy == Strategy.GRAPHQL_SCHEMA:
graphql_schema(config_dict)

if strategy == Strategy.MODELS_ONLY:
models_only(config_dict)


def client(config_dict):
settings = get_client_settings(config_dict)
Expand Down Expand Up @@ -79,6 +87,43 @@ def client(config_dict):
sys.stdout.write("\nGenerated files:\n " + "\n ".join(generated_files) + "\n")


def models_only(config_dict):
settings = get_models_only_settings(config_dict)

schema = get_graphql_schema(settings)

plugin_manager = PluginManager(
schema=schema,
config_dict=config_dict,
plugins_types=get_plugins_types(settings.plugins),
)
schema = add_mixin_directive_to_schema(schema)
schema = plugin_manager.process_schema(schema)
assert_valid_schema(schema)

fragments = []
queries = []
if settings.queries_path:
definitions = get_graphql_queries(settings.queries_path, schema)
queries = filter_operations_definitions(definitions)
fragments = filter_fragments_definitions(definitions)

sys.stdout.write(settings.used_settings_message)

package_generator = get_package_generator(
schema=schema,
fragments=fragments,
settings=settings,
plugin_manager=plugin_manager,
models_only=True,
)
for query in queries:
package_generator.add_operation(query)
generated_files = package_generator.generate()

sys.stdout.write("\nGenerated files:\n " + "\n ".join(generated_files) + "\n")


def graphql_schema(config_dict):
settings = get_graphql_schema_settings(config_dict)

Expand Down
Loading