Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor parameters, named queries, exceptions and more #19

Merged
merged 8 commits into from
Jul 23, 2022
Merged
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
2 changes: 1 addition & 1 deletion examples/arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ class Query(GQLQuery):


# Generate the GraphQL query string and instantiate variables
query_str = Query.get_query_string(named=False)
query_str = Query.get_query_string(include_name=False)
print(query_str)


Expand Down
6 changes: 3 additions & 3 deletions examples/variables.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
import requests
from pydantic import Field

from pygraphic import GQLParameters, GQLQuery, GQLType
from pygraphic import GQLQuery, GQLType, GQLVariables


# Define the query variables
class Variables(GQLParameters):
class Variables(GQLVariables):
repo_owner: str
repo_name: str
pull_requests_count: int
Expand All @@ -28,7 +28,7 @@ class Repository(GQLType):


# Define query model and attach variables to it
class PygraphicPullRequests(GQLQuery, parameters=Variables):
class PygraphicPullRequests(GQLQuery, variables=Variables):
repository: Repository = Field(owner=Variables.repo_owner, name=Variables.repo_name)


Expand Down
6 changes: 3 additions & 3 deletions pygraphic/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from . import defaults, types
from ._gql_parameters import GQLParameters
from . import defaults, exceptions, types
from ._gql_query import GQLQuery
from ._gql_type import GQLType
from ._gql_variables import GQLVariables


__all__ = ["defaults", "GQLParameters", "GQLType", "GQLQuery", "types"]
__all__ = ["defaults", "exceptions", "GQLVariables", "GQLType", "GQLQuery", "types"]
68 changes: 32 additions & 36 deletions pygraphic/_gql_query.py
Original file line number Diff line number Diff line change
@@ -1,51 +1,47 @@
from __future__ import annotations

from typing import Iterator, Optional
from typing import Optional

import pydantic

from ._gql_parameters import GQLParameters
from ._gql_type import GQLType
from ._utils import first_only
from ._gql_variables import GQLVariables
from .exceptions import QueryGenerationError
from .types import class_to_graphql_type


class GQLQuery(GQLType):
@classmethod
def get_query_string(cls, named: bool = True) -> str:
parameters: Optional[
type[GQLParameters]
] = cls.__config__.parameters # type: ignore

if not named and parameters is not None:
# TODO Find a better exception type
raise Exception("Query with parameters must have a name")

def _gen():
if named:
params = "".join(_gen_parameter_string(parameters))
yield "query " + cls.__name__ + params + " {"
def get_query_string(cls, include_name: bool = True) -> str:
variables: Optional[
type[GQLVariables]
] = cls.__config__.variables # type: ignore

if variables and not include_name:
raise QueryGenerationError("Query with variables must include a name")

def _generate():
if include_name:
variables_str = _get_variables_string(variables)
yield "query " + cls.__name__ + variables_str + " {"
else:
yield "query {"
for line in cls.generate_query_lines(nest_level=1):
yield line
for line in cls.generate_query_lines():
yield " " + line
yield "}"

return "\n".join(_gen())
return "\n".join(_generate())

class Config(pydantic.BaseConfig):
parameters: Optional[type[GQLParameters]] = None


def _gen_parameter_string(parameters: Optional[type[GQLParameters]]) -> Iterator[str]:
if parameters is None or not parameters.__fields__:
return
yield "("
for field, is_first in zip(parameters.__fields__.values(), first_only()):
if not is_first:
yield ", "
yield "$"
yield field.alias
yield ": "
yield class_to_graphql_type(field.type_, allow_none=field.allow_none)
yield ")"
variables: Optional[type[GQLVariables]] = None


def _get_variables_string(variables: Optional[type[GQLVariables]]) -> str:
if variables is None or not variables.__fields__:
return ""

def _generate():
for field in variables.__fields__.values():
yield "$" + field.alias + ": " + class_to_graphql_type(
field.type_, allow_none=field.allow_none
)

return "(" + ", ".join(_generate()) + ")"
72 changes: 38 additions & 34 deletions pygraphic/_gql_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,67 +8,71 @@
import pydantic
from pydantic.fields import ModelField

from ._utils import first_only
from .defaults import default_alias_generator
from .exceptions import QueryGenerationError


