Repository: encode/starlette

Issue:
Development install script
Installing Starlette for development is manual and some constraints or platform-specific cases may not be handled properly.

We'd need an install script to automate the process. After running it, contributors should be able to start working on Starlette right away.

Some guidelines listed by @tomchristie in #292:

- Ensure python3 meets the minimum required version.
- Create a virtualenv in ./venv, using -p python3.
- Include the script in the contributing notes.
- Include a note, that the instructions are currently suited to mac and linux, but that we'd happily take pull requests that either updated the instructions appropriately (I don't know windows users need to do in order to run shell scripts, if at all?) or to make the scripts more cross compatible. (If they were all python based for example.)

Discussion:

Patch:
diff --git a/.travis.yml b/.travis.yml
index 37e4b70..060b3ee 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -13,7 +13,7 @@ services:
     - postgresql
 
 install:
-    - pip install -r requirements.txt
+    - pip install -U -r requirements.txt
 
 before_script:
     - psql -c 'create database starlette;' -U postgres
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5cd3e51..d5bfdb2 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -2,7 +2,11 @@
 
 The Starlette team happily welcomes contributions. This document will help you get ready to contribute to Starlette!
 
-> **NOTE**: before writing any code, please first [open an issue] and discuss your ideas with the maintainers.
+This guide references utility shell scripts that were written to automate common tasks. You can find them in the [scripts](./scripts) directory.
+
+These scripts are currently suited to **Linux** and **macOS**, but we would happily take PRs to help us make them more cross-compatible or update the instructions.
+
+> **NOTE**: before writing any code, please first consider [opening an issue][issues] to discuss your ideas with the maintainers.
 
 ## Setting up the repository
 
@@ -12,20 +16,21 @@ The Starlette team happily welcomes contributions. This document will help you g
 4. Create a branch for your work, e.g. `git checkout -b fix-some-bug`.
 5. Remember to include tests and documentation updates if applicable.
 6. Once ready, push to your remote: `git push origin fix-some-bug`.
- 7. [Open a PR] on the main repo.
+ 7. [Open a PR][pr] on the main repo.
 
 ## Install
 
-- Make sure you are using a compatible Python version, as specified in our README.
-- Create a virtual environment and install project dependencies:
+Use the `install` script to install project dependencies in a virtual environment.
 
 ```bash
-python -m venv venv
-source venv/bin/activate
-pip install -r requirements.txt
+./scripts/install
 ```
 
-- Install an editable version of the project: `pip install -e .` (see [Editable Installs] in the `pip` docs).
+Your Python version will be verified and the virtual environment created using the `python3` executable. To use another executable, use the `-p` option, e.g.:
+
+```bash
+./scripts/install -p py
+```
 
 ## Running the tests
 
@@ -67,10 +72,9 @@ For your information, the docs site configuration is located in the `mkdocs.yml`
 
 Please refer to the [MkDocs docs][MkDocs] for more usage information, including how to add new pages.
 
-[open an issue]: https://github.com/encode/starlette/issues/new
-[Open a PR]: https://github.com/encode/starlette/compare
+[issues]: https://github.com/encode/starlette/issues/new
+[pr]: https://github.com/encode/starlette/compare
 [pytest]: https://docs.pytest.org
 [pytest-cov]: https://github.com/pytest-dev/pytest-cov
 [Black]: https://www.google.com/search?client=safari&rls=en&q=github+black&ie=UTF-8&oe=UTF-8
 [MkDocs]: https://www.mkdocs.org
-[Editable Installs]: https://pip.pypa.io/en/stable/reference/pip_install/#editable-installs
diff --git a/docs/authentication.md b/docs/authentication.md
index 7d3f293..e0e2185 100644
--- a/docs/authentication.md
+++ b/docs/authentication.md
@@ -131,9 +131,20 @@ async def dashboard(request):
     ...
 ```
 
+For class-based endpoints, you should wrap the decorator
+around a method on the class.
+
+```python
+@app.route("/dashboard")
+class Dashboard(HTTPEndpoint):
+    @requires("authenticated")
+    async def get(self, request):
+        ...
+```
+
 ## Custom authentication error responses
 
-You can customise the error response sent when a `AuthenticationError` is 
+You can customise the error response sent when a `AuthenticationError` is
 raised by an auth backend:
 
 ```python
