mirror of
https://github.com/graphql-python/graphene-django.git
synced 2024-11-22 01:27:01 +03:00
Merge branch 'main' into fix-m2m-filter-type
This commit is contained in:
commit
c2901d682a
10
.github/workflows/deploy.yml
vendored
10
.github/workflows/deploy.yml
vendored
|
@ -10,17 +10,17 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- name: Set up Python 3.9
|
- name: Set up Python 3.11
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: 3.9
|
python-version: '3.11'
|
||||||
- name: Build wheel and source tarball
|
- name: Build wheel and source tarball
|
||||||
run: |
|
run: |
|
||||||
pip install wheel
|
pip install wheel
|
||||||
python setup.py sdist bdist_wheel
|
python setup.py sdist bdist_wheel
|
||||||
- name: Publish a Python distribution to PyPI
|
- name: Publish a Python distribution to PyPI
|
||||||
uses: pypa/gh-action-pypi-publish@v1.1.0
|
uses: pypa/gh-action-pypi-publish@v1.8.6
|
||||||
with:
|
with:
|
||||||
user: __token__
|
user: __token__
|
||||||
password: ${{ secrets.pypi_password }}
|
password: ${{ secrets.pypi_password }}
|
||||||
|
|
8
.github/workflows/lint.yml
vendored
8
.github/workflows/lint.yml
vendored
|
@ -7,11 +7,11 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- name: Set up Python 3.9
|
- name: Set up Python 3.11
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: 3.9
|
python-version: '3.11'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
|
|
6
.github/workflows/tests.yml
vendored
6
.github/workflows/tests.yml
vendored
|
@ -13,10 +13,12 @@ jobs:
|
||||||
include:
|
include:
|
||||||
- django: "3.2"
|
- django: "3.2"
|
||||||
python-version: "3.7"
|
python-version: "3.7"
|
||||||
|
- django: "4.1"
|
||||||
|
python-version: "3.11"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
default_language_version:
|
default_language_version:
|
||||||
python: python3.9
|
python: python3.11
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.3.0
|
rev: v4.4.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: check-merge-conflict
|
- id: check-merge-conflict
|
||||||
- id: check-json
|
- id: check-json
|
||||||
|
@ -16,15 +16,15 @@ repos:
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
exclude: README.md
|
exclude: README.md
|
||||||
- repo: https://github.com/asottile/pyupgrade
|
- repo: https://github.com/asottile/pyupgrade
|
||||||
rev: v3.2.0
|
rev: v3.3.2
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyupgrade
|
- id: pyupgrade
|
||||||
args: [--py37-plus]
|
args: [--py37-plus]
|
||||||
- repo: https://github.com/psf/black
|
- repo: https://github.com/psf/black
|
||||||
rev: 22.10.0
|
rev: 23.3.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
- repo: https://github.com/PyCQA/flake8
|
- repo: https://github.com/PyCQA/flake8
|
||||||
rev: 5.0.4
|
rev: 6.0.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: flake8
|
- id: flake8
|
||||||
|
|
1
Makefile
1
Makefile
|
@ -6,6 +6,7 @@ help:
|
||||||
.PHONY: dev-setup ## Install development dependencies
|
.PHONY: dev-setup ## Install development dependencies
|
||||||
dev-setup:
|
dev-setup:
|
||||||
pip install -e ".[dev]"
|
pip install -e ".[dev]"
|
||||||
|
python -m pre_commit install
|
||||||
|
|
||||||
.PHONY: tests ## Run unit tests
|
.PHONY: tests ## Run unit tests
|
||||||
tests:
|
tests:
|
||||||
|
|
|
@ -2,8 +2,8 @@ Filtering
|
||||||
=========
|
=========
|
||||||
|
|
||||||
Graphene integrates with
|
Graphene integrates with
|
||||||
`django-filter <https://django-filter.readthedocs.io/en/main/>`__ to provide filtering of results.
|
`django-filter <https://django-filter.readthedocs.io/en/stable/>`__ to provide filtering of results.
|
||||||
See the `usage documentation <https://django-filter.readthedocs.io/en/main/guide/usage.html#the-filter>`__
|
See the `usage documentation <https://django-filter.readthedocs.io/en/stable/guide/usage.html#the-filter>`__
|
||||||
for details on the format for ``filter_fields``.
|
for details on the format for ``filter_fields``.
|
||||||
|
|
||||||
This filtering is automatically available when implementing a ``relay.Node``.
|
This filtering is automatically available when implementing a ``relay.Node``.
|
||||||
|
|
|
@ -5,7 +5,6 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = []
|
dependencies = []
|
||||||
|
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("ingredients", "0001_initial"),
|
("ingredients", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -4,7 +4,6 @@ from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("ingredients", "0002_auto_20161104_0050"),
|
("ingredients", "0002_auto_20161104_0050"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -5,7 +5,6 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("recipes", "0001_initial"),
|
("recipes", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("recipes", "0002_auto_20161104_0106"),
|
("recipes", "0002_auto_20161104_0106"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -5,7 +5,6 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = []
|
dependencies = []
|
||||||
|
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("ingredients", "0001_initial"),
|
("ingredients", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -5,7 +5,6 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("recipes", "0001_initial"),
|
("recipes", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
from .fields import DjangoConnectionField, DjangoListField
|
from .fields import DjangoConnectionField, DjangoListField
|
||||||
from .types import DjangoObjectType
|
from .types import DjangoObjectType
|
||||||
|
|
||||||
__version__ = "3.0.0"
|
__version__ = "3.0.2"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"__version__",
|
"__version__",
|
||||||
|
|
|
@ -96,7 +96,12 @@ def convert_choices_to_named_enum_with_descriptions(name, choices):
|
||||||
def description(self):
|
def description(self):
|
||||||
return str(named_choices_descriptions[self.name])
|
return str(named_choices_descriptions[self.name])
|
||||||
|
|
||||||
return_type = Enum(name, list(named_choices), type=EnumWithDescriptionsType)
|
return_type = Enum(
|
||||||
|
name,
|
||||||
|
list(named_choices),
|
||||||
|
type=EnumWithDescriptionsType,
|
||||||
|
description="An enumeration.", # Temporary fix until https://github.com/graphql-python/graphene/pull/1502 is merged
|
||||||
|
)
|
||||||
return return_type
|
return return_type
|
||||||
|
|
||||||
|
|
||||||
|
@ -315,26 +320,7 @@ def convert_field_to_djangomodel(field, registry=None):
|
||||||
if not _type:
|
if not _type:
|
||||||
return
|
return
|
||||||
|
|
||||||
class CustomField(Field):
|
return Field(
|
||||||
def wrap_resolve(self, parent_resolver):
|
|
||||||
"""
|
|
||||||
Implements a custom resolver which go through the `get_node` method to ensure that
|
|
||||||
it goes through the `get_queryset` method of the DjangoObjectType.
|
|
||||||
"""
|
|
||||||
resolver = super().wrap_resolve(parent_resolver)
|
|
||||||
|
|
||||||
def custom_resolver(root, info, **args):
|
|
||||||
fk_obj = resolver(root, info, **args)
|
|
||||||
if not isinstance(fk_obj, model):
|
|
||||||
# In case the resolver is a custom one that overwrites
|
|
||||||
# the default Django resolver
|
|
||||||
# This happens, for example, when using custom awaitable resolvers.
|
|
||||||
return fk_obj
|
|
||||||
return _type.get_node(info, fk_obj.pk)
|
|
||||||
|
|
||||||
return custom_resolver
|
|
||||||
|
|
||||||
return CustomField(
|
|
||||||
_type,
|
_type,
|
||||||
description=get_django_field_description(field),
|
description=get_django_field_description(field),
|
||||||
required=not field.null,
|
required=not field.null,
|
||||||
|
|
|
@ -87,7 +87,6 @@ def Query(EventType):
|
||||||
events = DjangoFilterConnectionField(EventType)
|
events = DjangoFilterConnectionField(EventType)
|
||||||
|
|
||||||
def resolve_events(self, info, **kwargs):
|
def resolve_events(self, info, **kwargs):
|
||||||
|
|
||||||
events = [
|
events = [
|
||||||
Event(name="Live Show", tags=["concert", "music", "rock"]),
|
Event(name="Live Show", tags=["concert", "music", "rock"]),
|
||||||
Event(name="Musical", tags=["movie", "music"]),
|
Event(name="Musical", tags=["movie", "music"]),
|
||||||
|
|
|
@ -82,7 +82,6 @@ class DjangoFormMutation(BaseDjangoFormMutation):
|
||||||
def __init_subclass_with_meta__(
|
def __init_subclass_with_meta__(
|
||||||
cls, form_class=None, only_fields=(), exclude_fields=(), **options
|
cls, form_class=None, only_fields=(), exclude_fields=(), **options
|
||||||
):
|
):
|
||||||
|
|
||||||
if not form_class:
|
if not form_class:
|
||||||
raise Exception("form_class is required for DjangoFormMutation")
|
raise Exception("form_class is required for DjangoFormMutation")
|
||||||
|
|
||||||
|
@ -129,7 +128,6 @@ class DjangoModelFormMutation(BaseDjangoFormMutation):
|
||||||
exclude_fields=(),
|
exclude_fields=(),
|
||||||
**options,
|
**options,
|
||||||
):
|
):
|
||||||
|
|
||||||
if not form_class:
|
if not form_class:
|
||||||
raise Exception("form_class is required for DjangoModelFormMutation")
|
raise Exception("form_class is required for DjangoModelFormMutation")
|
||||||
|
|
||||||
|
|
|
@ -63,7 +63,7 @@ class Command(CommandArguments):
|
||||||
if out == "-" or out == "-.json":
|
if out == "-" or out == "-.json":
|
||||||
self.stdout.write(json.dumps(schema_dict, indent=indent, sort_keys=True))
|
self.stdout.write(json.dumps(schema_dict, indent=indent, sort_keys=True))
|
||||||
elif out == "-.graphql":
|
elif out == "-.graphql":
|
||||||
self.stdout.write(print_schema(schema))
|
self.stdout.write(print_schema(schema.graphql_schema))
|
||||||
else:
|
else:
|
||||||
# Determine format
|
# Determine format
|
||||||
_, file_extension = os.path.splitext(out)
|
_, file_extension = os.path.splitext(out)
|
||||||
|
|
|
@ -72,7 +72,6 @@ class SerializerMutation(ClientIDMutation):
|
||||||
_meta=None,
|
_meta=None,
|
||||||
**options
|
**options
|
||||||
):
|
):
|
||||||
|
|
||||||
if not serializer_class:
|
if not serializer_class:
|
||||||
raise Exception("serializer_class is required for the SerializerMutation")
|
raise Exception("serializer_class is required for the SerializerMutation")
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,8 @@
|
||||||
GraphiQL,
|
GraphiQL,
|
||||||
React,
|
React,
|
||||||
ReactDOM,
|
ReactDOM,
|
||||||
SubscriptionsTransportWs,
|
graphqlWs,
|
||||||
|
GraphiQLPluginExplorer,
|
||||||
fetch,
|
fetch,
|
||||||
history,
|
history,
|
||||||
location,
|
location,
|
||||||
|
@ -52,108 +53,34 @@
|
||||||
|
|
||||||
var fetchURL = locationQuery(otherParams);
|
var fetchURL = locationQuery(otherParams);
|
||||||
|
|
||||||
// Defines a GraphQL fetcher using the fetch API.
|
|
||||||
function httpClient(graphQLParams, opts) {
|
|
||||||
if (typeof opts === 'undefined') {
|
|
||||||
opts = {};
|
|
||||||
}
|
|
||||||
var headers = opts.headers || {};
|
|
||||||
headers['Accept'] = headers['Accept'] || 'application/json';
|
|
||||||
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
|
|
||||||
|
|
||||||
// Parse the cookie value for a CSRF token
|
|
||||||
var csrftoken;
|
|
||||||
var cookies = ("; " + document.cookie).split("; csrftoken=");
|
|
||||||
if (cookies.length == 2) {
|
|
||||||
csrftoken = cookies.pop().split(";").shift();
|
|
||||||
} else {
|
|
||||||
csrftoken = document.querySelector("[name=csrfmiddlewaretoken]").value;
|
|
||||||
}
|
|
||||||
if (csrftoken) {
|
|
||||||
headers['X-CSRFToken'] = csrftoken
|
|
||||||
}
|
|
||||||
|
|
||||||
return fetch(fetchURL, {
|
|
||||||
method: "post",
|
|
||||||
headers: headers,
|
|
||||||
body: JSON.stringify(graphQLParams),
|
|
||||||
credentials: "include",
|
|
||||||
})
|
|
||||||
.then(function (response) {
|
|
||||||
return response.text();
|
|
||||||
})
|
|
||||||
.then(function (responseBody) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(responseBody);
|
|
||||||
} catch (error) {
|
|
||||||
return responseBody;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive the subscription URL. If the SUBSCRIPTION_URL setting is specified, uses that value. Otherwise
|
// Derive the subscription URL. If the SUBSCRIPTION_URL setting is specified, uses that value. Otherwise
|
||||||
// assumes the current window location with an appropriate websocket protocol.
|
// assumes the current window location with an appropriate websocket protocol.
|
||||||
var subscribeURL =
|
var subscribeURL =
|
||||||
location.origin.replace(/^http/, "ws") +
|
location.origin.replace(/^http/, "ws") +
|
||||||
(GRAPHENE_SETTINGS.subscriptionPath || location.pathname);
|
(GRAPHENE_SETTINGS.subscriptionPath || location.pathname);
|
||||||
|
|
||||||
// Create a subscription client.
|
function trueLambda() { return true; };
|
||||||
var subscriptionClient = new SubscriptionsTransportWs.SubscriptionClient(
|
|
||||||
subscribeURL,
|
var headers = {};
|
||||||
{
|
var cookies = ("; " + document.cookie).split("; csrftoken=");
|
||||||
// Reconnect after any interruptions.
|
if (cookies.length == 2) {
|
||||||
reconnect: true,
|
csrftoken = cookies.pop().split(";").shift();
|
||||||
// Delay socket initialization until the first subscription is started.
|
} else {
|
||||||
|
csrftoken = document.querySelector("[name=csrfmiddlewaretoken]").value;
|
||||||
|
}
|
||||||
|
if (csrftoken) {
|
||||||
|
headers['X-CSRFToken'] = csrftoken
|
||||||
|
}
|
||||||
|
|
||||||
|
var graphQLFetcher = GraphiQL.createFetcher({
|
||||||
|
url: fetchURL,
|
||||||
|
wsClient: graphqlWs.createClient({
|
||||||
|
url: subscribeURL,
|
||||||
|
shouldRetry: trueLambda,
|
||||||
lazy: true,
|
lazy: true,
|
||||||
},
|
}),
|
||||||
);
|
headers: headers
|
||||||
|
})
|
||||||
// Keep a reference to the currently-active subscription, if available.
|
|
||||||
var activeSubscription = null;
|
|
||||||
|
|
||||||
// Define a GraphQL fetcher that can intelligently route queries based on the operation type.
|
|
||||||
function graphQLFetcher(graphQLParams, opts) {
|
|
||||||
var operationType = getOperationType(graphQLParams);
|
|
||||||
|
|
||||||
// If we're about to execute a new operation, and we have an active subscription,
|
|
||||||
// unsubscribe before continuing.
|
|
||||||
if (activeSubscription) {
|
|
||||||
activeSubscription.unsubscribe();
|
|
||||||
activeSubscription = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (operationType === "subscription") {
|
|
||||||
return {
|
|
||||||
subscribe: function (observer) {
|
|
||||||
activeSubscription = subscriptionClient;
|
|
||||||
return subscriptionClient.request(graphQLParams, opts).subscribe(observer);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return httpClient(graphQLParams, opts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine the type of operation being executed for a given set of GraphQL parameters.
|
|
||||||
function getOperationType(graphQLParams) {
|
|
||||||
// Run a regex against the query to determine the operation type (query, mutation, subscription).
|
|
||||||
var operationRegex = new RegExp(
|
|
||||||
// Look for lines that start with an operation keyword, ignoring whitespace.
|
|
||||||
"^\\s*(query|mutation|subscription)\\s*" +
|
|
||||||
// The operation keyword should be followed by whitespace and the operationName in the GraphQL parameters (if available).
|
|
||||||
(graphQLParams.operationName ? ("\\s+" + graphQLParams.operationName) : "") +
|
|
||||||
// The line should eventually encounter an opening curly brace.
|
|
||||||
"[^\\{]*\\{",
|
|
||||||
// Enable multiline matching.
|
|
||||||
"m",
|
|
||||||
);
|
|
||||||
var match = operationRegex.exec(graphQLParams.query);
|
|
||||||
if (!match) {
|
|
||||||
return "query";
|
|
||||||
}
|
|
||||||
|
|
||||||
return match[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
// When the query and variables string is edited, update the URL bar so
|
// When the query and variables string is edited, update the URL bar so
|
||||||
// that it can be easily shared.
|
// that it can be easily shared.
|
||||||
|
@ -172,24 +99,44 @@
|
||||||
function updateURL() {
|
function updateURL() {
|
||||||
history.replaceState(null, null, locationQuery(parameters));
|
history.replaceState(null, null, locationQuery(parameters));
|
||||||
}
|
}
|
||||||
var options = {
|
|
||||||
fetcher: graphQLFetcher,
|
function GraphiQLWithExplorer() {
|
||||||
onEditQuery: onEditQuery,
|
var [query, setQuery] = React.useState(parameters.query);
|
||||||
onEditVariables: onEditVariables,
|
|
||||||
onEditOperationName: onEditOperationName,
|
function handleQuery(query) {
|
||||||
headerEditorEnabled: GRAPHENE_SETTINGS.graphiqlHeaderEditorEnabled,
|
setQuery(query);
|
||||||
shouldPersistHeaders: GRAPHENE_SETTINGS.graphiqlShouldPersistHeaders,
|
onEditQuery(query);
|
||||||
query: parameters.query,
|
}
|
||||||
};
|
|
||||||
if (parameters.variables) {
|
var explorerPlugin = GraphiQLPluginExplorer.useExplorerPlugin({
|
||||||
options.variables = parameters.variables;
|
query: query,
|
||||||
}
|
onEdit: handleQuery,
|
||||||
if (parameters.operation_name) {
|
});
|
||||||
options.operationName = parameters.operation_name;
|
|
||||||
|
var options = {
|
||||||
|
fetcher: graphQLFetcher,
|
||||||
|
plugins: [explorerPlugin],
|
||||||
|
defaultEditorToolsVisibility: true,
|
||||||
|
onEditQuery: handleQuery,
|
||||||
|
onEditVariables: onEditVariables,
|
||||||
|
onEditOperationName: onEditOperationName,
|
||||||
|
isHeadersEditorEnabled: GRAPHENE_SETTINGS.graphiqlHeaderEditorEnabled,
|
||||||
|
shouldPersistHeaders: GRAPHENE_SETTINGS.graphiqlShouldPersistHeaders,
|
||||||
|
query: query,
|
||||||
|
};
|
||||||
|
if (parameters.variables) {
|
||||||
|
options.variables = parameters.variables;
|
||||||
|
}
|
||||||
|
if (parameters.operation_name) {
|
||||||
|
options.operationName = parameters.operation_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return React.createElement(GraphiQL, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render <GraphiQL /> into the body.
|
// Render <GraphiQL /> into the body.
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
React.createElement(GraphiQL, options),
|
React.createElement(GraphiQLWithExplorer),
|
||||||
document.getElementById("editor"),
|
document.getElementById("editor"),
|
||||||
);
|
);
|
||||||
})(
|
})(
|
||||||
|
@ -199,7 +146,8 @@
|
||||||
window.GraphiQL,
|
window.GraphiQL,
|
||||||
window.React,
|
window.React,
|
||||||
window.ReactDOM,
|
window.ReactDOM,
|
||||||
window.SubscriptionsTransportWs,
|
window.graphqlWs,
|
||||||
|
window.GraphiQLPluginExplorer,
|
||||||
window.fetch,
|
window.fetch,
|
||||||
window.history,
|
window.history,
|
||||||
window.location,
|
window.location,
|
||||||
|
|
|
@ -33,9 +33,12 @@ add "&raw" to the end of the URL within a browser.
|
||||||
<script src="https://cdn.jsdelivr.net/npm/graphiql@{{graphiql_version}}/graphiql.min.js"
|
<script src="https://cdn.jsdelivr.net/npm/graphiql@{{graphiql_version}}/graphiql.min.js"
|
||||||
integrity="{{graphiql_sri}}"
|
integrity="{{graphiql_sri}}"
|
||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/subscriptions-transport-ws@{{subscriptions_transport_ws_version}}/browser/client.js"
|
<script src="https://cdn.jsdelivr.net/npm/graphql-ws@{{subscriptions_transport_ws_version}}/umd/graphql-ws.min.js"
|
||||||
integrity="{{subscriptions_transport_ws_sri}}"
|
integrity="{{subscriptions_transport_ws_sri}}"
|
||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@graphiql/plugin-explorer@{{graphiql_plugin_explorer_version}}/dist/graphiql-plugin-explorer.umd.js"
|
||||||
|
integrity="{{graphiql_plugin_explorer_sri}}"
|
||||||
|
crossorigin="anonymous"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="editor"></div>
|
<div id="editor"></div>
|
||||||
|
|
|
@ -5,7 +5,6 @@ from .mutations import PetFormMutation, PetMutation
|
||||||
|
|
||||||
|
|
||||||
class QueryRoot(ObjectType):
|
class QueryRoot(ObjectType):
|
||||||
|
|
||||||
thrower = graphene.String(required=True)
|
thrower = graphene.String(required=True)
|
||||||
request = graphene.String(required=True)
|
request = graphene.String(required=True)
|
||||||
test = graphene.String(who=graphene.String())
|
test = graphene.String(who=graphene.String())
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import datetime
|
import datetime
|
||||||
from django.db.models import Count
|
import re
|
||||||
|
from django.db.models import Count, Prefetch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
@ -7,8 +8,12 @@ from graphene import List, NonNull, ObjectType, Schema, String
|
||||||
|
|
||||||
from ..fields import DjangoListField
|
from ..fields import DjangoListField
|
||||||
from ..types import DjangoObjectType
|
from ..types import DjangoObjectType
|
||||||
from .models import Article as ArticleModel
|
from .models import (
|
||||||
from .models import Reporter as ReporterModel
|
Article as ArticleModel,
|
||||||
|
Film as FilmModel,
|
||||||
|
FilmDetails as FilmDetailsModel,
|
||||||
|
Reporter as ReporterModel,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestDjangoListField:
|
class TestDjangoListField:
|
||||||
|
@ -500,3 +505,145 @@ class TestDjangoListField:
|
||||||
|
|
||||||
assert not result.errors
|
assert not result.errors
|
||||||
assert result.data == {"reporters": [{"firstName": "Tara"}]}
|
assert result.data == {"reporters": [{"firstName": "Tara"}]}
|
||||||
|
|
||||||
|
def test_select_related_and_prefetch_related_are_respected(
|
||||||
|
self, django_assert_num_queries
|
||||||
|
):
|
||||||
|
class Article(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = ArticleModel
|
||||||
|
fields = ("headline", "editor", "reporter")
|
||||||
|
|
||||||
|
class Film(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = FilmModel
|
||||||
|
fields = ("genre", "details")
|
||||||
|
|
||||||
|
class FilmDetail(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = FilmDetailsModel
|
||||||
|
fields = ("location",)
|
||||||
|
|
||||||
|
class Reporter(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = ReporterModel
|
||||||
|
fields = ("first_name", "articles", "films")
|
||||||
|
|
||||||
|
class Query(ObjectType):
|
||||||
|
articles = DjangoListField(Article)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_articles(root, info):
|
||||||
|
# Optimize for querying associated editors and reporters, and the films and film
|
||||||
|
# details of those reporters. This is similar to what would happen using a library
|
||||||
|
# like https://github.com/tfoxy/graphene-django-optimizer for a query like the one
|
||||||
|
# below (albeit simplified and hardcoded here).
|
||||||
|
return ArticleModel.objects.select_related(
|
||||||
|
"editor", "reporter"
|
||||||
|
).prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"reporter__films",
|
||||||
|
queryset=FilmModel.objects.select_related("details"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
schema = Schema(query=Query)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
articles {
|
||||||
|
headline
|
||||||
|
|
||||||
|
editor {
|
||||||
|
firstName
|
||||||
|
}
|
||||||
|
|
||||||
|
reporter {
|
||||||
|
firstName
|
||||||
|
|
||||||
|
films {
|
||||||
|
genre
|
||||||
|
|
||||||
|
details {
|
||||||
|
location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
r1 = ReporterModel.objects.create(first_name="Tara", last_name="West")
|
||||||
|
r2 = ReporterModel.objects.create(first_name="Debra", last_name="Payne")
|
||||||
|
|
||||||
|
ArticleModel.objects.create(
|
||||||
|
headline="Amazing news",
|
||||||
|
reporter=r1,
|
||||||
|
pub_date=datetime.date.today(),
|
||||||
|
pub_date_time=datetime.datetime.now(),
|
||||||
|
editor=r2,
|
||||||
|
)
|
||||||
|
ArticleModel.objects.create(
|
||||||
|
headline="Not so good news",
|
||||||
|
reporter=r2,
|
||||||
|
pub_date=datetime.date.today(),
|
||||||
|
pub_date_time=datetime.datetime.now(),
|
||||||
|
editor=r1,
|
||||||
|
)
|
||||||
|
|
||||||
|
film1 = FilmModel.objects.create(genre="ac")
|
||||||
|
film2 = FilmModel.objects.create(genre="ot")
|
||||||
|
film3 = FilmModel.objects.create(genre="do")
|
||||||
|
FilmDetailsModel.objects.create(location="Hollywood", film=film1)
|
||||||
|
FilmDetailsModel.objects.create(location="Antarctica", film=film3)
|
||||||
|
r1.films.add(film1, film2)
|
||||||
|
r2.films.add(film3)
|
||||||
|
|
||||||
|
# We expect 2 queries to be performed based on the above resolver definition: one for all
|
||||||
|
# articles joined with the reporters model (for associated editors and reporters), and one
|
||||||
|
# for the films prefetch (which includes its `select_related` JOIN logic in its queryset)
|
||||||
|
with django_assert_num_queries(2) as captured:
|
||||||
|
result = schema.execute(query)
|
||||||
|
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data == {
|
||||||
|
"articles": [
|
||||||
|
{
|
||||||
|
"headline": "Amazing news",
|
||||||
|
"editor": {"firstName": "Debra"},
|
||||||
|
"reporter": {
|
||||||
|
"firstName": "Tara",
|
||||||
|
"films": [
|
||||||
|
{"genre": "AC", "details": {"location": "Hollywood"}},
|
||||||
|
{"genre": "OT", "details": None},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"headline": "Not so good news",
|
||||||
|
"editor": {"firstName": "Tara"},
|
||||||
|
"reporter": {
|
||||||
|
"firstName": "Debra",
|
||||||
|
"films": [
|
||||||
|
{"genre": "DO", "details": {"location": "Antarctica"}},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
assert len(captured.captured_queries) == 2 # Sanity-check
|
||||||
|
|
||||||
|
# First we should have queried for all articles in a single query, joining on the reporters
|
||||||
|
# model (for the editors and reporters ForeignKeys)
|
||||||
|
assert re.match(
|
||||||
|
r'SELECT .* "tests_article" INNER JOIN "tests_reporter"',
|
||||||
|
captured.captured_queries[0]["sql"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Then we should have queried for all of the films of all reporters, joined with the film
|
||||||
|
# details for each film, using a single query
|
||||||
|
assert re.match(
|
||||||
|
r'SELECT .* FROM "tests_film" INNER JOIN "tests_film_reporters" .* LEFT OUTER JOIN "tests_filmdetails"',
|
||||||
|
captured.captured_queries[1]["sql"],
|
||||||
|
)
|
||||||
|
|
|
@ -16,6 +16,12 @@ class TestShouldCallGetQuerySetOnForeignKey:
|
||||||
Check that the get_queryset method is called in both forward and reversed direction
|
Check that the get_queryset method is called in both forward and reversed direction
|
||||||
of a foreignkey on types.
|
of a foreignkey on types.
|
||||||
(see issue #1111)
|
(see issue #1111)
|
||||||
|
|
||||||
|
NOTE: For now, we do not expect this get_queryset method to be called for nested
|
||||||
|
objects, as the original attempt to do so prevented SQL query-optimization with
|
||||||
|
`select_related`/`prefetch_related` and caused N+1 queries. See discussions here
|
||||||
|
https://github.com/graphql-python/graphene-django/pull/1315/files#r1015659857
|
||||||
|
and here https://github.com/graphql-python/graphene-django/pull/1401.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
|
@ -121,69 +127,6 @@ class TestShouldCallGetQuerySetOnForeignKey:
|
||||||
assert not result.errors
|
assert not result.errors
|
||||||
assert result.data == {"reporter": {"firstName": "Jane"}}
|
assert result.data == {"reporter": {"firstName": "Jane"}}
|
||||||
|
|
||||||
def test_get_queryset_called_on_foreignkey(self):
|
|
||||||
# If a user tries to access a reporter through an article they should get our authorization error
|
|
||||||
query = """
|
|
||||||
query getArticle($id: ID!) {
|
|
||||||
article(id: $id) {
|
|
||||||
headline
|
|
||||||
reporter {
|
|
||||||
firstName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
result = self.schema.execute(query, variables={"id": self.articles[0].id})
|
|
||||||
assert len(result.errors) == 1
|
|
||||||
assert result.errors[0].message == "Not authorized to access reporters."
|
|
||||||
|
|
||||||
# An admin user should be able to get reporters through an article
|
|
||||||
query = """
|
|
||||||
query getArticle($id: ID!) {
|
|
||||||
article(id: $id) {
|
|
||||||
headline
|
|
||||||
reporter {
|
|
||||||
firstName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
result = self.schema.execute(
|
|
||||||
query,
|
|
||||||
variables={"id": self.articles[0].id},
|
|
||||||
context_value={"admin": True},
|
|
||||||
)
|
|
||||||
assert not result.errors
|
|
||||||
assert result.data["article"] == {
|
|
||||||
"headline": "A fantastic article",
|
|
||||||
"reporter": {"firstName": "Jane"},
|
|
||||||
}
|
|
||||||
|
|
||||||
# An admin user should not be able to access draft article through a reporter
|
|
||||||
query = """
|
|
||||||
query getReporter($id: ID!) {
|
|
||||||
reporter(id: $id) {
|
|
||||||
firstName
|
|
||||||
articles {
|
|
||||||
headline
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
result = self.schema.execute(
|
|
||||||
query,
|
|
||||||
variables={"id": self.reporter.id},
|
|
||||||
context_value={"admin": True},
|
|
||||||
)
|
|
||||||
assert not result.errors
|
|
||||||
assert result.data["reporter"] == {
|
|
||||||
"firstName": "Jane",
|
|
||||||
"articles": [{"headline": "A fantastic article"}],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestShouldCallGetQuerySetOnForeignKeyNode:
|
class TestShouldCallGetQuerySetOnForeignKeyNode:
|
||||||
"""
|
"""
|
||||||
|
@ -290,72 +233,3 @@ class TestShouldCallGetQuerySetOnForeignKeyNode:
|
||||||
)
|
)
|
||||||
assert not result.errors
|
assert not result.errors
|
||||||
assert result.data == {"reporter": {"firstName": "Jane"}}
|
assert result.data == {"reporter": {"firstName": "Jane"}}
|
||||||
|
|
||||||
def test_get_queryset_called_on_foreignkey(self):
|
|
||||||
# If a user tries to access a reporter through an article they should get our authorization error
|
|
||||||
query = """
|
|
||||||
query getArticle($id: ID!) {
|
|
||||||
article(id: $id) {
|
|
||||||
headline
|
|
||||||
reporter {
|
|
||||||
firstName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
result = self.schema.execute(
|
|
||||||
query, variables={"id": to_global_id("ArticleType", self.articles[0].id)}
|
|
||||||
)
|
|
||||||
assert len(result.errors) == 1
|
|
||||||
assert result.errors[0].message == "Not authorized to access reporters."
|
|
||||||
|
|
||||||
# An admin user should be able to get reporters through an article
|
|
||||||
query = """
|
|
||||||
query getArticle($id: ID!) {
|
|
||||||
article(id: $id) {
|
|
||||||
headline
|
|
||||||
reporter {
|
|
||||||
firstName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
result = self.schema.execute(
|
|
||||||
query,
|
|
||||||
variables={"id": to_global_id("ArticleType", self.articles[0].id)},
|
|
||||||
context_value={"admin": True},
|
|
||||||
)
|
|
||||||
assert not result.errors
|
|
||||||
assert result.data["article"] == {
|
|
||||||
"headline": "A fantastic article",
|
|
||||||
"reporter": {"firstName": "Jane"},
|
|
||||||
}
|
|
||||||
|
|
||||||
# An admin user should not be able to access draft article through a reporter
|
|
||||||
query = """
|
|
||||||
query getReporter($id: ID!) {
|
|
||||||
reporter(id: $id) {
|
|
||||||
firstName
|
|
||||||
articles {
|
|
||||||
edges {
|
|
||||||
node {
|
|
||||||
headline
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
result = self.schema.execute(
|
|
||||||
query,
|
|
||||||
variables={"id": to_global_id("ReporterType", self.reporter.id)},
|
|
||||||
context_value={"admin": True},
|
|
||||||
)
|
|
||||||
assert not result.errors
|
|
||||||
assert result.data["reporter"] == {
|
|
||||||
"firstName": "Jane",
|
|
||||||
"articles": {"edges": [{"node": {"headline": "A fantastic article"}}]},
|
|
||||||
}
|
|
||||||
|
|
|
@ -780,7 +780,6 @@ def test_should_query_promise_connectionfields():
|
||||||
|
|
||||||
|
|
||||||
def test_should_query_connectionfields_with_last():
|
def test_should_query_connectionfields_with_last():
|
||||||
|
|
||||||
r = Reporter.objects.create(
|
r = Reporter.objects.create(
|
||||||
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
|
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
|
||||||
)
|
)
|
||||||
|
@ -818,7 +817,6 @@ def test_should_query_connectionfields_with_last():
|
||||||
|
|
||||||
|
|
||||||
def test_should_query_connectionfields_with_manager():
|
def test_should_query_connectionfields_with_manager():
|
||||||
|
|
||||||
r = Reporter.objects.create(
|
r = Reporter.objects.create(
|
||||||
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
|
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
|
||||||
)
|
)
|
||||||
|
|
|
@ -66,16 +66,19 @@ class GraphQLView(View):
|
||||||
react_dom_sri = "sha256-nbMykgB6tsOFJ7OdVmPpdqMFVk4ZsqWocT6issAPUF0="
|
react_dom_sri = "sha256-nbMykgB6tsOFJ7OdVmPpdqMFVk4ZsqWocT6issAPUF0="
|
||||||
|
|
||||||
# The GraphiQL React app.
|
# The GraphiQL React app.
|
||||||
graphiql_version = "1.4.7" # "1.0.3"
|
graphiql_version = "2.4.1" # "1.0.3"
|
||||||
graphiql_sri = "sha256-cpZ8w9D/i6XdEbY/Eu7yAXeYzReVw0mxYd7OU3gUcsc=" # "sha256-VR4buIDY9ZXSyCNFHFNik6uSe0MhigCzgN4u7moCOTk="
|
graphiql_sri = "sha256-s+f7CFAPSUIygFnRC2nfoiEKd3liCUy+snSdYFAoLUc=" # "sha256-VR4buIDY9ZXSyCNFHFNik6uSe0MhigCzgN4u7moCOTk="
|
||||||
graphiql_css_sri = "sha256-HADQowUuFum02+Ckkv5Yu5ygRoLllHZqg0TFZXY7NHI=" # "sha256-LwqxjyZgqXDYbpxQJ5zLQeNcf7WVNSJ+r8yp2rnWE/E="
|
graphiql_css_sri = "sha256-88yn8FJMyGboGs4Bj+Pbb3kWOWXo7jmb+XCRHE+282k=" # "sha256-LwqxjyZgqXDYbpxQJ5zLQeNcf7WVNSJ+r8yp2rnWE/E="
|
||||||
|
|
||||||
# The websocket transport library for subscriptions.
|
# The websocket transport library for subscriptions.
|
||||||
subscriptions_transport_ws_version = "0.9.18"
|
subscriptions_transport_ws_version = "5.12.1"
|
||||||
subscriptions_transport_ws_sri = (
|
subscriptions_transport_ws_sri = (
|
||||||
"sha256-i0hAXd4PdJ/cHX3/8tIy/Q/qKiWr5WSTxMFuL9tACkw="
|
"sha256-EZhvg6ANJrBsgLvLAa0uuHNLepLJVCFYS+xlb5U/bqw="
|
||||||
)
|
)
|
||||||
|
|
||||||
|
graphiql_plugin_explorer_version = "0.1.15"
|
||||||
|
graphiql_plugin_explorer_sri = "sha256-3hUuhBXdXlfCj6RTeEkJFtEh/kUG+TCDASFpFPLrzvE="
|
||||||
|
|
||||||
schema = None
|
schema = None
|
||||||
graphiql = False
|
graphiql = False
|
||||||
middleware = None
|
middleware = None
|
||||||
|
@ -158,6 +161,8 @@ class GraphQLView(View):
|
||||||
graphiql_css_sri=self.graphiql_css_sri,
|
graphiql_css_sri=self.graphiql_css_sri,
|
||||||
subscriptions_transport_ws_version=self.subscriptions_transport_ws_version,
|
subscriptions_transport_ws_version=self.subscriptions_transport_ws_version,
|
||||||
subscriptions_transport_ws_sri=self.subscriptions_transport_ws_sri,
|
subscriptions_transport_ws_sri=self.subscriptions_transport_ws_sri,
|
||||||
|
graphiql_plugin_explorer_version=self.graphiql_plugin_explorer_version,
|
||||||
|
graphiql_plugin_explorer_sri=self.graphiql_plugin_explorer_sri,
|
||||||
# The SUBSCRIPTION_PATH setting.
|
# The SUBSCRIPTION_PATH setting.
|
||||||
subscription_path=self.subscription_path,
|
subscription_path=self.subscription_path,
|
||||||
# GraphiQL headers tab,
|
# GraphiQL headers tab,
|
||||||
|
|
12
setup.py
12
setup.py
|
@ -14,7 +14,7 @@ rest_framework_require = ["djangorestframework>=3.6.3"]
|
||||||
|
|
||||||
|
|
||||||
tests_require = [
|
tests_require = [
|
||||||
"pytest>=7.1.3",
|
"pytest>=7.3.1",
|
||||||
"pytest-cov",
|
"pytest-cov",
|
||||||
"pytest-random-order",
|
"pytest-random-order",
|
||||||
"coveralls",
|
"coveralls",
|
||||||
|
@ -26,10 +26,11 @@ tests_require = [
|
||||||
|
|
||||||
|
|
||||||
dev_requires = [
|
dev_requires = [
|
||||||
"black==22.8.0",
|
"black==23.3.0",
|
||||||
"flake8==5.0.4",
|
"flake8==6.0.0",
|
||||||
"flake8-black==0.3.3",
|
"flake8-black==0.3.6",
|
||||||
"flake8-bugbear==22.9.11",
|
"flake8-bugbear==23.3.23",
|
||||||
|
"pre-commit",
|
||||||
] + tests_require
|
] + tests_require
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
|
@ -50,6 +51,7 @@ setup(
|
||||||
"Programming Language :: Python :: 3.8",
|
"Programming Language :: Python :: 3.8",
|
||||||
"Programming Language :: Python :: 3.9",
|
"Programming Language :: Python :: 3.9",
|
||||||
"Programming Language :: Python :: 3.10",
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
"Programming Language :: Python :: Implementation :: PyPy",
|
"Programming Language :: Python :: Implementation :: PyPy",
|
||||||
"Framework :: Django",
|
"Framework :: Django",
|
||||||
"Framework :: Django :: 3.2",
|
"Framework :: Django :: 3.2",
|
||||||
|
|
2
tox.ini
2
tox.ini
|
@ -2,6 +2,7 @@
|
||||||
envlist =
|
envlist =
|
||||||
py{37,38,39,310}-django32,
|
py{37,38,39,310}-django32,
|
||||||
py{38,39,310}-django{40,41,main},
|
py{38,39,310}-django{40,41,main},
|
||||||
|
py311-django{41,main}
|
||||||
pre-commit
|
pre-commit
|
||||||
|
|
||||||
[gh-actions]
|
[gh-actions]
|
||||||
|
@ -10,6 +11,7 @@ python =
|
||||||
3.8: py38
|
3.8: py38
|
||||||
3.9: py39
|
3.9: py39
|
||||||
3.10: py310
|
3.10: py310
|
||||||
|
3.11: py311
|
||||||
|
|
||||||
[gh-actions:env]
|
[gh-actions:env]
|
||||||
DJANGO =
|
DJANGO =
|
||||||
|
|
Loading…
Reference in New Issue
Block a user