class GQLType(pydantic.BaseModel):
@classmethod
def generate_query_lines(cls, nest_level: int = 0) -> Iterator[str]:
def generate_query_lines(cls) -> Iterator[str]:
fields = typing.get_type_hints(cls)
for field_name, field_type in fields.items():
field = cls.__fields__[field_name]
params = "".join(_gen_parameter_string(field.field_info.extra))
arguments_str = _get_arguments_string(field.field_info.extra)
if typing.get_origin(field_type) is list:
args = typing.get_args(field_type)
assert len(args) == 1
if len(args) != 1:
raise QueryGenerationError(
f"Type '{field_type}' has unexpected amount of arguments"
)
field_type = args[0]
if typing.get_origin(field_type) is UnionType:
sub_types = typing.get_args(field_type)
yield " " * nest_level + field.alias + params + " {"
yield field.alias + arguments_str + " {"
for sub_type in sub_types:
if sub_type is object:
continue
assert issubclass(sub_type, GQLType)
yield " " * (nest_level + 1) + "... on " + sub_type.__name__ + " {"
for line in sub_type.generate_query_lines(
nest_level=nest_level + 2
):
yield line
yield " " * (nest_level + 1) + "}"
yield " " * nest_level + "}"
if not issubclass(sub_type, GQLType):
raise QueryGenerationError(
f"Member '{sub_type}' of a union type"
f"must be a subtype of '{GQLType.__name__}'"
)
yield " " + "... on " + sub_type.__name__ + " {"
for line in sub_type.generate_query_lines():
yield " " * 2 + line
yield " " + "}"
yield "}"
continue
if not inspect.isclass(field_type):
raise Exception(f"Type {field_type} not supported")
raise QueryGenerationError(f"Type {field_type} is not supported")
if issubclass(field_type, GQLType):
field_type.update_forward_refs()
yield " " * nest_level + field.alias + params + " {"
for line in field_type.generate_query_lines(nest_level=nest_level + 1):
yield line
yield " " * nest_level + "}"
yield field.alias + arguments_str + " {"
for line in field_type.generate_query_lines():
yield " " + line
yield "}"
continue
yield " " * nest_level + field.alias + params
yield field.alias + arguments_str
continue

class Config:
alias_generator = default_alias_generator
allow_population_by_field_name = True


def _gen_parameter_string(parameters: dict[str, Any]) -> Iterator[str]:
if not parameters:
return
yield "("
for (name, value), is_first in zip(parameters.items(), first_only()):
if not is_first:
yield ", "
yield default_alias_generator(name)
yield ": "
def _get_arguments_string(arguments: dict[str, Any]) -> str:
if not arguments:
return ""

def _serialize_value(value: Any) -> str:
if type(value) is ModelField:
yield "$" + value.alias
continue
return "$" + value.alias
if isinstance(value, Enum):
yield value.name
continue
yield json.dumps(value, indent=None, default=str)
yield ")"
return value.name
return json.dumps(value, indent=None, default=str)

def _generate():
for name, value in arguments.items():
yield default_alias_generator(name) + ": " + _serialize_value(value)

return "(" + ", ".join(_generate()) + ")"
6 changes: 2 additions & 4 deletions pygraphic/_gql_parameters.py → pygraphic/_gql_variables.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
from __future__ import annotations

from typing import Any

import pydantic
Expand All @@ -14,15 +12,15 @@
class ModelMetaclass(pydantic.main.ModelMetaclass):
def __getattr__(cls, __name: str) -> Any:
try:
mcs: type[GQLParameters] = cls # type: ignore
mcs: type[GQLVariables] = cls # type: ignore
return mcs.__fields__[__name]
except KeyError:
raise AttributeError(
f"type object '{cls.__name__}' has no attribute '{__name}'"
)


class GQLParameters(pydantic.BaseModel, metaclass=ModelMetaclass):
class GQLVariables(pydantic.BaseModel, metaclass=ModelMetaclass):
def json(self, **kwargs: Any) -> str:
return super().json(by_alias=True, **kwargs)

Expand Down
7 changes: 0 additions & 7 deletions pygraphic/_utils.py

This file was deleted.

2 changes: 2 additions & 0 deletions pygraphic/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class QueryGenerationError(Exception):
pass
4 changes: 2 additions & 2 deletions pygraphic/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def class_to_graphql_type(python_class: type, allow_none: bool) -> str:
else:
return type_ + "!"
except KeyError:
raise KeyError(
f"Type '{python_class.__name__}' could not be converted to a GraphQL type."
raise TypeError(
f"Type '{python_class}' could not be converted to a GraphQL type."
"See pygraphic.types.register_graphql_type"
)
2 changes: 1 addition & 1 deletion tests/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@


def test_class_to_typename_crashes_on_unknown_class():
with pytest.raises(KeyError) as e_info:
with pytest.raises(TypeError) as e_info:
class_to_graphql_type(object, allow_none=True)


Expand Down