@@ -141,4 +152,4 @@ def on_auth_error(request: Request, exc: Exception):
     return JSONResponse({"error": str(exc)}, status_code=401)
 
 app.add_middleware(AuthenticationMiddleware, backend=BasicAuthBackend(), on_error=on_auth_error)
-```
\ No newline at end of file
+```
diff --git a/docs/config.md b/docs/config.md
index ed8b07c..baa9e38 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -48,14 +48,15 @@ If none of those match, then `config(...)` will raise an error.
 ## Secrets
 
 For sensitive keys, the `Secret` class is useful, since it helps minimize
-occasions where the value it holds could leak out into tracebacks or logging.
+occasions where the value it holds could leak out into tracebacks or
+other code introspection.
 
 To get the value of a `Secret` instance, you must explicitly cast it to a string.
 You should only do this at the point at which the value is used.
 
 ```python
 >>> from myproject import settings
->>> print(settings.SECRET_KEY)
+>>> settings.SECRET_KEY
 Secret('**********')
 >>> str(settings.SECRET_KEY)
 '98n349$%8b8-7yjn0n8y93T$23r'
@@ -66,9 +67,9 @@ in their representations.
 
 ```python
 >>> from myproject import settings
->>> print(settings.DATABASE_URL)
+>>> settings.DATABASE_URL
 DatabaseURL('postgresql://admin:**********@192.168.0.8/my-application')
