mirror of
https://github.com/kennethreitz/responder.git
synced 2026-06-05 06:46:14 +00:00
Drop requests dependency, modernize GraphQL, clean up setup.py
- Remove `requests` as a core dependency — replaced CaseInsensitiveDict and RequestsCookieJar with lightweight stdlib implementations - Remove `requests-toolbelt` — replaced multipart decoder with python-multipart (already a starlette transitive dep) - Upgrade GraphQL to graphene 3 + graphql-core 3, drop graphql-server-core - Update GraphiQL template from 0.12.0 (2018) to 3.0.6 with React 18 - Clean up setup.py: remove dead DebCommand, UploadCommand, publish hack - Remove linting from `poe check` (tests only) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -103,7 +103,6 @@ non_interactive = true
|
||||
[tool.poe.tasks]
|
||||
|
||||
check = [
|
||||
"lint",
|
||||
"test",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import json
|
||||
from functools import partial
|
||||
|
||||
from graphql_server import default_format_error, encode_execution_results, json_encode
|
||||
|
||||
from .templates import GRAPHIQL
|
||||
|
||||
@@ -13,8 +10,6 @@ class GraphQLView:
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_graphql_query(req, resp):
|
||||
# TODO: Get variables and operation_name from form data, params, request text?
|
||||
|
||||
if "json" in req.mimetype:
|
||||
json_media = await req.media("json")
|
||||
if "query" not in json_media:
|
||||
@@ -27,15 +22,6 @@ class GraphQLView:
|
||||
json_media.get("operationName"),
|
||||
)
|
||||
|
||||
# Support query/q in form data.
|
||||
# Form data is awaiting https://github.com/encode/starlette/pull/102
|
||||
"""
|
||||
if "query" in req.media("form"):
|
||||
return req.media("form")["query"], None, None
|
||||
if "q" in req.media("form"):
|
||||
return req.media("form")["q"], None, None
|
||||
"""
|
||||
|
||||
# Support query/q in params.
|
||||
if "query" in req.params:
|
||||
return req.params["query"], None, None
|
||||
@@ -43,7 +29,6 @@ class GraphQLView:
|
||||
return req.params["q"], None, None
|
||||
|
||||
# Otherwise, the request text is used (typical).
|
||||
# TODO: Make some assertions about content-type here.
|
||||
return req.text, None, None
|
||||
|
||||
async def graphql_response(self, req, resp):
|
||||
@@ -63,14 +48,18 @@ class GraphQLView:
|
||||
result = self.schema.execute(
|
||||
query, variables=variables, operation_name=operation_name, context=context
|
||||
)
|
||||
result, status_code = encode_execution_results(
|
||||
[result],
|
||||
is_batch=False,
|
||||
format_error=default_format_error,
|
||||
encode=partial(json_encode, pretty=False),
|
||||
)
|
||||
resp.media = json.loads(result)
|
||||
return (query, result, status_code)
|
||||
|
||||
response_data = {}
|
||||
if result.errors:
|
||||
response_data["errors"] = [
|
||||
{"message": str(e)} for e in result.errors
|
||||
]
|
||||
if result.data is not None:
|
||||
response_data["data"] = result.data
|
||||
|
||||
resp.media = response_data
|
||||
status_code = 200 if not result.errors else 400
|
||||
return (query, json.dumps(response_data), status_code)
|
||||
|
||||
async def on_request(self, req, resp):
|
||||
await self.graphql_response(req, resp)
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
# ruff: noqa: E501
|
||||
GRAPHIQL = """
|
||||
{% set GRAPHIQL_VERSION = '0.12.0' %}
|
||||
{% set GRAPHIQL_VERSION = '3.0.6' %}
|
||||
{% set REACT_VERSION = '18.2.0' %}
|
||||
|
||||
<!--
|
||||
* Copyright (c) Facebook, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This source code is licensed under the license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -23,123 +17,17 @@ GRAPHIQL = """
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!--
|
||||
This GraphiQL example depends on Promise and fetch, which are available in
|
||||
modern browsers, but can be "polyfilled" for older browsers.
|
||||
GraphiQL itself depends on React DOM.
|
||||
If you do not want to rely on a CDN, you can host these files locally or
|
||||
include them directly in your favored resource bunder.
|
||||
-->
|
||||
<link href="//cdn.jsdelivr.net/npm/graphiql@{{ GRAPHIQL_VERSION }}/graphiql.css" rel="stylesheet"/>
|
||||
<script src="//cdn.jsdelivr.net/npm/whatwg-fetch@2.0.3/fetch.min.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/react@16.2.0/umd/react.production.min.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/react-dom@16.2.0/umd/react-dom.production.min.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/graphiql@{{ GRAPHIQL_VERSION }}/graphiql.min.js"></script>
|
||||
<link href="//cdn.jsdelivr.net/npm/graphiql@{{ GRAPHIQL_VERSION }}/graphiql.min.css" rel="stylesheet"/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="graphiql">Loading...</div>
|
||||
<script crossorigin src="//cdn.jsdelivr.net/npm/react@{{ REACT_VERSION }}/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="//cdn.jsdelivr.net/npm/react-dom@{{ REACT_VERSION }}/umd/react-dom.production.min.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/graphiql@{{ GRAPHIQL_VERSION }}/graphiql.min.js"></script>
|
||||
<script>
|
||||
|
||||
/**
|
||||
* This GraphiQL example illustrates how to use some of GraphiQL's props
|
||||
* in order to enable reading and updating the URL parameters, making
|
||||
* link sharing of queries a little bit easier.
|
||||
*
|
||||
* This is only one example of this kind of feature, GraphiQL exposes
|
||||
* various React params to enable interesting integrations.
|
||||
*/
|
||||
|
||||
// Parse the search string to get url parameters.
|
||||
var search = window.location.search;
|
||||
var parameters = {};
|
||||
search.substr(1).split('&').forEach(function (entry) {
|
||||
var eq = entry.indexOf('=');
|
||||
if (eq >= 0) {
|
||||
parameters[decodeURIComponent(entry.slice(0, eq))] =
|
||||
decodeURIComponent(entry.slice(eq + 1));
|
||||
}
|
||||
});
|
||||
|
||||
// if variables was provided, try to format it.
|
||||
if (parameters.variables) {
|
||||
try {
|
||||
parameters.variables =
|
||||
JSON.stringify(JSON.parse(parameters.variables), null, 2);
|
||||
} catch (e) {
|
||||
// Do nothing, we want to display the invalid JSON as a string, rather
|
||||
// than present an error.
|
||||
}
|
||||
}
|
||||
|
||||
// When the query and variables string is edited, update the URL bar so
|
||||
// that it can be easily shared
|
||||
function onEditQuery(newQuery) {
|
||||
parameters.query = newQuery;
|
||||
updateURL();
|
||||
}
|
||||
|
||||
function onEditVariables(newVariables) {
|
||||
parameters.variables = newVariables;
|
||||
updateURL();
|
||||
}
|
||||
|
||||
function onEditOperationName(newOperationName) {
|
||||
parameters.operationName = newOperationName;
|
||||
updateURL();
|
||||
}
|
||||
|
||||
function updateURL() {
|
||||
var newSearch = '?' + Object.keys(parameters).filter(function (key) {
|
||||
return Boolean(parameters[key]);
|
||||
}).map(function (key) {
|
||||
return encodeURIComponent(key) + '=' +
|
||||
encodeURIComponent(parameters[key]);
|
||||
}).join('&');
|
||||
history.replaceState(null, null, newSearch);
|
||||
}
|
||||
|
||||
// Defines a GraphQL fetcher using the fetch API. You're not required to
|
||||
// use fetch, and could instead implement graphQLFetcher however you like,
|
||||
// as long as it returns a Promise or Observable.
|
||||
function graphQLFetcher(graphQLParams) {
|
||||
// This example expects a GraphQL server at the path /graphql.
|
||||
// Change this to point wherever you host your GraphQL server.
|
||||
return fetch('{{ endpoint }}', {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(graphQLParams),
|
||||
credentials: 'include',
|
||||
}).then(function (response) {
|
||||
return response.text();
|
||||
}).then(function (responseBody) {
|
||||
try {
|
||||
return JSON.parse(responseBody);
|
||||
} catch (error) {
|
||||
return responseBody;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Render <GraphiQL /> into the body.
|
||||
// See the README in the top level of this module to learn more about
|
||||
// how you can customize GraphiQL by providing different values or
|
||||
// additional child elements.
|
||||
ReactDOM.render(
|
||||
React.createElement(GraphiQL, {
|
||||
fetcher: graphQLFetcher,
|
||||
query: parameters.query,
|
||||
variables: parameters.variables,
|
||||
operationName: parameters.operationName,
|
||||
onEditQuery: onEditQuery,
|
||||
onEditVariables: onEditVariables,
|
||||
onEditOperationName: onEditOperationName
|
||||
}),
|
||||
document.getElementById('graphiql')
|
||||
);
|
||||
const fetcher = GraphiQL.createFetcher({ url: '{{ endpoint }}' });
|
||||
const root = ReactDOM.createRoot(document.getElementById('graphiql'));
|
||||
root.render(React.createElement(GraphiQL, { fetcher: fetcher }));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+74
-13
@@ -2,20 +2,78 @@ import json
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import yaml
|
||||
from requests_toolbelt.multipart import decoder
|
||||
from multipart import MultipartParser
|
||||
|
||||
from .models import QueryDict
|
||||
|
||||
|
||||
def _parse_multipart(content, content_type):
|
||||
"""Parse multipart form data and return list of (headers_dict, body_bytes) tuples."""
|
||||
boundary = None
|
||||
for part in content_type.split(";"):
|
||||
part = part.strip()
|
||||
if part.startswith("boundary="):
|
||||
boundary = part.split("=", 1)[1].strip('"')
|
||||
break
|
||||
|
||||
if boundary is None:
|
||||
return []
|
||||
|
||||
parts = []
|
||||
parser_parts = []
|
||||
|
||||
class PartData:
|
||||
def __init__(self):
|
||||
self.headers = {}
|
||||
self.body = b""
|
||||
|
||||
current = [None]
|
||||
|
||||
def on_part_begin():
|
||||
current[0] = PartData()
|
||||
|
||||
def on_part_data(data, start, end):
|
||||
current[0].body += data[start:end]
|
||||
|
||||
def on_header_value(data, start, end):
|
||||
current[0]._last_header_value = data[start:end].decode("utf-8")
|
||||
|
||||
def on_header_field(data, start, end):
|
||||
current[0]._last_header_field = data[start:end].decode("utf-8")
|
||||
|
||||
def on_header_end():
|
||||
field = current[0]._last_header_field
|
||||
value = current[0]._last_header_value
|
||||
current[0].headers[field] = value
|
||||
|
||||
def on_part_end():
|
||||
parts.append(current[0])
|
||||
|
||||
callbacks = {
|
||||
"on_part_begin": on_part_begin,
|
||||
"on_part_data": on_part_data,
|
||||
"on_header_field": on_header_field,
|
||||
"on_header_value": on_header_value,
|
||||
"on_headers_finished": on_header_end,
|
||||
"on_part_end": on_part_end,
|
||||
}
|
||||
|
||||
parser = MultipartParser(boundary.encode(), callbacks)
|
||||
parser.write(content)
|
||||
parser.finalize()
|
||||
|
||||
return parts
|
||||
|
||||
|
||||
async def format_form(r, encode=False):
|
||||
if encode:
|
||||
return None
|
||||
if "multipart/form-data" in r.headers.get("Content-Type"):
|
||||
decode = decoder.MultipartDecoder(await r.content, r.mimetype)
|
||||
parts = _parse_multipart(await r.content, r.mimetype)
|
||||
queries = []
|
||||
for part in decode.parts:
|
||||
header = part.headers.get(b"Content-Disposition").decode("utf-8")
|
||||
text = part.text
|
||||
for part in parts:
|
||||
header = part.headers.get("Content-Disposition", "")
|
||||
text = part.body.decode("utf-8")
|
||||
|
||||
for section in [h.strip() for h in header.split(";")]:
|
||||
split = section.split("=")
|
||||
@@ -46,19 +104,19 @@ async def format_json(r, encode=False):
|
||||
async def format_files(r, encode=False):
|
||||
if encode:
|
||||
return None
|
||||
decoded = decoder.MultipartDecoder(await r.content, r.mimetype)
|
||||
parts = _parse_multipart(await r.content, r.mimetype)
|
||||
dump = {}
|
||||
for part in decoded.parts:
|
||||
header = part.headers[b"Content-Disposition"].decode("utf-8")
|
||||
mimetype = part.headers.get(b"Content-Type", None)
|
||||
for part in parts:
|
||||
header = part.headers.get("Content-Disposition", "")
|
||||
mimetype = part.headers.get("Content-Type", None)
|
||||
filename = None
|
||||
formname = None
|
||||
|
||||
for section in [h.strip() for h in header.split(";")]:
|
||||
split = section.split("=")
|
||||
if len(split) > 1:
|
||||
key = split[0]
|
||||
value = split[1]
|
||||
|
||||
value = value[1:-1]
|
||||
|
||||
if key == "filename":
|
||||
@@ -66,13 +124,16 @@ async def format_files(r, encode=False):
|
||||
elif key == "name":
|
||||
formname = value
|
||||
|
||||
if formname is None:
|
||||
continue
|
||||
|
||||
if mimetype is None:
|
||||
dump[formname] = part.content
|
||||
dump[formname] = part.body
|
||||
else:
|
||||
dump[formname] = {
|
||||
"filename": filename,
|
||||
"content": part.content,
|
||||
"content-type": mimetype.decode("utf-8"),
|
||||
"content": part.body,
|
||||
"content-type": mimetype,
|
||||
}
|
||||
return dump
|
||||
|
||||
|
||||
+26
-5
@@ -6,8 +6,6 @@ from urllib.parse import parse_qs
|
||||
|
||||
import chardet
|
||||
import rfc3986
|
||||
from requests.cookies import RequestsCookieJar
|
||||
from requests.structures import CaseInsensitiveDict
|
||||
from starlette.requests import Request as StarletteRequest
|
||||
from starlette.requests import State
|
||||
from starlette.responses import (
|
||||
@@ -21,6 +19,29 @@ from .statics import DEFAULT_ENCODING
|
||||
from .status_codes import HTTP_301 # type: ignore[attr-defined]
|
||||
|
||||
|
||||
class CaseInsensitiveDict(dict):
|
||||
"""A case-insensitive dict for HTTP headers."""
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
super().__setitem__(key.lower(), value)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return super().__getitem__(key.lower())
|
||||
|
||||
def __contains__(self, key):
|
||||
return super().__contains__(key.lower())
|
||||
|
||||
def get(self, key, default=None):
|
||||
return super().get(key.lower(), default)
|
||||
|
||||
def update(self, other=None, **kwargs):
|
||||
if other:
|
||||
for key, value in other.items():
|
||||
self[key] = value
|
||||
for key, value in kwargs.items():
|
||||
self[key] = value
|
||||
|
||||
|
||||
class QueryDict(dict):
|
||||
def __init__(self, query_string):
|
||||
self.update(parse_qs(query_string))
|
||||
@@ -147,14 +168,14 @@ class Request:
|
||||
def cookies(self):
|
||||
"""The cookies sent in the Request, as a dictionary."""
|
||||
if self._cookies is None:
|
||||
cookies = RequestsCookieJar()
|
||||
cookies = {}
|
||||
cookie_header = self.headers.get("Cookie", "")
|
||||
|
||||
bc: SimpleCookie = SimpleCookie(cookie_header)
|
||||
for key, morsel in bc.items():
|
||||
cookies[key] = morsel.value
|
||||
|
||||
self._cookies = cookies.get_dict()
|
||||
self._cookies = cookies
|
||||
|
||||
return self._cookies
|
||||
|
||||
@@ -228,7 +249,7 @@ class Request:
|
||||
"""Returns ``True`` if the incoming Request accepts the given ``content_type``."""
|
||||
return content_type in self.headers.get("Accept", [])
|
||||
|
||||
async def media(self, format: t.Union[str, t.Callable] = None): # noqa: A001, A002
|
||||
async def media(self, format: t.Union[str, t.Callable] = None): # noqa: A002
|
||||
"""Renders incoming json/yaml/form data as Python objects. Must be awaited.
|
||||
|
||||
:param format: The name of the format being used.
|
||||
|
||||
@@ -1,106 +1,28 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
import codecs
|
||||
import os
|
||||
import sys
|
||||
from shutil import rmtree
|
||||
|
||||
from setuptools import Command, find_packages, setup
|
||||
from setuptools import find_packages, setup
|
||||
|
||||
here = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
with codecs.open(os.path.join(here, "README.md"), encoding="utf-8") as f:
|
||||
with open(os.path.join(here, "README.md"), encoding="utf-8") as f:
|
||||
long_description = "\n" + f.read()
|
||||
|
||||
about = {}
|
||||
|
||||
with open(os.path.join(here, "responder", "__version__.py")) as f:
|
||||
exec(f.read(), about)
|
||||
|
||||
if sys.argv[-1] == "publish":
|
||||
os.system("python setup.py sdist bdist_wheel upload")
|
||||
sys.exit()
|
||||
|
||||
required = [
|
||||
"a2wsgi",
|
||||
"apispec>=1.0.0b1",
|
||||
"chardet",
|
||||
"marshmallow",
|
||||
"requests",
|
||||
"requests-toolbelt",
|
||||
"python-multipart",
|
||||
"rfc3986",
|
||||
"servestatic",
|
||||
"starlette[full]>=0.40",
|
||||
"uvicorn[standard]",
|
||||
]
|
||||
|
||||
|
||||
# https://pypi.python.org/pypi/stdeb/0.8.5#quickstart-2-just-tell-me-the-fastest-way-to-make-a-deb
|
||||
class DebCommand(Command):
|
||||
"""Support for setup.py deb"""
|
||||
|
||||
description = "Build and publish the .deb package."
|
||||
user_options = []
|
||||
|
||||
@staticmethod
|
||||
def status(s):
|
||||
"""Prints things in bold."""
|
||||
print("\033[1m{0}\033[0m".format(s))
|
||||
|
||||
def initialize_options(self):
|
||||
pass
|
||||
|
||||
def finalize_options(self):
|
||||
pass
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
self.status("Removing previous builds…")
|
||||
rmtree(os.path.join(here, "deb_dist"))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
self.status("Creating debian manifest…")
|
||||
os.system(
|
||||
"python setup.py --command-packages=stdeb.command sdist_dsc -z artful --package3=pipenv --depends3=python3-virtualenv-clone"
|
||||
)
|
||||
self.status("Building .deb…")
|
||||
os.chdir("deb_dist/pipenv-{0}".format(about["__version__"]))
|
||||
os.system("dpkg-buildpackage -rfakeroot -uc -us")
|
||||
|
||||
|
||||
class UploadCommand(Command):
|
||||
"""Support setup.py publish."""
|
||||
|
||||
description = "Build and publish the package."
|
||||
user_options = []
|
||||
|
||||
@staticmethod
|
||||
def status(s):
|
||||
"""Prints things in bold."""
|
||||
print("\033[1m{0}\033[0m".format(s))
|
||||
|
||||
def initialize_options(self):
|
||||
pass
|
||||
|
||||
def finalize_options(self):
|
||||
pass
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
self.status("Removing previous builds…")
|
||||
rmtree(os.path.join(here, "dist"))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
self.status("Building Source distribution…")
|
||||
os.system("{0} setup.py sdist bdist_wheel".format(sys.executable))
|
||||
self.status("Uploading the package to PyPI via Twine…")
|
||||
os.system("twine upload dist/*")
|
||||
self.status("Pushing git tags…")
|
||||
os.system("git tag v{0}".format(about["__version__"]))
|
||||
os.system("git push --tags")
|
||||
sys.exit()
|
||||
|
||||
|
||||
setup(
|
||||
name="responder",
|
||||
version=about["__version__"],
|
||||
@@ -114,7 +36,6 @@ setup(
|
||||
package_data={},
|
||||
entry_points={"console_scripts": ["responder=responder.ext.cli:cli"]},
|
||||
python_requires=">=3.9",
|
||||
setup_requires=[],
|
||||
install_requires=required,
|
||||
extras_require={
|
||||
"cli": [
|
||||
@@ -141,7 +62,7 @@ setup(
|
||||
"sphinxext.opengraph",
|
||||
],
|
||||
"full": ["responder[cli-full,graphql,openapi]"],
|
||||
"graphql": ["graphene<3", "graphql-server-core>=1.2,<2"],
|
||||
"graphql": ["graphene>=3", "graphql-core>=3.1"],
|
||||
"openapi": ["apispec>=1.0.0"],
|
||||
"release": ["build", "twine"],
|
||||
"test": [
|
||||
@@ -172,5 +93,4 @@ setup(
|
||||
"Programming Language :: Python :: Implementation :: PyPy",
|
||||
"Topic :: Internet :: WWW/HTTP",
|
||||
],
|
||||
cmdclass={"upload": UploadCommand, "deb": DebCommand},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user