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:
2026-03-22 05:31:32 -04:00
parent 66fc7afbe4
commit 80715a12ac
6 changed files with 125 additions and 247 deletions
-1
View File
@@ -103,7 +103,6 @@ non_interactive = true
[tool.poe.tasks]
check = [
"lint",
"test",
]
+12 -23
View File
@@ -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)
+9 -121
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+4 -84
View File
@@ -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},
)