->>> str(settings.SECRET_KEY)
+>>> str(settings.DATABASE_URL)
 'postgresql://admin:Fkjh348htGee4t3@192.168.0.8/my-application'
 ```
 
diff --git a/docs/third-party-packages.md b/docs/third-party-packages.md
index 99f6abf..bc89b72 100644
--- a/docs/third-party-packages.md
+++ b/docs/third-party-packages.md
@@ -25,6 +25,42 @@ That library aims to bring a layer on top of Starlette framework to provide usef
 * **Starlette ASGI** objects like `Request`, `Response`, `Session` and so on are defined as components and ready to be injected in your endpoints.
 
 
+### webargs-starlette
+
+
+Link: <a href="https://github.com/sloria/webargs-starlette" target="_blank">https://github.com/sloria/webargs-starlette</a>
+
+Declarative request parsing and validation for Starlette, built on top
+of [webargs](https://github.com/marshmallow-code/webargs).
+
+Allows you to parse querystring, JSON, form, headers, and cookies using
+type annotations.
+
+```python
+import uvicorn
+from starlette.applications import Starlette
+from starlette.responses import JSONResponse
+from webargs_starlette import use_annotations
+
+app = Starlette()
+
+
+@app.route("/")
+@use_annotations(locations=("query",))
+async def index(request, name: str = "World"):
+    return JSONResponse({"Hello": name})
+
+
+if __name__ == "__main__":
+    uvicorn.run(app, port=5000)
+
+# curl 'http://localhost:5000/'
+# {"Hello": "World"}
+# curl 'http://localhost:5000/?name=Ada'
+# {"Hello": "Ada"}
+```
+
+
 ## Frameworks
 
 ### Responder
diff --git a/requirements.txt b/requirements.txt
index aae4715..0d72fb7 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -12,6 +12,7 @@ ujson
 asyncpg
 
 # Testing
+autoflake
 black
 codecov
 isort
diff --git a/scripts/README.md b/scripts/README.md
index 16e3157..8401542 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -1,5 +1,6 @@
 # Development Scripts
 
+* `scripts/install` - Install dependencies in a virtual environment.
 * `scripts/test` - Run the test suite.
 * `scripts/lint` - Run the code linting.
 * `scripts/publish` - Publish the latest version to PyPI.
diff --git a/scripts/install b/scripts/install
new file mode 100755
index 0000000..65246ce
--- /dev/null
+++ b/scripts/install
@@ -0,0 +1,30 @@
+#!/bin/sh -e
+
+# Use the Python executable provided from the `-p` option, or a default.
+[[ $1 = "-p" ]] && PYTHON=$2 || PYTHON="python3"
+
+MIN_VERSION="(3,6)"
+VERSION_OK=`"$PYTHON" -c "import sys; print(sys.version_info[0:2] >= $MIN_VERSION and '1' or '');"`
+
+if [[ -z "$VERSION_OK" ]] ; then
+    PYTHON_VERSION=`"$PYTHON" -c "import sys; print('%s.%s' % sys.version_info[0:2]);"`
+    DISP_MIN_VERSION=`"$PYTHON" -c "print('%s.%s' % $MIN_VERSION)"`
+    echo "ERROR: Python $PYTHON_VERSION detected, but $DISP_MIN_VERSION+ is required."
+    echo "Please upgrade your Python distribution to install Starlette."
+    exit 1
+fi
+
+REQUIREMENTS="requirements.txt"
+VENV="venv"
+PIP="$VENV/bin/pip"
+
+set -x
+"$PYTHON" -m venv "$VENV"
+"$PIP" install -r "$REQUIREMENTS"
+"$PIP" install -e .
+set +x
+
+echo "\nAll set! Activate your virtual environment using the command below."
+echo ". $VENV/bin/activate"
+
+exit 0
diff --git a/scripts/lint b/scripts/lint
index b21618d..a083019 100755
--- a/scripts/lint
+++ b/scripts/lint
@@ -7,5 +7,6 @@ fi
 
 set -x
 
+${PREFIX}autoflake --in-place --recursive starlette tests
 ${PREFIX}black starlette tests
 ${PREFIX}isort --multi-line=3 --trailing-comma --force-grid-wrap=0 --combine-as --line-width 88 --recursive --apply starlette tests
diff --git a/setup.py b/setup.py
index 13189ad..ab0c0b8 100644
--- a/setup.py
+++ b/setup.py
@@ -11,15 +11,16 @@ def get_version(package):
     """
     Return package version as listed in `__version__` in `init.py`.
     """
-    init_py = open(os.path.join(package, '__init__.py')).read()
-    return re.search("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1)
+    with open(os.path.join(package, '__init__.py')) as f:
+        return re.search("__version__ = ['\"]([^'\"]+)['\"]", f.read()).group(1)
 
 
 def get_long_description():
     """
     Return the README.
     """
-    return open('README.md', 'r', encoding="utf8").read()
+    with open('README.md', encoding="utf8") as f:
+        return f.read()
 
 
 def get_packages(package):
diff --git a/starlette/authentication.py b/starlette/authentication.py
index f2d16ca..9aa4ca5 100644
--- a/starlette/authentication.py
+++ b/starlette/authentication.py
@@ -1,5 +1,6 @@
 import asyncio
 import functools
+import inspect
 import typing
 
 from starlette.exceptions import HTTPException
@@ -22,25 +23,39 @@ def requires(
     scopes_list = [scopes] if isinstance(scopes, str) else list(scopes)
 
     def decorator(func: typing.Callable) -> typing.Callable:
+        sig = inspect.signature(func)
+        for idx, parameter in enumerate(sig.parameters.values()):
+            if parameter.name == "request":
+                break
+        else:
+            raise Exception('No "request" argument on function "%s"' % func)
+
         if asyncio.iscoroutinefunction(func):
 
             @functools.wraps(func)
-            async def wrapper(request: Request) -> Response:
+            async def wrapper(*args: typing.Any, **kwargs: typing.Any) -> Response:
+                request = kwargs.get("request", args[idx])
+                assert isinstance(request, Request)
+
                 if not has_required_scope(request, scopes_list):
                     if redirect is not None:
                         return RedirectResponse(url=request.url_for(redirect))
                     raise HTTPException(status_code=status_code)
-                return await func(request)
+                return await func(*args, **kwargs)
 
             return wrapper
 
         @functools.wraps(func)
-        def sync_wrapper(request: Request) -> Response:
+        def sync_wrapper(*args: typing.Any, **kwargs: typing.Any) -> Response:
+            # Support either `func(request)` or `func(self, request)`
+            request = kwargs.get("request", args[idx])
+            assert isinstance(request, Request)
+
             if not has_required_scope(request, scopes_list):
                 if redirect is not None:
                     return RedirectResponse(url=request.url_for(redirect))
                 raise HTTPException(status_code=status_code)
-            return func(request)
+            return func(*args, **kwargs)
 
         return sync_wrapper
 
diff --git a/starlette/graphql.py b/starlette/graphql.py
index 3d95b1d..690aab5 100644
--- a/starlette/graphql.py
+++ b/starlette/graphql.py
@@ -36,7 +36,7 @@ class GraphQLApp:
         await response(receive, send)
 
     async def handle_graphql(self, request: Request) -> Response:
-        if request.method == "GET":
+        if request.method in ("GET", "HEAD"):
             if "text/html" in request.headers.get("Accept", ""):
                 return await self.handle_graphiql(request)
 
@@ -77,7 +77,9 @@ class GraphQLApp:
         background = BackgroundTasks()
         context = {"request": request, "background": background}
 
-        result = await self.execute(query, variables=variables, context=context)
+        result = await self.execute(
+            query, variables=variables, context=context, operation_name=operation_name
+        )
         error_data = (
             [format_graphql_error(err) for err in result.errors]
             if result.errors
diff --git a/starlette/middleware/lifespan.py b/starlette/middleware/lifespan.py
index 8c0d5d4..3c7faae 100644
--- a/starlette/middleware/lifespan.py
+++ b/starlette/middleware/lifespan.py
@@ -1,8 +1,5 @@
 import asyncio
-import logging
-import traceback
 import typing
-from types import TracebackType
 
 from starlette.types import ASGIApp, ASGIInstance, Message, Receive, Scope, Send
 
diff --git a/starlette/requests.py b/starlette/requests.py
index 2132151..49671e7 100644
--- a/starlette/requests.py
+++ b/starlette/requests.py
@@ -132,6 +132,7 @@ class Request(HTTPConnection):
     async def stream(self) -> typing.AsyncGenerator[bytes, None]:
         if hasattr(self, "_body"):
             yield self._body
+            yield b""
             return
 
         if self._stream_consumed:
diff --git a/starlette/routing.py b/starlette/routing.py
index 7ea80e1..9ea092b 100644
--- a/starlette/routing.py
+++ b/starlette/routing.py
@@ -20,8 +20,6 @@ class NoMatchFound(Exception):
     if no matching route exists.
     """
 
-    pass
-
 
 class Match(Enum):
     NONE = 0
@@ -164,7 +162,13 @@ class Route(BaseRoute):
             # Endpoint is a class. Treat it as ASGI.
             self.app = endpoint
 
-        self.methods = methods
+        if methods is None:
+            self.methods = None
+        else:
+            self.methods = set([method.upper() for method in methods])
+            if "GET" in self.methods:
+                self.methods |= set(["HEAD"])
+
         self.path_regex, self.path_format, self.param_convertors = self.compile_path(
             path
         )
diff --git a/starlette/websockets.py b/starlette/websockets.py
index 9b9d976..6240f98 100644
--- a/starlette/websockets.py
+++ b/starlette/websockets.py
@@ -1,7 +1,6 @@
 import enum
 import json
 import typing
-from collections.abc import Mapping
 
 from starlette.datastructures import URL, Headers, QueryParams
 from starlette.requests import HTTPConnection
@@ -107,7 +106,7 @@ class WebSocket(HTTPConnection):
         else:
             raise RuntimeError('Cannot call "send" once a close message has been sent.')
 
-    async def accept(self, subprotocol: typing.List[str] = None) -> None:
+    async def accept(self, subprotocol: str = None) -> None:
         if self.client_state == WebSocketState.CONNECTING:
             # If we haven't yet seen the 'connect' message, then wait for it first.
             await self.receive()


Test patch:
diff --git a/starlette/testclient.py b/starlette/testclient.py
index 53afb45..01b843a 100644
--- a/starlette/testclient.py
+++ b/starlette/testclient.py
@@ -6,7 +6,6 @@ import queue
 import threading
 import types
 import typing
-from types import TracebackType
 from urllib.parse import unquote, urljoin, urlparse
 
 import requests
@@ -174,7 +173,8 @@ class _ASGIAdapter(requests.adapters.HTTPAdapter):
                 ), 'Received "http.response.body" after response completed.'
                 body = message.get("body", b"")
                 more_body = message.get("more_body", False)
-                raw_kwargs["body"].write(body)
+                if request.method != "HEAD":
+                    raw_kwargs["body"].write(body)
                 if not more_body:
                     raw_kwargs["body"].seek(0)
                     response_complete = True
diff --git a/tests/test_applications.py b/tests/test_applications.py
index 9a79b3a..925bbd4 100644
--- a/tests/test_applications.py
+++ b/tests/test_applications.py
@@ -100,6 +100,10 @@ def test_func_route():
     assert response.status_code == 200
     assert response.text == "Hello, world!"
 
+    response = client.head("/func")
+    assert response.status_code == 200
+    assert response.text == ""
+
 
 def test_async_route():
     response = client.get("/async")
diff --git a/tests/test_authentication.py b/tests/test_authentication.py
index da22fc8..038a097 100644
--- a/tests/test_authentication.py
+++ b/tests/test_authentication.py
@@ -1,6 +1,8 @@
 import base64
 import binascii
 
+import pytest
+
 from starlette.applications import Starlette
 from starlette.authentication import (
     AuthCredentials,
@@ -9,6 +11,7 @@ from starlette.authentication import (
     SimpleUser,
     requires,
 )
+from starlette.endpoints import HTTPEndpoint
 from starlette.middleware.authentication import AuthenticationMiddleware
 from starlette.requests import Request
 from starlette.responses import JSONResponse
@@ -69,7 +72,7 @@ async def admin(request):
 
 @app.route("/dashboard/sync")
 @requires("authenticated")
-def dashboard(request):
+def dashboard_sync(request):
     return JSONResponse(
         {
             "authenticated": request.user.is_authenticated,
@@ -78,6 +81,18 @@ def dashboard(request):
     )
 
 
+@app.route("/dashboard/class")
+class Dashboard(HTTPEndpoint):
+    @requires("authenticated")
+    def get(self, request):
+        return JSONResponse(
+            {
+                "authenticated": request.user.is_authenticated,
+                "user": request.user.display_name,
+            }
+        )
+
+
 @app.route("/admin/sync")
 @requires("authenticated", redirect="homepage")
 def admin(request):
@@ -89,6 +104,14 @@ def admin(request):
     )
 
 
+def test_invalid_decorator_usage():
+    with pytest.raises(Exception):
+
+        @requires("authenticated")
+        def foo():
+            pass  # pragma: nocover
+
+
 def test_user_interface():
     with TestClient(app) as client:
         response = client.get("/")
@@ -116,6 +139,13 @@ def test_authentication_required():
         assert response.status_code == 200
         assert response.json() == {"authenticated": True, "user": "tomchristie"}
 
+        response = client.get("/dashboard/class")
+        assert response.status_code == 403
+
+        response = client.get("/dashboard/class", auth=("tomchristie", "example"))
+        assert response.status_code == 200
+        assert response.json() == {"authenticated": True, "user": "tomchristie"}
+
         response = client.get("/dashboard", headers={"Authorization": "basic foobar"})
         assert response.status_code == 400
         assert response.text == "Invalid basic auth credentials"
diff --git a/tests/test_formparsers.py b/tests/test_formparsers.py
index 874cfce..d8bae23 100644
--- a/tests/test_formparsers.py
+++ b/tests/test_formparsers.py
@@ -33,6 +33,22 @@ def app(scope):
     return asgi
 
 
+def app_read_body(scope):
+    async def asgi(receive, send):
+        request = Request(scope, receive)
+        # Read bytes, to force request.stream() to return the already parsed body
+        body_bytes = await request.body()
+        data = await request.form()
+        output = {}
+        for key, value in data.items():
+            output[key] = value
+        await request.close()
+        response = JSONResponse(output)
+        await response(receive, send)
+
+    return asgi
+
+
 def test_multipart_request_data(tmpdir):
     client = TestClient(app)
     response = client.post("/", data={"some": "data"}, files=FORCE_MULTIPART)
@@ -45,10 +61,11 @@ def test_multipart_request_files(tmpdir):
         file.write(b"<file content>")
 
     client = TestClient(app)
-    response = client.post("/", files={"test": open(path, "rb")})
-    assert response.json() == {
-        "test": {"filename": "test.txt", "content": "<file content>"}
-    }
+    with open(path, "rb") as f:
+        response = client.post("/", files={"test": f})
+        assert response.json() == {
+            "test": {"filename": "test.txt", "content": "<file content>"}
+        }
 
 
 def test_multipart_request_multiple_files(tmpdir):
@@ -61,13 +78,12 @@ def test_multipart_request_multiple_files(tmpdir):
         file.write(b"<file2 content>")
 
     client = TestClient(app)
-    response = client.post(
-        "/", files={"test1": open(path1, "rb"), "test2": open(path2, "rb")}
-    )
-    assert response.json() == {
-        "test1": {"filename": "test1.txt", "content": "<file1 content>"},
-        "test2": {"filename": "test2.txt", "content": "<file2 content>"},
-    }
+    with open(path1, "rb") as f1, open(path2, "rb") as f2:
+        response = client.post("/", files={"test1": f1, "test2": f2})
+        assert response.json() == {
+            "test1": {"filename": "test1.txt", "content": "<file1 content>"},
+            "test2": {"filename": "test2.txt", "content": "<file2 content>"},
+        }
 
 
 def test_multipart_request_mixed_files_and_data(tmpdir):
@@ -111,3 +127,17 @@ def test_no_request_data(tmpdir):
     client = TestClient(app)
     response = client.post("/")
     assert response.json() == {}
+
+
+def test_urlencoded_multi_field_app_reads_body(tmpdir):
+    client = TestClient(app_read_body)
+    response = client.post("/", data={"some": "data", "second": "key pair"})
+    assert response.json() == {"some": "data", "second": "key pair"}
+
+
+def test_multipart_multi_field_app_reads_body(tmpdir):
+    client = TestClient(app_read_body)
+    response = client.post(
+        "/", data={"some": "data", "second": "key pair"}, files=FORCE_MULTIPART
+    )
+    assert response.json() == {"some": "data", "second": "key pair"}
diff --git a/tests/test_responses.py b/tests/test_responses.py
index d4bafaf..2c056ed 100644
--- a/tests/test_responses.py
+++ b/tests/test_responses.py
@@ -287,3 +287,16 @@ def test_populate_headers():
     assert response.text == "hi"
     assert response.headers["content-length"] == "2"
     assert response.headers["content-type"] == "text/html; charset=utf-8"
+
+
+def test_head_method():
+    def app(scope):
+        async def asgi(receive, send):
+            response = Response("hello, world", media_type="text/plain")
+            await response(receive, send)
+
+        return asgi
+
+    client = TestClient(app)
+    response = client.head("/")
+    assert response.text == ""