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

GraphQL-based quilt3.admin API #3990

Merged
merged 52 commits into from
Jun 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
6efd241
update schema
sir-sigurd Jun 4, 2024
643df07
WIP
sir-sigurd May 30, 2024
43637a2
add wrapped client
sir-sigurd May 31, 2024
6b83de2
add get_user()
sir-sigurd May 31, 2024
2a9fc64
more methods, requests client, auth
sir-sigurd Jun 4, 2024
10eda87
move to quilt3
sir-sigurd Jun 4, 2024
d384e13
require pydantic
sir-sigurd Jun 4, 2024
e8917b7
update signature of create_user
sir-sigurd Jun 5, 2024
5199582
use datetime
sir-sigurd Jun 5, 2024
88872ff
DRY queries a bit
sir-sigurd Jun 5, 2024
edd2c19
more changes:
sir-sigurd Jun 7, 2024
6bb55f1
remove old admin.py
sir-sigurd Jun 7, 2024
0a4a749
format docstring
sir-sigurd Jun 7, 2024
8e3cdc2
add own types for docstrings
sir-sigurd Jun 7, 2024
5088184
use None instead of UNSET for better docs
sir-sigurd Jun 7, 2024
beda1c6
remove outdated file
sir-sigurd Jun 7, 2024
f6703bd
try to satisfy isort
sir-sigurd Jun 7, 2024
6899d78
isort
sir-sigurd Jun 7, 2024
4c92ab9
try to disable linter for generated code
sir-sigurd Jun 7, 2024
3ff638b
try again
sir-sigurd Jun 7, 2024
0bf469c
remove return None
sir-sigurd Jun 7, 2024
7483e56
try ignore again
sir-sigurd Jun 7, 2024
819258a
pylint
sir-sigurd Jun 7, 2024
af372a5
pycodestyle
sir-sigurd Jun 7, 2024
16e01e5
set pydantic version
sir-sigurd Jun 7, 2024
f453ee8
add requirements
sir-sigurd Jun 11, 2024
dd402b0
check generated code is up-to-date
sir-sigurd Jun 11, 2024
32ec5fc
fix
sir-sigurd Jun 11, 2024
85c3070
fix
sir-sigurd Jun 11, 2024
9a70bac
regen
sir-sigurd Jun 11, 2024
7e4fe7c
better exceptions?
sir-sigurd Jun 11, 2024
a0b392f
remove comment
sir-sigurd Jun 11, 2024
96819cd
remove unused imports
sir-sigurd Jun 11, 2024
eecce62
add some docs
sir-sigurd Jun 11, 2024
71aacc2
add .gitattributes
sir-sigurd Jun 11, 2024
ed8e40d
regen
sir-sigurd Jun 12, 2024
02be9f8
add comment in exceptions.py
sir-sigurd Jun 13, 2024
71be296
fix indent
sir-sigurd Jun 13, 2024
be2f5f8
make get_user() return None if user not found
sir-sigurd Jun 13, 2024
0e37375
trigger CI on schema change
sir-sigurd Jun 13, 2024
134c937
Apply suggestions from code review
sir-sigurd Jun 13, 2024
96d9746
switch to dataclasses
sir-sigurd Jun 14, 2024
d8d1048
rework imports
sir-sigurd Jun 14, 2024
c97f620
satisfy type checker
sir-sigurd Jun 14, 2024
574de57
black
sir-sigurd Jun 14, 2024
4107668
fix type annotation
sir-sigurd Jun 14, 2024
11a4fe0
return user from mutations
sir-sigurd Jun 14, 2024
174a8bf
more user api
sir-sigurd Jun 14, 2024
faeebc8
tests
sir-sigurd Jun 14, 2024
97b57fb
add changelog
sir-sigurd Jun 17, 2024
a03aa71
split API into namespaces
sir-sigurd Jun 17, 2024
e38e644
ajdust changelog
sir-sigurd Jun 18, 2024
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
36 changes: 36 additions & 0 deletions .github/workflows/test-quilt3-admin-codegen.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Test quilt3.admin code generation

on:
push:
paths:
- '.github/workflows/test-quilt3-admin-codegen.yaml'
- 'shared/graphql/schema.graphql'
- 'api/python/quilt3-admin/**'
- 'api/python/quilt3/admin/_graphql_client/**'
sir-sigurd marked this conversation as resolved.
Show resolved Hide resolved
pull_request:
paths:
- '.github/workflows/test-quilt3-admin-codegen.yaml'
- 'shared/graphql/schema.graphql'
- 'api/python/quilt3-admin/**'
- 'api/python/quilt3/admin/_graphql_client/**'
merge_group:

jobs:
test-quilt3-admin-codegen:
name: test quilt3.admin generated code is up-to-date
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./api/python/quilt3-admin
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version-file: 'api/python/quilt3-admin/.python-version'
cache: 'pip'
cache-dependency-path: 'api/python/quilt3-admin/requirements.txt'
- run: pip install -r requirements.txt
- run: rm -r ../quilt3/admin/_graphql_client
- run: ariadne-codegen
- name: Check for changes
run: git diff --exit-code
1 change: 1 addition & 0 deletions api/python/.gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
quilt3/admin/_graphql_client/** linguist-generated
1 change: 1 addition & 0 deletions api/python/quilt3-admin/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.12
10 changes: 10 additions & 0 deletions api/python/quilt3-admin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# quilt3.admin GraphQL code generation

```sh
python -m venv venv
python -m pip install -r requirements.txt
ariadne-codegen
```

This will generate GraphQL client in `api/python/quilt3/admin/_graphql_client/` using
GraphQL queries from `queries.graphql`.
211 changes: 211 additions & 0 deletions api/python/quilt3-admin/base_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
# This is
# https://github.com/mirumee/ariadne-codegen/blob/5bfd63c5e7e3a8cc5293eb94deee638b7adab98d/ariadne_codegen/client_generators/dependencies/base_client.py
# modified to use our requests session instead of httpx.
# pylint: disable=relative-beyond-top-level
import json
from typing import IO, Any, Dict, List, Optional, Tuple, TypeVar, cast

import requests
from pydantic import BaseModel
from pydantic_core import to_jsonable_python

from quilt3 import session

from .base_model import UNSET, Upload
from .exceptions import (
GraphQLClientGraphQLMultiError,
GraphQLClientHttpError,
GraphQLClientInvalidResponseError,
)

Self = TypeVar("Self", bound="BaseClient")


class BaseClient:
nl0 marked this conversation as resolved.
Show resolved Hide resolved
def __init__(
self,
) -> None:
self.url = session.get_registry_url() + "/graphql"

self.http_client = session.get_session()

def __enter__(self: Self) -> Self:
return self

def __exit__(
self,
exc_type: object,
exc_val: object,
exc_tb: object,
) -> None:
self.http_client.close()

def execute(
self,
query: str,
operation_name: Optional[str] = None,
variables: Optional[Dict[str, Any]] = None,
**kwargs: Any,
) -> requests.Response:
processed_variables, files, files_map = self._process_variables(variables)

if files and files_map:
return self._execute_multipart(
query=query,
operation_name=operation_name,
variables=processed_variables,
files=files,
files_map=files_map,
**kwargs,
)

return self._execute_json(
query=query,
operation_name=operation_name,
variables=processed_variables,
**kwargs,
)

def get_data(self, response: requests.Response) -> Dict[str, Any]:
if not 200 <= response.status_code < 300:
raise GraphQLClientHttpError(
status_code=response.status_code, response=response
)

try:
response_json = response.json()
except ValueError as exc:
raise GraphQLClientInvalidResponseError(response=response) from exc

if (not isinstance(response_json, dict)) or (
"data" not in response_json and "errors" not in response_json
):
raise GraphQLClientInvalidResponseError(response=response)

data = response_json.get("data")
errors = response_json.get("errors")

if errors:
raise GraphQLClientGraphQLMultiError.from_errors_dicts(
errors_dicts=errors, data=data
)

return cast(Dict[str, Any], data)

def _process_variables(
self, variables: Optional[Dict[str, Any]]
) -> Tuple[
Dict[str, Any], Dict[str, Tuple[str, IO[bytes], str]], Dict[str, List[str]]
]:
if not variables:
return {}, {}, {}

serializable_variables = self._convert_dict_to_json_serializable(variables)
return self._get_files_from_variables(serializable_variables)

def _convert_dict_to_json_serializable(
self, dict_: Dict[str, Any]
) -> Dict[str, Any]:
return {
key: self._convert_value(value)
for key, value in dict_.items()
if value is not UNSET
}

def _convert_value(self, value: Any) -> Any:
if isinstance(value, BaseModel):
return value.model_dump(by_alias=True, exclude_unset=True)
if isinstance(value, list):
return [self._convert_value(item) for item in value]
return value

def _get_files_from_variables(
self, variables: Dict[str, Any]
) -> Tuple[
Dict[str, Any], Dict[str, Tuple[str, IO[bytes], str]], Dict[str, List[str]]
]:
files_map: Dict[str, List[str]] = {}
files_list: List[Upload] = []

def separate_files(path: str, obj: Any) -> Any:
if isinstance(obj, list):
nulled_list = []
for index, value in enumerate(obj):
value = separate_files(f"{path}.{index}", value)
nulled_list.append(value)
return nulled_list

if isinstance(obj, dict):
nulled_dict = {}
for key, value in obj.items():
value = separate_files(f"{path}.{key}", value)
nulled_dict[key] = value
return nulled_dict

if isinstance(obj, Upload):
if obj in files_list:
file_index = files_list.index(obj)
files_map[str(file_index)].append(path)
else:
file_index = len(files_list)
files_list.append(obj)
files_map[str(file_index)] = [path]
return None

return obj

nulled_variables = separate_files("variables", variables)
files: Dict[str, Tuple[str, IO[bytes], str]] = {
str(i): (file_.filename, cast(IO[bytes], file_.content), file_.content_type)
for i, file_ in enumerate(files_list)
}
return nulled_variables, files, files_map

def _execute_multipart(
self,
query: str,
operation_name: Optional[str],
variables: Dict[str, Any],
files: Dict[str, Tuple[str, IO[bytes], str]],
files_map: Dict[str, List[str]],
**kwargs: Any,
) -> requests.Response:
data = {
"operations": json.dumps(
{
"query": query,
"operationName": operation_name,
"variables": variables,
},
default=to_jsonable_python,
),
"map": json.dumps(files_map, default=to_jsonable_python),
}

return self.http_client.post(url=self.url, data=data, files=files, **kwargs)

def _execute_json(
self,
query: str,
operation_name: Optional[str],
variables: Dict[str, Any],
**kwargs: Any,
) -> requests.Response:
headers: Dict[str, str] = {"Content-Type": "application/json"}
headers.update(kwargs.get("headers", {}))

merged_kwargs: Dict[str, Any] = kwargs.copy()
merged_kwargs["headers"] = headers

return self.http_client.post(
url=self.url,
data=json.dumps(
{
"query": query,
"operationName": operation_name,
"variables": variables,
},
default=to_jsonable_python,
),
**merged_kwargs,
)
87 changes: 87 additions & 0 deletions api/python/quilt3-admin/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# This is
# https://github.com/mirumee/ariadne-codegen/blob/5bfd63c5e7e3a8cc5293eb94deee638b7adab98d/ariadne_codegen/client_generators/dependencies/exceptions.py
# modified to use our requests instead of httpx.
# pylint: disable=super-init-not-called
from typing import Any, Dict, List, Optional, Union

import requests


class GraphQLClientError(Exception):
sir-sigurd marked this conversation as resolved.
Show resolved Hide resolved
"""Base exception."""


class GraphQLClientHttpError(GraphQLClientError):
def __init__(self, status_code: int, response: requests.Response) -> None:
self.status_code = status_code
self.response = response

def __str__(self) -> str:
return f"HTTP status code: {self.status_code}"


class GraphQLClientInvalidResponseError(GraphQLClientError):
def __init__(self, response: requests.Response) -> None:
self.response = response

def __str__(self) -> str:
return "Invalid response format."


class GraphQLClientGraphQLError(GraphQLClientError):
def __init__(
self,
message: str,
locations: Optional[List[Dict[str, int]]] = None,
path: Optional[List[str]] = None,
extensions: Optional[Dict[str, object]] = None,
orginal: Optional[Dict[str, object]] = None,
):
self.message = message
self.locations = locations
self.path = path
self.extensions = extensions
self.orginal = orginal

def __str__(self) -> str:
return self.message

@classmethod
def from_dict(cls, error: Dict[str, Any]) -> "GraphQLClientGraphQLError":
return cls(
message=error["message"],
locations=error.get("locations"),
path=error.get("path"),
extensions=error.get("extensions"),
orginal=error,
)


class GraphQLClientGraphQLMultiError(GraphQLClientError):
def __init__(
self,
errors: List[GraphQLClientGraphQLError],
data: Optional[Dict[str, Any]] = None,
):
self.errors = errors
self.data = data

def __str__(self) -> str:
return "; ".join(str(e) for e in self.errors)

@classmethod
def from_errors_dicts(
cls, errors_dicts: List[Dict[str, Any]], data: Optional[Dict[str, Any]] = None
) -> "GraphQLClientGraphQLMultiError":
return cls(
errors=[GraphQLClientGraphQLError.from_dict(e) for e in errors_dicts],
data=data,
)


class GraphQLClientInvalidMessageFormat(GraphQLClientError):
def __init__(self, message: Union[str, bytes]) -> None:
self.message = message

def __str__(self) -> str:
return "Invalid message format."
21 changes: 21 additions & 0 deletions api/python/quilt3-admin/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[tool.ariadne-codegen]
schema_path = "../../../shared/graphql/schema.graphql"
queries_path = "queries.graphql"
target_package_path = "../quilt3/admin/"
target_package_name = "_graphql_client"
files_to_include = [
"exceptions.py",
]
async_client = false
base_client_file_path = "base_client.py"
base_client_name = "BaseClient"
include_all_inputs = false
include_all_enums = false
plugins = [
"ariadne_codegen.contrib.shorter_results.ShorterResultsPlugin",
"ariadne_codegen.contrib.shorter_results.ShorterResultsPlugin",
"ariadne_codegen.contrib.shorter_results.ShorterResultsPlugin",
]

[tool.ariadne-codegen.scalars.Datetime]
type = "datetime.datetime"
Loading
Loading