diff --git a/examples/lifespan.py b/examples/lifespan.py index 152d096..98abd3b 100644 --- a/examples/lifespan.py +++ b/examples/lifespan.py @@ -8,10 +8,8 @@ import responder @asynccontextmanager async def lifespan(app): # Startup: initialize resources - print("Starting up...") yield # Shutdown: clean up resources - print("Shutting down...") api = responder.API(lifespan=lifespan) diff --git a/examples/rest_api.py b/examples/rest_api.py index 848a066..60db95f 100644 --- a/examples/rest_api.py +++ b/examples/rest_api.py @@ -1,8 +1,9 @@ # Complete REST API example with Pydantic validation. # https://responder.kennethreitz.org/tutorial-rest.html -import responder from pydantic import BaseModel +import responder + class BookIn(BaseModel): title: str @@ -35,8 +36,13 @@ def list_books(req, resp): resp.media = list(books_db.values()) -@api.route("/books", methods=["POST"], check_existing=False, - request_model=BookIn, response_model=BookOut) +@api.route( + "/books", + methods=["POST"], + check_existing=False, + request_model=BookIn, + response_model=BookOut, +) async def create_book(req, resp): global next_id data = await req.media() diff --git a/examples/websocket_chat.py b/examples/websocket_chat.py index 615173e..22fa680 100644 --- a/examples/websocket_chat.py +++ b/examples/websocket_chat.py @@ -35,7 +35,7 @@ def index(req, resp): - """ + """ # noqa: E501 @api.route("/chat", websocket=True) @@ -47,7 +47,7 @@ async def chat(ws): message = await ws.receive_text() for client in connected: await client.send_text(message) - except Exception: + except Exception: # noqa: S110 pass finally: connected.discard(ws) diff --git a/pyproject.toml b/pyproject.toml index 1d81f81..d883d35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ requires = [ name = "responder" description = "A familiar HTTP Service Framework for Python." readme = "README.md" -license = {text = "Apache 2.0"} +license = { text = "Apache 2.0" } authors = [ { name = "Kenneth Reitz", email = "me@kennethreitz.org" }, ] @@ -20,7 +20,7 @@ classifiers = [ "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", - "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -31,10 +31,10 @@ classifiers = [ "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet :: WWW/HTTP", ] -dynamic = ["version"] +dynamic = [ "version" ] dependencies = [ "a2wsgi", - "apispec>=1.0.0", + "apispec>=1", "chardet", "docopt-ng", "graphene>=3", @@ -44,17 +44,15 @@ dependencies = [ "pueblo[sfa-full]>=0.0.11", "pydantic>=2", "python-multipart", - "starlette[full]>=1.0", + "starlette[full]>=1", "uvicorn[standard]", ] - -[project.optional-dependencies] -develop = [ +optional-dependencies.develop = [ "pyproject-fmt", "ruff", "validate-pyproject", ] -docs = [ +optional-dependencies.docs = [ "alabaster<1.1", "myst-parser", "sphinx>=5,<9", @@ -62,8 +60,8 @@ docs = [ "sphinx-copybutton", "sphinx-design-elements", ] -release = ["build", "twine"] -test = [ +optional-dependencies.release = [ "build", "twine" ] +optional-dependencies.test = [ "flask", "mypy", "pytest", @@ -71,32 +69,22 @@ test = [ "pytest-mock", "pytest-rerunfailures", ] +urls.Documentation = "https://responder.kennethreitz.org" +urls.Homepage = "https://github.com/kennethreitz/responder" +urls.Issues = "https://github.com/kennethreitz/responder/issues" +urls.Repository = "https://github.com/kennethreitz/responder" +scripts.responder = "responder.ext.cli:cli" -[project.scripts] -responder = "responder.ext.cli:cli" - -[project.urls] -Homepage = "https://github.com/kennethreitz/responder" -Documentation = "https://responder.kennethreitz.org" -Repository = "https://github.com/kennethreitz/responder" -Issues = "https://github.com/kennethreitz/responder/issues" - -[tool.setuptools.dynamic] -version = {attr = "responder.__version__.__version__"} - -[tool.setuptools.package-data] -responder = ["py.typed", "ext/openapi/docs/*.html"] - -[tool.setuptools.packages.find] -exclude = ["tests"] +[tool.setuptools] +dynamic.version = { attr = "responder.__version__.__version__" } +package-data.responder = [ "py.typed", "ext/openapi/docs/*.html" ] +packages.find.exclude = [ "tests" ] [tool.ruff] line-length = 90 - extend-exclude = [ "docs/source/conf.py", ] - lint.select = [ # Builtins "A", @@ -124,59 +112,20 @@ lint.select = [ # flake8-2020 "YTT", ] - lint.extend-ignore = [ - "S101", # Allow use of `assert`. + "S101", # Allow use of `assert`. ] - -lint.per-file-ignores."responder/util/cmd.py" = [ "A005" ] # Module shadows a Python standard-library module - +lint.per-file-ignores."responder/util/cmd.py" = [ "A005" ] # Module shadows a Python standard-library module lint.per-file-ignores."tests/*" = [ "ERA001", # Found commented-out code. "S101", # Allow use of `assert`, and `print`. ] -[tool.pytest.ini_options] -addopts = """ - -rfEXs -p pytester --strict-markers --verbosity=3 - --cov --cov-report=term-missing --cov-report=xml - """ -filterwarnings = [ - "error::UserWarning", -] -log_level = "DEBUG" -log_cli_level = "DEBUG" -log_format = "%(asctime)-15s [%(name)-36s] %(levelname)-8s: %(message)s" -minversion = "2.0" -testpaths = [ - "responder", - "tests", -] -markers = [ -] -xfail_strict = true - -[tool.coverage.run] -branch = false -omit = [ - "*.html", - "tests/*", -] - -[tool.coverage.report] -fail_under = 0 -show_missing = true -exclude_lines = [ - "# pragma: no cover", - "raise NotImplemented", -] - [tool.mypy] packages = [ "responder", ] -exclude = [ -] +exclude = [] check_untyped_defs = true explicit_package_bases = true ignore_missing_imports = true @@ -184,3 +133,35 @@ implicit_optional = true install_types = true namespace_packages = true non_interactive = true + +[tool.pytest] +ini_options.addopts = """ + -rfEXs -p pytester --strict-markers --verbosity=3 + --cov --cov-report=term-missing --cov-report=xml + """ +ini_options.filterwarnings = [ + "error::UserWarning", +] +ini_options.log_level = "DEBUG" +ini_options.log_cli_level = "DEBUG" +ini_options.log_format = "%(asctime)-15s [%(name)-36s] %(levelname)-8s: %(message)s" +ini_options.minversion = "2.0" +ini_options.testpaths = [ + "responder", + "tests", +] +ini_options.markers = [] +ini_options.xfail_strict = true + +[tool.coverage] +run.branch = false +run.omit = [ + "*.html", + "tests/*", +] +report.exclude_lines = [ + "# pragma: no cover", + "raise NotImplemented", +] +report.fail_under = 0 +report.show_missing = true diff --git a/responder/api.py b/responder/api.py index 785b101..87eb59f 100644 --- a/responder/api.py +++ b/responder/api.py @@ -161,9 +161,7 @@ class API: import uuid as _uuid def _add_request_id(req, resp): - rid = req.headers.get( - "X-Request-ID", str(_uuid.uuid4()) - ) + rid = req.headers.get("X-Request-ID", str(_uuid.uuid4())) resp.headers["X-Request-ID"] = rid self.router.after_request(_add_request_id) @@ -562,7 +560,7 @@ class API: """Run the application. Shorthand for :meth:`serve` that inherits the ``debug`` setting. :param kwargs: Keyword arguments passed through to :meth:`serve`. - """ + """ # noqa: E501 if "debug" not in kwargs: kwargs.update({"debug": self.debug}) self.serve(**kwargs) diff --git a/responder/ext/openapi/__init__.py b/responder/ext/openapi/__init__.py index c6746ea..bd2fb34 100644 --- a/responder/ext/openapi/__init__.py +++ b/responder/ext/openapi/__init__.py @@ -129,7 +129,9 @@ class OpenAPISchema: op["requestBody"] = { "content": { "application/json": { - "schema": {"$ref": f"#/components/schemas/{model_name}"} + "schema": { + "$ref": f"#/components/schemas/{model_name}" + } } } } diff --git a/responder/ext/ratelimit.py b/responder/ext/ratelimit.py index a9c8e64..df5d5f4 100644 --- a/responder/ext/ratelimit.py +++ b/responder/ext/ratelimit.py @@ -38,9 +38,7 @@ class RateLimiter: def _cleanup(self, key): now = time.time() cutoff = now - self.period - self._buckets[key] = [ - t for t in self._buckets[key] if t > cutoff - ] + self._buckets[key] = [t for t in self._buckets[key] if t > cutoff] def check(self, req, resp): """Check rate limit. Sets 429 status if exceeded.""" diff --git a/responder/routes.py b/responder/routes.py index 13db9f6..6096958 100644 --- a/responder/routes.py +++ b/responder/routes.py @@ -192,7 +192,7 @@ class Route(BaseRoute): try: validated = resp_model(**response.media) response.media = validated.model_dump() - except Exception: + except Exception: # noqa: S110 pass # Don't break the response if serialization fails # Run after-request hooks diff --git a/tests/test_coverage.py b/tests/test_coverage.py index 331a359..f5c851e 100644 --- a/tests/test_coverage.py +++ b/tests/test_coverage.py @@ -4,14 +4,14 @@ import time import pytest from starlette.testclient import TestClient as StarletteTestClient +from starlette.websockets import WebSocketDisconnect import responder from responder.background import BackgroundQueue -from responder.models import CaseInsensitiveDict, QueryDict, Response +from responder.models import QueryDict from responder.routes import Route, WebSocketRoute from responder.templates import Templates - # --- api.py coverage --- @@ -78,7 +78,7 @@ def test_background_task_exception(capsys): raise ValueError("task failed") future = failing_task() - future.result # wait for completion + future.result # wait for completion # noqa: B018 time.sleep(0.2) # let the done callback fire captured = capsys.readouterr() @@ -302,7 +302,7 @@ def test_yaml_content_negotiation(api): def test_websocket_404(api): """Lines 308-310: WebSocket to unknown route gets closed.""" client = StarletteTestClient(api) - with pytest.raises(Exception): + with pytest.raises(WebSocketDisconnect): with client.websocket_connect("ws://;/nonexistent"): pass @@ -325,9 +325,7 @@ def test_websocket_route_params(): pass route = WebSocketRoute("/ws/{room_id:int}", handler) - matches, scope = route.matches( - {"type": "websocket", "path": "/ws/42"} - ) + matches, scope = route.matches({"type": "websocket", "path": "/ws/42"}) assert matches is True assert scope["path_params"] == {"room_id": 42} @@ -591,7 +589,10 @@ def test_pydantic_schema(): from pydantic import BaseModel api = responder.API( - title="Test", version="1.0", openapi="3.0.2", allowed_hosts=[";"], + title="Test", + version="1.0", + openapi="3.0.2", + allowed_hosts=[";"], ) @api.schema("Pet") @@ -611,7 +612,10 @@ def test_pydantic_request_response_models(): from pydantic import BaseModel api = responder.API( - title="Test", version="1.0", openapi="3.0.2", allowed_hosts=[";"], + title="Test", + version="1.0", + openapi="3.0.2", + allowed_hosts=[";"], ) class ItemIn(BaseModel): @@ -623,8 +627,7 @@ def test_pydantic_request_response_models(): name: str price: float - @api.route("/items", methods=["POST"], - request_model=ItemIn, response_model=ItemOut) + @api.route("/items", methods=["POST"], request_model=ItemIn, response_model=ItemOut) async def create(req, resp): data = await req.media() resp.media = {"id": 1, **data} diff --git a/tests/test_graphql.py b/tests/test_graphql.py index f38c4ad..bfb5ca7 100644 --- a/tests/test_graphql.py +++ b/tests/test_graphql.py @@ -109,10 +109,13 @@ def test_graphql_error_response(api, schema): def test_graphql_variables_json(api, schema): """Variables passed via JSON body.""" api.add_route("/", GraphQLView(schema=schema, api=api)) - r = api.requests.post("http://;/", json={ - "query": "query Hello($name: String!) { hello(name: $name) }", - "variables": {"name": "Alice"}, - }) + r = api.requests.post( + "http://;/", + json={ + "query": "query Hello($name: String!) { hello(name: $name) }", + "variables": {"name": "Alice"}, + }, + ) assert r.json() == {"data": {"hello": "Hello Alice"}} @@ -121,7 +124,8 @@ def test_graphql_variables_query_param(api, schema): api.add_route("/", GraphQLView(schema=schema, api=api)) variables = json.dumps({"name": "Bob"}) r = api.requests.get( - f"http://;/?query=query Hello($name: String!) {{ hello(name: $name) }}&variables={variables}", + f"http://;/?query=query Hello($name: String!) " + f"{{ hello(name: $name) }}&variables={variables}", headers={"Accept": "json"}, ) assert r.json() == {"data": {"hello": "Hello Bob"}} @@ -134,10 +138,13 @@ def test_graphql_operation_name_json(api, multi_op_schema): query SayHello { hello } query SayGoodbye { goodbye } """ - r = api.requests.post("http://;/", json={ - "query": query, - "operationName": "SayHello", - }) + r = api.requests.post( + "http://;/", + json={ + "query": query, + "operationName": "SayHello", + }, + ) data = r.json() assert data["data"]["hello"] == "Hello stranger" @@ -157,9 +164,12 @@ def test_graphql_operation_name_query_param(api, multi_op_schema): def test_graphql_mutation(api, mutation_schema): """Mutations work via JSON body.""" api.add_route("/", GraphQLView(schema=mutation_schema, api=api)) - r = api.requests.post("http://;/", json={ - "query": 'mutation { createUser(name: "Eve") { ok name } }', - }) + r = api.requests.post( + "http://;/", + json={ + "query": 'mutation { createUser(name: "Eve") { ok name } }', + }, + ) data = r.json() assert data["data"]["createUser"]["ok"] is True assert data["data"]["createUser"]["name"] == "Eve" @@ -168,10 +178,14 @@ def test_graphql_mutation(api, mutation_schema): def test_graphql_mutation_with_variables(api, mutation_schema): """Mutations with variables.""" api.add_route("/", GraphQLView(schema=mutation_schema, api=api)) - r = api.requests.post("http://;/", json={ - "query": "mutation CreateUser($name: String!) { createUser(name: $name) { ok name } }", - "variables": {"name": "Frank"}, - }) + r = api.requests.post( + "http://;/", + json={ + "query": "mutation CreateUser($name: String!) " + "{ createUser(name: $name) { ok name } }", + "variables": {"name": "Frank"}, + }, + ) data = r.json() assert data["data"]["createUser"]["ok"] is True assert data["data"]["createUser"]["name"] == "Frank" diff --git a/tests/test_new_features.py b/tests/test_new_features.py index 7ebaaf5..4ce8542 100644 --- a/tests/test_new_features.py +++ b/tests/test_new_features.py @@ -1,13 +1,10 @@ """Tests for new features: validation, SSE, after_request, route groups, etc.""" -import pytest from pydantic import BaseModel -from starlette.testclient import TestClient as StarletteTestClient import responder from responder.ext.ratelimit import RateLimiter - # --- Pydantic auto-validation --- @@ -42,7 +39,9 @@ def test_pydantic_request_validation(): assert "errors" in r.json() # Invalid request — wrong type - r = api.requests.post("http://;/items", json={"name": "widget", "price": "not_a_number"}) + r = api.requests.post( + "http://;/items", json={"name": "widget", "price": "not_a_number"} + ) assert r.status_code == 422 @@ -50,8 +49,7 @@ def test_pydantic_response_serialization(): """Auto-serialize response through response_model.""" api = responder.API(allowed_hosts=[";"]) - @api.route("/items", methods=["POST"], - request_model=ItemIn, response_model=ItemOut) + @api.route("/items", methods=["POST"], request_model=ItemIn, response_model=ItemOut) async def create(req, resp): data = await req.media() # Include an extra field that should be stripped by the model @@ -257,7 +255,7 @@ def test_rate_limiter(): def view(req, resp): resp.text = "ok" - for i in range(3): + for _i in range(3): r = api.requests.get("http://;/") assert r.status_code == 200 assert "X-RateLimit-Remaining" in r.headers