Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-starlette for openSUSE:Factory checked in at 2023-02-13 16:39:25 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-starlette (Old) and /work/SRC/openSUSE:Factory/.python-starlette.new.1848 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-starlette" Mon Feb 13 16:39:25 2023 rev:16 rq:1064388 version:0.24.0 Changes: -------- --- /work/SRC/openSUSE:Factory/python-starlette/python-starlette.changes 2023-01-05 15:55:06.226922109 +0100 +++ /work/SRC/openSUSE:Factory/.python-starlette.new.1848/python-starlette.changes 2023-02-13 16:41:30.567579938 +0100 @@ -1,0 +2,20 @@ +Fri Feb 10 18:28:13 UTC 2023 - David Anes <david.a...@suse.com> + +- Disable broken tests for i586 and armv7l. + +- Update to 0.24.0 + * Added + - Allow StaticFiles to follow symlinks + - Allow Request.form() as a context manager + - Add size attribute to UploadFile + - Add env_prefix argument to Config + - Add template context processors + - Support str and datetime on expires parameter on the Response.set_cookie method + * Changed + - Lazily build the middleware stack + - Make the file argument required on UploadFile + - Use debug extension instead of custom response template extension + * Fixed + - Fix url parsing of ipv6 urls on URL.replace + +------------------------------------------------------------------- Old: ---- starlette-0.23.1.tar.gz New: ---- starlette-0.24.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-starlette.spec ++++++ --- /var/tmp/diff_new_pack.3SeBBE/_old 2023-02-13 16:41:31.083583277 +0100 +++ /var/tmp/diff_new_pack.3SeBBE/_new 2023-02-13 16:41:31.091583329 +0100 @@ -27,7 +27,7 @@ %define skip_python2 1 Name: python-starlette%{psuffix} -Version: 0.23.1 +Version: 0.24.0 Release: 0 Summary: Lightweight ASGI framework/toolkit License: BSD-3-Clause @@ -58,6 +58,7 @@ BuildRequires: %{python_module trio} # testing requires it for all flavors BuildRequires: %{python_module typing_extensions} +BuildRequires: %{python_module importlib-metadata} # /SECITON %endif %python_subpackages @@ -84,7 +85,15 @@ sed -i "s|--strict-config||" setup.cfg sed -i "s|--strict-markers||" setup.cfg sed -i "s| error$||" setup.cfg -%pytest --asyncio-mode=strict + +# The following tests don't work in some archs because time_t cannot +# hold the values the test expect, as they go beyond the maximum +# value in i586 and armv7l. As we are using Buildarch: noarch, we +# cannot just use ifarch conditionals here... +ignored_tests="test_set_cookie" +ignored_tests="$ignored_tests or test_expires_on_set_cookie" +%pytest --asyncio-mode=strict -k "not ($ignored_tests)" + %endif %if ! %{with test} ++++++ starlette-0.23.1.tar.gz -> starlette-0.24.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/starlette-0.23.1/docs/config.md new/starlette-0.24.0/docs/config.md --- old/starlette-0.23.1/docs/config.md 2022-12-09 15:45:37.000000000 +0100 +++ new/starlette-0.24.0/docs/config.md 2023-02-06 17:01:14.000000000 +0100 @@ -113,6 +113,23 @@ environ['TESTING'] = 'TRUE' ``` +## Reading prefixed environment variables + +You can namespace the environment variables by setting `env_prefix` argument. + +```python title="myproject/settings.py" +import os +from starlette.config import Config + +os.environ['APP_DEBUG'] = 'yes' +os.environ['ENVIRONMENT'] = 'dev' + +config = Config(env_prefix='APP_') + +DEBUG = config('DEBUG') # lookups APP_DEBUG, returns "yes" +ENVIRONMENT = config('ENVIRONMENT') # lookups APP_ENVIRONMENT, raises KeyError as variable is not defined +``` + ## A full example Structuring large applications can be complex. You need proper separation of diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/starlette-0.23.1/docs/release-notes.md new/starlette-0.24.0/docs/release-notes.md --- old/starlette-0.23.1/docs/release-notes.md 2022-12-09 15:45:37.000000000 +0100 +++ new/starlette-0.24.0/docs/release-notes.md 2023-02-06 17:01:14.000000000 +0100 @@ -1,3 +1,23 @@ +## 0.24.0 + +February 6, 2023 + +### Added +* Allow `StaticFiles` to follow symlinks [#1683](https://github.com/encode/starlette/pull/1683). +* Allow `Request.form()` as a context manager [#1903](https://github.com/encode/starlette/pull/1903). +* Add `size` attribute to `UploadFile` [#1405](https://github.com/encode/starlette/pull/1405). +* Add `env_prefix` argument to `Config` [#1990](https://github.com/encode/starlette/pull/1990). +* Add template context processors [#1904](https://github.com/encode/starlette/pull/1904). +* Support `str` and `datetime` on `expires` parameter on the `Response.set_cookie` method [#1908](https://github.com/encode/starlette/pull/1908). + +### Changed +* Lazily build the middleware stack [#2017](https://github.com/encode/starlette/pull/2017). +* Make the `file` argument required on `UploadFile` [#1413](https://github.com/encode/starlette/pull/1413). +* Use debug extension instead of custom response template extension [#1991](https://github.com/encode/starlette/pull/1991). + +### Fixed +* Fix url parsing of ipv6 urls on `URL.replace` [#1965](https://github.com/encode/starlette/pull/1965). + ## 0.23.1 December 9, 2022 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/starlette-0.23.1/docs/requests.md new/starlette-0.24.0/docs/requests.md --- old/starlette-0.23.1/docs/requests.md 2022-12-09 15:45:37.000000000 +0100 +++ new/starlette-0.24.0/docs/requests.md 2023-02-06 17:01:14.000000000 +0100 @@ -81,7 +81,7 @@ The request body as bytes: `await request.body()` -The request body, parsed as form data or multipart: `await request.form()` +The request body, parsed as form data or multipart: `async with request.form() as form:` The request body, parsed as JSON: `await request.json()` @@ -114,7 +114,7 @@ Request files are normally sent as multipart form data (`multipart/form-data`). -When you call `await request.form()` you receive a `starlette.datastructures.FormData` which is an immutable +When you call `async with request.form() as form` you receive a `starlette.datastructures.FormData` which is an immutable multidict, containing both file uploads and text input. File upload items are represented as instances of `starlette.datastructures.UploadFile`. `UploadFile` has the following attributes: @@ -123,6 +123,7 @@ * `content_type`: A `str` with the content type (MIME type / media type) (e.g. `image/jpeg`). * `file`: A <a href="https://docs.python.org/3/library/tempfile.html#tempfile.SpooledTemporaryFile" target="_blank">`SpooledTemporaryFile`</a> (a <a href="https://docs.python.org/3/glossary.html#term-file-like-object" target="_blank">file-like</a> object). This is the actual Python file that you can pass directly to other functions or libraries that expect a "file-like" object. * `headers`: A `Headers` object. Often this will only be the `Content-Type` header, but if additional headers were included in the multipart field they will be included here. Note that these headers have no relationship with the headers in `Request.headers`. +* `size`: An `int` with uploaded file's size in bytes. This value is calculated from request's contents, making it better choice to find uploaded file's size than `Content-Length` header. `None` if not set. `UploadFile` has the following `async` methods. They all call the corresponding file methods underneath (using the internal `SpooledTemporaryFile`). @@ -137,9 +138,9 @@ For example, you can get the file name and the contents with: ```python -form = await request.form() -filename = form["upload_file"].filename -contents = await form["upload_file"].read() +async with request.form() as form: + filename = form["upload_file"].filename + contents = await form["upload_file"].read() ``` !!! info diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/starlette-0.23.1/docs/responses.md new/starlette-0.24.0/docs/responses.md --- old/starlette-0.23.1/docs/responses.md 2022-12-09 15:45:37.000000000 +0100 +++ new/starlette-0.24.0/docs/responses.md 2023-02-06 17:01:14.000000000 +0100 @@ -36,7 +36,7 @@ * `key` - A string that will be the cookie's key. * `value` - A string that will be the cookie's value. * `max_age` - An integer that defines the lifetime of the cookie in seconds. A negative integer or a value of `0` will discard the cookie immediately. `Optional` -* `expires` - An integer that defines the number of seconds until the cookie expires. `Optional` +* `expires` - Either an integer that defines the number of seconds until the cookie expires, or a datetime. `Optional` * `path` - A string that specifies the subset of routes to which the cookie will apply. `Optional` * `domain` - A string that specifies the domain for which the cookie is valid. `Optional` * `secure` - A bool indicating that the cookie will only be sent to the server if request is made using SSL and the HTTPS protocol. `Optional` diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/starlette-0.23.1/docs/staticfiles.md new/starlette-0.24.0/docs/staticfiles.md --- old/starlette-0.23.1/docs/staticfiles.md 2022-12-09 15:45:37.000000000 +0100 +++ new/starlette-0.24.0/docs/staticfiles.md 2023-02-06 17:01:14.000000000 +0100 @@ -3,12 +3,13 @@ ### StaticFiles -Signature: `StaticFiles(directory=None, packages=None, check_dir=True)` +Signature: `StaticFiles(directory=None, packages=None, check_dir=True, follow_symlink=False)` * `directory` - A string or [os.Pathlike][pathlike] denoting a directory path. * `packages` - A list of strings or list of tuples of strings of python packages. * `html` - Run in HTML mode. Automatically loads `index.html` for directories if such file exist. * `check_dir` - Ensure that the directory exists upon instantiation. Defaults to `True`. +* `follow_symlink` - A boolean indicating if symbolic links for files and directories should be followed. Defaults to `False`. You can combine this ASGI application with Starlette's routing to provide comprehensive static file serving. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/starlette-0.23.1/docs/templates.md new/starlette-0.24.0/docs/templates.md --- old/starlette-0.23.1/docs/templates.md 2022-12-09 15:45:37.000000000 +0100 +++ new/starlette-0.24.0/docs/templates.md 2023-02-06 17:01:14.000000000 +0100 @@ -1,4 +1,4 @@ -Starlette is not *strictly* coupled to any particular templating engine, but +Starlette is not _strictly_ coupled to any particular templating engine, but Jinja2 provides an excellent choice. Starlette provides a simple way to get `jinja2` configured. This is probably @@ -33,7 +33,7 @@ For example, we can link to static files from within our HTML templates: ```html -<link href="{{ url_for('static', path='/css/bootstrap.min.css') }}" rel="stylesheet"> +<link href="{{ url_for('static', path='/css/bootstrap.min.css') }}" rel="stylesheet" /> ``` If you want to use [custom filters][jinja2], you will need to update the `env` @@ -50,12 +50,51 @@ templates.env.filters['marked'] = marked_filter ``` +## Context processors + +A context processor is a function that returns a dictionary to be merged into a template context. +Every function takes only one argument `request` and must return a dictionary to add to the context. + +A common use case of template processors is to extend the template context with shared variables. + +```python +import typing +from starlette.requests import Request + +def app_context(request: Request) -> typing.Dict[str, typing.Any]: + return {'app': request.app} +``` + +### Registering context templates + +Pass context processors to `context_processors` argument of the `Jinja2Templates` class. + +```python +import typing + +from starlette.requests import Request +from starlette.templating import Jinja2Templates + +def app_context(request: Request) -> typing.Dict[str, typing.Any]: + return {'app': request.app} + +templates = Jinja2Templates( + directory='templates', context_processors=[app_context] +) +``` + +!!! info + Asynchronous functions as context processors are not supported. + ## Testing template responses When using the test client, template responses include `.template` and `.context` attributes. ```python +from starlette.testclient import TestClient + + def test_homepage(): client = TestClient(app) response = client.get("/") diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/starlette-0.23.1/docs/third-party-packages.md new/starlette-0.24.0/docs/third-party-packages.md --- old/starlette-0.23.1/docs/third-party-packages.md 2022-12-09 15:45:37.000000000 +0100 +++ new/starlette-0.24.0/docs/third-party-packages.md 2023-02-06 17:01:14.000000000 +0100 @@ -147,7 +147,6 @@ to quickly generate fully customizable admin interface for your models. You can export your data to many formats (*CSV*, *PDF*, *Excel*, etc), filter your data with complex query including `AND` and `OR` conditions, upload files, ... - ## Frameworks ### FastAPI @@ -158,6 +157,17 @@ High performance, easy to learn, fast to code, ready for production web API framework. Inspired by **APIStar**'s previous server system with type declarations for route parameters, based on the OpenAPI specification version 3.0.0+ (with JSON Schema), powered by **Pydantic** for the data handling. +### Esmerald + +<a href="https://github.com/dymmond/esmerald" target="_blank">GitHub</a> | +<a href="https://esmerald.dymmond.com/" target="_blank">Documentation</a> + +Highly scalable, performant, easy to learn, easy to code and for every application web framework. +Inspired by a lot of frameworks out there, Esmerald provides what every application needs, from the +smallest to complex. Includes, routes, middlewares, permissions, scheduler, interceptors and lot more. + +Powered by **Starlette** and **Pydantic** with OpenAPI specification. + ### Flama <a href="https://github.com/perdy/flama/" target="_blank">GitHub</a> | @@ -204,6 +214,15 @@ <a href="https://github.com/adriangb/xpresso" target="_blank">GitHub</a> | <a href=https://xpresso-api.dev/" target="_blank">Documentation</a> +### Ellar + +<a href="https://github.com/eadwinCode/ellar" target="_blank">GitHub</a> | +<a href="https://eadwincode.github.io/ellar/" target="_blank">Documentation</a> + +Ellar is an ASGI web framework for building fast, efficient and scalable RESTAPIs and server-side applications. It offers a high level of abstraction in building server-side applications and combines elements of OOP (Object Oriented Programming), and FP (Functional Programming) - Inspired by Nestjs. + +It is built on 3 core libraries **Starlette**, **Pydantic**, and **injector**. + ### Apiman An extension to integrate Swagger/OpenAPI document easily for Starlette project and provide [SwaggerUI](http://swagger.io/swagger-ui/) and [RedocUI](https://rebilly.github.io/ReDoc/). diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/starlette-0.23.1/docs/websockets.md new/starlette-0.24.0/docs/websockets.md --- old/starlette-0.23.1/docs/websockets.md 2022-12-09 15:45:37.000000000 +0100 +++ new/starlette-0.24.0/docs/websockets.md 2023-02-06 17:01:14.000000000 +0100 @@ -73,6 +73,29 @@ JSON messages default to being received over text data frames, from version 0.10.0 onwards. Use `websocket.receive_json(data, mode="binary")` to receive JSON over binary data frames. +### Iterating data + +* `websocket.iter_text()` +* `websocket.iter_bytes()` +* `websocket.iter_json()` + +Similar to `receive_text`, `receive_bytes`, and `receive_json` but returns an +async iterator. + +```python hl_lines="7-8" +from starlette.websockets import WebSocket + + +async def app(scope, receive, send): + websocket = WebSocket(scope=scope, receive=receive, send=send) + await websocket.accept() + async for message in websocket.iter_text(): + await websocket.send_text(f"Message text was: {message}") + await websocket.close() +``` + +When `starlette.websockets.WebSocketDisconnect` is raised, the iterator will exit. + ### Closing the connection * `await websocket.close(code=1000, reason=None)` diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/starlette-0.23.1/requirements.txt new/starlette-0.24.0/requirements.txt --- old/starlette-0.23.1/requirements.txt 2022-12-09 15:45:37.000000000 +0100 +++ new/starlette-0.24.0/requirements.txt 2023-02-06 17:01:14.000000000 +0100 @@ -3,15 +3,15 @@ # Testing autoflake==1.5.3 -black==22.10.0 -coverage==6.5.0 +black==22.12.0 +coverage==7.1.0 flake8==3.9.2 importlib-metadata==4.13.0 isort==5.10.1 mypy==0.991 typing_extensions==4.4.0 types-contextvars==2.4.7 -types-PyYAML==6.0.12.2 +types-PyYAML==6.0.12.3 types-dataclasses==0.6.6 pytest==7.2.0 trio==0.21.0 @@ -22,5 +22,5 @@ mkautodoc==0.2.0 # Packaging -build==0.8.0 -twine==4.0.1 +build==0.9.0 +twine==4.0.2 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/starlette-0.23.1/setup.py new/starlette-0.24.0/setup.py --- old/starlette-0.23.1/setup.py 2022-12-09 15:45:37.000000000 +0100 +++ new/starlette-0.24.0/setup.py 1970-01-01 01:00:00.000000000 +0100 @@ -1,29 +0,0 @@ -import sys - -from setuptools import setup - -sys.stderr.write( - """ -=============================== -Unsupported installation method -=============================== -Starlette no longer supports installation with `python setup.py install`. -Please use `python -m pip install .` instead. -""" -) -sys.exit(1) - - -# The below code will never execute, however GitHub is particularly -# picky about where it finds Python packaging metadata. -# See: https://github.com/github/feedback/discussions/6456 -# -# To be removed once GitHub catches up. - -setup( - name="starlette", - install_requires=[ - "anyio>=3.4.0,<5", - "typing_extensions>=3.10.0; python_version < '3.10'", - ], -) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/starlette-0.23.1/starlette/__init__.py new/starlette-0.24.0/starlette/__init__.py --- old/starlette-0.23.1/starlette/__init__.py 2022-12-09 15:45:37.000000000 +0100 +++ new/starlette-0.24.0/starlette/__init__.py 2023-02-06 17:01:14.000000000 +0100 @@ -1 +1 @@ -__version__ = "0.23.1" +__version__ = "0.24.0" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/starlette-0.23.1/starlette/_utils.py new/starlette-0.24.0/starlette/_utils.py --- old/starlette-0.23.1/starlette/_utils.py 2022-12-09 15:45:37.000000000 +0100 +++ new/starlette-0.24.0/starlette/_utils.py 2023-02-06 17:01:14.000000000 +0100 @@ -1,6 +1,13 @@ import asyncio import functools +import sys import typing +from types import TracebackType + +if sys.version_info < (3, 8): # pragma: no cover + from typing_extensions import Protocol +else: # pragma: no cover + from typing import Protocol def is_async_callable(obj: typing.Any) -> bool: @@ -10,3 +17,58 @@ return asyncio.iscoroutinefunction(obj) or ( callable(obj) and asyncio.iscoroutinefunction(obj.__call__) ) + + +T_co = typing.TypeVar("T_co", covariant=True) + + +# TODO: once 3.8 is the minimum supported version (27 Jun 2023) +# this can just become +# class AwaitableOrContextManager( +# typing.Awaitable[T_co], +# typing.AsyncContextManager[T_co], +# typing.Protocol[T_co], +# ): +# pass +class AwaitableOrContextManager(Protocol[T_co]): + def __await__(self) -> typing.Generator[typing.Any, None, T_co]: + ... # pragma: no cover + + async def __aenter__(self) -> T_co: + ... # pragma: no cover + + async def __aexit__( + self, + __exc_type: typing.Optional[typing.Type[BaseException]], + __exc_value: typing.Optional[BaseException], + __traceback: typing.Optional[TracebackType], + ) -> typing.Union[bool, None]: + ... # pragma: no cover + + +class SupportsAsyncClose(Protocol): + async def close(self) -> None: + ... # pragma: no cover + + +SupportsAsyncCloseType = typing.TypeVar( + "SupportsAsyncCloseType", bound=SupportsAsyncClose, covariant=False +) + + +class AwaitableOrContextManagerWrapper(typing.Generic[SupportsAsyncCloseType]): + __slots__ = ("aw", "entered") + + def __init__(self, aw: typing.Awaitable[SupportsAsyncCloseType]) -> None: + self.aw = aw + + def __await__(self) -> typing.Generator[typing.Any, None, SupportsAsyncCloseType]: + return self.aw.__await__() + + async def __aenter__(self) -> SupportsAsyncCloseType: + self.entered = await self.aw + return self.entered + + async def __aexit__(self, *args: typing.Any) -> typing.Union[None, bool]: + await self.entered.close() + return None diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/starlette-0.23.1/starlette/applications.py new/starlette-0.24.0/starlette/applications.py --- old/starlette-0.23.1/starlette/applications.py 2022-12-09 15:45:37.000000000 +0100 +++ new/starlette-0.24.0/starlette/applications.py 2023-02-06 17:01:14.000000000 +0100 @@ -65,7 +65,7 @@ on_startup is None and on_shutdown is None ), "Use either 'lifespan' or 'on_startup'/'on_shutdown', not both." - self._debug = debug + self.debug = debug self.state = State() self.router = Router( routes, on_startup=on_startup, on_shutdown=on_shutdown, lifespan=lifespan @@ -74,7 +74,7 @@ {} if exception_handlers is None else dict(exception_handlers) ) self.user_middleware = [] if middleware is None else list(middleware) - self.middleware_stack = self.build_middleware_stack() + self.middleware_stack: typing.Optional[ASGIApp] = None def build_middleware_stack(self) -> ASGIApp: debug = self.debug @@ -108,20 +108,13 @@ def routes(self) -> typing.List[BaseRoute]: return self.router.routes - @property - def debug(self) -> bool: - return self._debug - - @debug.setter - def debug(self, value: bool) -> None: - self._debug = value - self.middleware_stack = self.build_middleware_stack() - def url_path_for(self, name: str, **path_params: typing.Any) -> URLPath: return self.router.url_path_for(name, **path_params) async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: scope["app"] = self + if self.middleware_stack is None: + self.middleware_stack = self.build_middleware_stack() await self.middleware_stack(scope, receive, send) def on_event(self, event_type: str) -> typing.Callable: # pragma: nocover @@ -137,11 +130,10 @@ ) -> None: # pragma: no cover self.router.host(host, app=app, name=name) - def add_middleware( - self, middleware_class: type, **options: typing.Any - ) -> None: # pragma: no cover + def add_middleware(self, middleware_class: type, **options: typing.Any) -> None: + if self.middleware_stack is not None: # pragma: no cover + raise RuntimeError("Cannot add middleware after an application has started") self.user_middleware.insert(0, Middleware(middleware_class, **options)) - self.middleware_stack = self.build_middleware_stack() def add_exception_handler( self, @@ -149,7 +141,6 @@ handler: typing.Callable, ) -> None: # pragma: no cover self.exception_handlers[exc_class_or_status_code] = handler - self.middleware_stack = self.build_middleware_stack() def add_event_handler( self, event_type: str, func: typing.Callable diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/starlette-0.23.1/starlette/config.py new/starlette-0.24.0/starlette/config.py --- old/starlette-0.23.1/starlette/config.py 2022-12-09 15:45:37.000000000 +0100 +++ new/starlette-0.24.0/starlette/config.py 2023-02-06 17:01:14.000000000 +0100 @@ -54,8 +54,10 @@ self, env_file: typing.Optional[typing.Union[str, Path]] = None, environ: typing.Mapping[str, str] = environ, + env_prefix: str = "", ) -> None: self.environ = environ + self.env_prefix = env_prefix self.file_values: typing.Dict[str, str] = {} if env_file is not None and os.path.isfile(env_file): self.file_values = self._read_file(env_file) @@ -103,6 +105,7 @@ cast: typing.Optional[typing.Callable] = None, default: typing.Any = undefined, ) -> typing.Any: + key = self.env_prefix + key if key in self.environ: value = self.environ[key] return self._perform_cast(key, value, cast) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/starlette-0.23.1/starlette/datastructures.py new/starlette-0.24.0/starlette/datastructures.py --- old/starlette-0.23.1/starlette/datastructures.py 2022-12-09 15:45:37.000000000 +0100 +++ new/starlette-0.24.0/starlette/datastructures.py 2023-02-06 17:01:14.000000000 +0100 @@ -1,4 +1,3 @@ -import tempfile import typing from collections.abc import Sequence from shlex import shlex @@ -114,11 +113,18 @@ or "hostname" in kwargs or "port" in kwargs ): - hostname = kwargs.pop("hostname", self.hostname) + hostname = kwargs.pop("hostname", None) port = kwargs.pop("port", self.port) username = kwargs.pop("username", self.username) password = kwargs.pop("password", self.password) + if hostname is None: + netloc = self.netloc + _, _, hostname = netloc.rpartition("@") + + if hostname[-1] != "]": + hostname = hostname.rsplit(":", 1)[0] + netloc = hostname if port is not None: netloc += f":{port}" @@ -428,32 +434,33 @@ An uploaded file included as part of the request data. """ - spool_max_size = 1024 * 1024 - file: typing.BinaryIO - headers: "Headers" - def __init__( self, - filename: str, - file: typing.Optional[typing.BinaryIO] = None, - content_type: str = "", + file: typing.BinaryIO, *, + size: typing.Optional[int] = None, + filename: typing.Optional[str] = None, headers: "typing.Optional[Headers]" = None, ) -> None: self.filename = filename - self.content_type = content_type - if file is None: - self.file = tempfile.SpooledTemporaryFile(max_size=self.spool_max_size) # type: ignore[assignment] # noqa: E501 - else: - self.file = file + self.file = file + self.size = size self.headers = headers or Headers() @property + def content_type(self) -> typing.Optional[str]: + return self.headers.get("content-type", None) + + @property def _in_memory(self) -> bool: + # check for SpooledTemporaryFile._rolled rolled_to_disk = getattr(self.file, "_rolled", True) return not rolled_to_disk async def write(self, data: bytes) -> None: + if self.size is not None: + self.size += len(data) + if self._in_memory: self.file.write(data) else: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/starlette-0.23.1/starlette/formparsers.py new/starlette-0.24.0/starlette/formparsers.py --- old/starlette-0.23.1/starlette/formparsers.py 2022-12-09 15:45:37.000000000 +0100 +++ new/starlette-0.24.0/starlette/formparsers.py 2023-02-06 17:01:14.000000000 +0100 @@ -1,5 +1,6 @@ import typing from enum import Enum +from tempfile import SpooledTemporaryFile from urllib.parse import unquote_plus from starlette.datastructures import FormData, Headers, UploadFile @@ -116,6 +117,8 @@ class MultiPartParser: + max_file_size = 1024 * 1024 + def __init__( self, headers: Headers, stream: typing.AsyncGenerator[bytes, None] ) -> None: @@ -160,7 +163,7 @@ async def parse(self) -> FormData: # Parse the Content-Type header to get the multipart boundary. - content_type, params = parse_options_header(self.headers["Content-Type"]) + _, params = parse_options_header(self.headers["Content-Type"]) charset = params.get(b"charset", "utf-8") if type(charset) == bytes: charset = charset.decode("latin-1") @@ -186,7 +189,6 @@ header_field = b"" header_value = b"" content_disposition = None - content_type = b"" field_name = "" data = b"" file: typing.Optional[UploadFile] = None @@ -202,7 +204,6 @@ for message_type, message_bytes in messages: if message_type == MultiPartMessage.PART_BEGIN: content_disposition = None - content_type = b"" data = b"" item_headers = [] elif message_type == MultiPartMessage.HEADER_FIELD: @@ -213,8 +214,6 @@ field = header_field.lower() if field == b"content-disposition": content_disposition = header_value - elif field == b"content-type": - content_type = header_value item_headers.append((field, header_value)) header_field = b"" header_value = b"" @@ -229,9 +228,11 @@ ) if b"filename" in options: filename = _user_safe_decode(options[b"filename"], charset) + tempfile = SpooledTemporaryFile(max_size=self.max_file_size) file = UploadFile( + file=tempfile, # type: ignore[arg-type] + size=0, filename=filename, - content_type=content_type.decode("latin-1"), headers=Headers(raw=item_headers), ) else: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/starlette-0.23.1/starlette/middleware/base.py new/starlette-0.24.0/starlette/middleware/base.py --- old/starlette-0.23.1/starlette/middleware/base.py 2022-12-09 15:45:37.000000000 +0100 +++ new/starlette-0.24.0/starlette/middleware/base.py 2023-02-06 17:01:14.000000000 +0100 @@ -2,8 +2,9 @@ import anyio +from starlette.background import BackgroundTask from starlette.requests import Request -from starlette.responses import Response, StreamingResponse +from starlette.responses import ContentStream, Response, StreamingResponse from starlette.types import ASGIApp, Message, Receive, Scope, Send RequestResponseEndpoint = typing.Callable[[Request], typing.Awaitable[Response]] @@ -75,6 +76,9 @@ try: message = await recv_stream.receive() + info = message.get("info", None) + if message["type"] == "http.response.debug" and info is not None: + message = await recv_stream.receive() except anyio.EndOfStream: if app_exc is not None: raise app_exc @@ -93,8 +97,8 @@ if app_exc is not None: raise app_exc - response = StreamingResponse( - status_code=message["status"], content=body_stream() + response = _StreamingResponse( + status_code=message["status"], content=body_stream(), info=info ) response.raw_headers = message["headers"] return response @@ -109,3 +113,22 @@ self, request: Request, call_next: RequestResponseEndpoint ) -> Response: raise NotImplementedError() # pragma: no cover + + +class _StreamingResponse(StreamingResponse): + def __init__( + self, + content: ContentStream, + status_code: int = 200, + headers: typing.Optional[typing.Mapping[str, str]] = None, + media_type: typing.Optional[str] = None, + background: typing.Optional[BackgroundTask] = None, + info: typing.Optional[typing.Mapping[str, typing.Any]] = None, + ) -> None: + self._info = info + super().__init__(content, status_code, headers, media_type, background) + + async def stream_response(self, send: Send) -> None: + if self._info: + await send({"type": "http.response.debug", "info": self._info}) + return await super().stream_response(send) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/starlette-0.23.1/starlette/requests.py new/starlette-0.24.0/starlette/requests.py --- old/starlette-0.23.1/starlette/requests.py 2022-12-09 15:45:37.000000000 +0100 +++ new/starlette-0.24.0/starlette/requests.py 2023-02-06 17:01:14.000000000 +0100 @@ -4,6 +4,7 @@ import anyio +from starlette._utils import AwaitableOrContextManager, AwaitableOrContextManagerWrapper from starlette.datastructures import URL, Address, FormData, Headers, QueryParams, State from starlette.exceptions import HTTPException from starlette.formparsers import FormParser, MultiPartException, MultiPartParser @@ -187,6 +188,8 @@ class Request(HTTPConnection): + _form: typing.Optional[FormData] + def __init__( self, scope: Scope, receive: Receive = empty_receive, send: Send = empty_send ): @@ -196,6 +199,7 @@ self._send = send self._stream_consumed = False self._is_disconnected = False + self._form = None @property def method(self) -> str: @@ -210,10 +214,8 @@ yield self._body yield b"" return - if self._stream_consumed: raise RuntimeError("Stream consumed") - self._stream_consumed = True while True: message = await self._receive() @@ -242,8 +244,8 @@ self._json = json.loads(body) return self._json - async def form(self) -> FormData: - if not hasattr(self, "_form"): + async def _get_form(self) -> FormData: + if self._form is None: assert ( parse_options_header is not None ), "The `python-multipart` library must be installed to use form parsing." @@ -265,8 +267,11 @@ self._form = FormData() return self._form + def form(self) -> AwaitableOrContextManager[FormData]: + return AwaitableOrContextManagerWrapper(self._get_form()) + async def close(self) -> None: - if hasattr(self, "_form"): + if self._form is not None: await self._form.close() async def is_disconnected(self) -> bool: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/starlette-0.23.1/starlette/responses.py new/starlette-0.24.0/starlette/responses.py --- old/starlette-0.23.1/starlette/responses.py 2022-12-09 15:45:37.000000000 +0100 +++ new/starlette-0.24.0/starlette/responses.py 2023-02-06 17:01:14.000000000 +0100 @@ -4,7 +4,8 @@ import stat import sys import typing -from email.utils import formatdate +from datetime import datetime +from email.utils import format_datetime, formatdate from functools import partial from mimetypes import guess_type as mimetypes_guess_type from urllib.parse import quote @@ -105,7 +106,7 @@ key: str, value: str = "", max_age: typing.Optional[int] = None, - expires: typing.Optional[int] = None, + expires: typing.Optional[typing.Union[datetime, str, int]] = None, path: str = "/", domain: typing.Optional[str] = None, secure: bool = False, @@ -117,7 +118,10 @@ if max_age is not None: cookie[key]["max-age"] = max_age if expires is not None: - cookie[key]["expires"] = expires + if isinstance(expires, datetime): + cookie[key]["expires"] = format_datetime(expires, usegmt=True) + else: + cookie[key]["expires"] = expires if path is not None: cookie[key]["path"] = path if domain is not None: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/starlette-0.23.1/starlette/staticfiles.py new/starlette-0.24.0/starlette/staticfiles.py --- old/starlette-0.23.1/starlette/staticfiles.py 2022-12-09 15:45:37.000000000 +0100 +++ new/starlette-0.24.0/starlette/staticfiles.py 2023-02-06 17:01:14.000000000 +0100 @@ -45,12 +45,14 @@ ] = None, html: bool = False, check_dir: bool = True, + follow_symlink: bool = False, ) -> None: self.directory = directory self.packages = packages self.all_directories = self.get_directories(directory, packages) self.html = html self.config_checked = False + self.follow_symlink = follow_symlink if check_dir and directory is not None and not os.path.isdir(directory): raise RuntimeError(f"Directory '{directory}' does not exist") @@ -161,7 +163,11 @@ self, path: str ) -> typing.Tuple[str, typing.Optional[os.stat_result]]: for directory in self.all_directories: - full_path = os.path.realpath(os.path.join(directory, path)) + joined_path = os.path.join(directory, path) + if self.follow_symlink: + full_path = os.path.abspath(joined_path) + else: + full_path = os.path.realpath(joined_path) directory = os.path.realpath(directory) if os.path.commonprefix([full_path, directory]) != directory: # Don't allow misbehaving clients to break out of the static files diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/starlette-0.23.1/starlette/templating.py new/starlette-0.24.0/starlette/templating.py --- old/starlette-0.23.1/starlette/templating.py 2022-12-09 15:45:37.000000000 +0100 +++ new/starlette-0.24.0/starlette/templating.py 2023-02-06 17:01:14.000000000 +0100 @@ -2,6 +2,7 @@ from os import PathLike from starlette.background import BackgroundTask +from starlette.requests import Request from starlette.responses import Response from starlette.types import Receive, Scope, Send @@ -40,12 +41,14 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: request = self.context.get("request", {}) extensions = request.get("extensions", {}) - if "http.response.template" in extensions: + if "http.response.debug" in extensions: await send( { - "type": "http.response.template", - "template": self.template, - "context": self.context, + "type": "http.response.debug", + "info": { + "template": self.template, + "context": self.context, + }, } ) await super().__call__(scope, receive, send) @@ -59,10 +62,16 @@ """ def __init__( - self, directory: typing.Union[str, PathLike], **env_options: typing.Any + self, + directory: typing.Union[str, PathLike], + context_processors: typing.Optional[ + typing.List[typing.Callable[[Request], typing.Dict[str, typing.Any]]] + ] = None, + **env_options: typing.Any ) -> None: assert jinja2 is not None, "jinja2 must be installed to use Jinja2Templates" self.env = self._create_env(directory, **env_options) + self.context_processors = context_processors or [] def _create_env( self, directory: typing.Union[str, PathLike], **env_options: typing.Any @@ -94,6 +103,11 @@ ) -> _TemplateResponse: if "request" not in context: raise ValueError('context must include a "request" key') + + request = typing.cast(Request, context["request"]) + for context_processor in self.context_processors: + context.update(context_processor(request)) + template = self.get_template(name) return _TemplateResponse( template, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/starlette-0.23.1/starlette/testclient.py new/starlette-0.24.0/starlette/testclient.py --- old/starlette-0.23.1/starlette/testclient.py 2022-12-09 15:45:37.000000000 +0100 +++ new/starlette-0.24.0/starlette/testclient.py 2023-02-06 17:01:14.000000000 +0100 @@ -259,7 +259,7 @@ "headers": headers, "client": ["testclient", 50000], "server": [host, port], - "extensions": {"http.response.template": {}}, + "extensions": {"http.response.debug": {}}, } request_complete = False @@ -324,9 +324,9 @@ if not more_body: raw_kwargs["stream"].seek(0) response_complete.set() - elif message["type"] == "http.response.template": - template = message["template"] - context = message["context"] + elif message["type"] == "http.response.debug": + template = message["info"]["template"] + context = message["info"]["context"] try: with self.portal_factory() as portal: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/starlette-0.23.1/tests/test_applications.py new/starlette-0.24.0/tests/test_applications.py --- old/starlette-0.23.1/tests/test_applications.py 2022-12-09 15:45:37.000000000 +0100 +++ new/starlette-0.24.0/tests/test_applications.py 2023-02-06 17:01:14.000000000 +0100 @@ -1,7 +1,9 @@ import os from contextlib import asynccontextmanager +from typing import Any, Callable import anyio +import httpx import pytest from starlette import status @@ -13,6 +15,7 @@ from starlette.responses import JSONResponse, PlainTextResponse from starlette.routing import Host, Mount, Route, Router, WebSocketRoute from starlette.staticfiles import StaticFiles +from starlette.types import ASGIApp from starlette.websockets import WebSocket @@ -486,3 +489,45 @@ app.on_event("startup")(startup) assert len(record) == 1 + + +def test_middleware_stack_init(test_client_factory: Callable[[ASGIApp], httpx.Client]): + class NoOpMiddleware: + def __init__(self, app: ASGIApp): + self.app = app + + async def __call__(self, *args: Any): + await self.app(*args) + + class SimpleInitializableMiddleware: + counter = 0 + + def __init__(self, app: ASGIApp): + self.app = app + SimpleInitializableMiddleware.counter += 1 + + async def __call__(self, *args: Any): + await self.app(*args) + + def get_app() -> ASGIApp: + app = Starlette() + app.add_middleware(SimpleInitializableMiddleware) + app.add_middleware(NoOpMiddleware) + return app + + app = get_app() + + with test_client_factory(app): + pass + + assert SimpleInitializableMiddleware.counter == 1 + + test_client_factory(app).get("/foo") + + assert SimpleInitializableMiddleware.counter == 1 + + app = get_app() + + test_client_factory(app).get("/foo") + + assert SimpleInitializableMiddleware.counter == 2 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/starlette-0.23.1/tests/test_config.py new/starlette-0.24.0/tests/test_config.py --- old/starlette-0.23.1/tests/test_config.py 2022-12-09 15:45:37.000000000 +0100 +++ new/starlette-0.24.0/tests/test_config.py 2023-02-06 17:01:14.000000000 +0100 @@ -127,3 +127,13 @@ environ = Environ() assert list(iter(environ)) == list(iter(os.environ)) assert len(environ) == len(os.environ) + + +def test_config_with_env_prefix(tmpdir, monkeypatch): + config = Config( + environ={"APP_DEBUG": "value", "ENVIRONMENT": "dev"}, env_prefix="APP_" + ) + assert config.get("DEBUG") == "value" + + with pytest.raises(KeyError): + config.get("ENVIRONMENT") diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/starlette-0.23.1/tests/test_datastructures.py new/starlette-0.24.0/tests/test_datastructures.py --- old/starlette-0.23.1/tests/test_datastructures.py 2022-12-09 15:45:37.000000000 +0100 +++ new/starlette-0.24.0/tests/test_datastructures.py 2023-02-06 17:01:14.000000000 +0100 @@ -1,4 +1,6 @@ import io +from tempfile import SpooledTemporaryFile +from typing import BinaryIO import pytest @@ -38,6 +40,24 @@ assert new == "https://example.com:123/path/to/somewhere?abc=123#anchor" assert new.hostname == "example.com" + ipv6_url = URL("https://[fe::2]:12345") + new = ipv6_url.replace(port=8080) + assert new == "https://[fe::2]:8080" + + new = ipv6_url.replace(username="username", password="password") + assert new == "https://username:password@[fe::2]:12345" + assert new.netloc == "username:password@[fe::2]:12345" + + ipv6_url = URL("https://[fe::2]") + new = ipv6_url.replace(port=123) + assert new == "https://[fe::2]:123" + + url = URL("http://u:p@host/") + assert url.replace(hostname="bar") == URL("http://u:p@bar/") + + url = URL("http://u:p@host:80") + assert url.replace(port=88) == URL("http://u:p@host:88") + def test_url_query_params(): u = URL("https://example.org/path/?page=3") @@ -269,35 +289,63 @@ assert QueryParams(q) == q -class BigUploadFile(UploadFile): - spool_max_size = 1024 - - @pytest.mark.anyio -async def test_upload_file(): - big_file = BigUploadFile("big-file") - await big_file.write(b"big-data" * 512) - await big_file.write(b"big-data") - await big_file.seek(0) - assert await big_file.read(1024) == b"big-data" * 128 - await big_file.close() +async def test_upload_file_file_input(): + """Test passing file/stream into the UploadFile constructor""" + stream = io.BytesIO(b"data") + file = UploadFile(filename="file", file=stream, size=4) + assert await file.read() == b"data" + assert file.size == 4 + await file.write(b" and more data!") + assert await file.read() == b"" + assert file.size == 19 + await file.seek(0) + assert await file.read() == b"data and more data!" @pytest.mark.anyio -async def test_upload_file_file_input(): - """Test passing file/stream into the UploadFile constructor""" +async def test_upload_file_without_size(): + """Test passing file/stream into the UploadFile constructor without size""" stream = io.BytesIO(b"data") file = UploadFile(filename="file", file=stream) assert await file.read() == b"data" + assert file.size is None await file.write(b" and more data!") assert await file.read() == b"" + assert file.size is None await file.seek(0) assert await file.read() == b"data and more data!" +@pytest.mark.anyio +@pytest.mark.parametrize("max_size", [1, 1024], ids=["rolled", "unrolled"]) +async def test_uploadfile_rolling(max_size: int) -> None: + """Test that we can r/w to a SpooledTemporaryFile + managed by UploadFile before and after it rolls to disk + """ + stream: BinaryIO = SpooledTemporaryFile( # type: ignore[assignment] + max_size=max_size + ) + file = UploadFile(filename="file", file=stream, size=0) + assert await file.read() == b"" + assert file.size == 0 + await file.write(b"data") + assert await file.read() == b"" + assert file.size == 4 + await file.seek(0) + assert await file.read() == b"data" + await file.write(b" more") + assert await file.read() == b"" + assert file.size == 9 + await file.seek(0) + assert await file.read() == b"data more" + assert file.size == 9 + await file.close() + + def test_formdata(): stream = io.BytesIO(b"data") - upload = UploadFile(filename="file", file=stream) + upload = UploadFile(filename="file", file=stream, size=4) form = FormData([("a", "123"), ("a", "456"), ("b", upload)]) assert "a" in form assert "A" not in form diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/starlette-0.23.1/tests/test_formparsers.py new/starlette-0.24.0/tests/test_formparsers.py --- old/starlette-0.23.1/tests/test_formparsers.py 2022-12-09 15:45:37.000000000 +0100 +++ new/starlette-0.24.0/tests/test_formparsers.py 2023-02-06 17:01:14.000000000 +0100 @@ -29,6 +29,7 @@ content = await value.read() output[key] = { "filename": value.filename, + "size": value.size, "content": content.decode(), "content_type": value.content_type, } @@ -51,6 +52,7 @@ output[key].append( { "filename": value.filename, + "size": value.size, "content": content.decode(), "content_type": value.content_type, } @@ -71,6 +73,7 @@ content = await value.read() output[key] = { "filename": value.filename, + "size": value.size, "content": content.decode(), "content_type": value.content_type, "headers": list(value.headers.items()), @@ -112,6 +115,7 @@ assert response.json() == { "test": { "filename": "test.txt", + "size": 14, "content": "<file content>", "content_type": "text/plain", } @@ -129,6 +133,7 @@ assert response.json() == { "test": { "filename": "test.txt", + "size": 14, "content": "<file content>", "content_type": "text/plain", } @@ -152,11 +157,13 @@ assert response.json() == { "test1": { "filename": "test1.txt", + "size": 15, "content": "<file1 content>", "content_type": "text/plain", }, "test2": { "filename": "test2.txt", + "size": 15, "content": "<file2 content>", "content_type": "text/plain", }, @@ -185,6 +192,7 @@ "test1": "<file1 content>", "test2": { "filename": "test2.txt", + "size": 15, "content": "<file2 content>", "content_type": "text/plain", "headers": [ @@ -220,11 +228,13 @@ "abc", { "filename": "test1.txt", + "size": 15, "content": "<file1 content>", "content_type": "text/plain", }, { "filename": "test2.txt", + "size": 15, "content": "<file2 content>", "content_type": "text/plain", }, @@ -261,6 +271,7 @@ assert response.json() == { "file": { "filename": "file.txt", + "size": 14, "content": "<file content>", "content_type": "text/plain", }, @@ -291,6 +302,7 @@ assert response.json() == { "file": { "filename": "ææ¸.txt", + "size": 14, "content": "<file content>", "content_type": "text/plain", } @@ -318,6 +330,7 @@ assert response.json() == { "file": { "filename": "ç»å.jpg", + "size": 14, "content": "<file content>", "content_type": "image/jpeg", } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/starlette-0.23.1/tests/test_requests.py new/starlette-0.24.0/tests/test_requests.py --- old/starlette-0.23.1/tests/test_requests.py 2022-12-09 15:45:37.000000000 +0100 +++ new/starlette-0.24.0/tests/test_requests.py 2023-02-06 17:01:14.000000000 +0100 @@ -128,6 +128,19 @@ assert response.json() == {"form": {"abc": "123 @"}} +def test_request_form_context_manager(test_client_factory): + async def app(scope, receive, send): + request = Request(scope, receive) + async with request.form() as form: + response = JSONResponse({"form": dict(form)}) + await response(scope, receive, send) + + client = test_client_factory(app) + + response = client.post("/", data={"abc": "123 @"}) + assert response.json() == {"form": {"abc": "123 @"}} + + def test_request_body_then_stream(test_client_factory): async def app(scope, receive, send): request = Request(scope, receive) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/starlette-0.23.1/tests/test_responses.py new/starlette-0.24.0/tests/test_responses.py --- old/starlette-0.23.1/tests/test_responses.py 2022-12-09 15:45:37.000000000 +0100 +++ new/starlette-0.24.0/tests/test_responses.py 2023-02-06 17:01:14.000000000 +0100 @@ -1,4 +1,7 @@ +import datetime as dt import os +import time +from http.cookies import SimpleCookie import anyio import pytest @@ -288,7 +291,11 @@ assert response.headers["content-disposition"] == expected_disposition -def test_set_cookie(test_client_factory): +def test_set_cookie(test_client_factory, monkeypatch): + # Mock time used as a reference for `Expires` by stdlib `SimpleCookie`. + mocked_now = dt.datetime(2100, 1, 22, 12, 0, 0, tzinfo=dt.timezone.utc) + monkeypatch.setattr(time, "time", lambda: mocked_now.timestamp()) + async def app(scope, receive, send): response = Response("Hello, world!", media_type="text/plain") response.set_cookie( @@ -307,6 +314,37 @@ client = test_client_factory(app) response = client.get("/") assert response.text == "Hello, world!" + assert ( + response.headers["set-cookie"] + == "mycookie=myvalue; Domain=localhost; expires=Fri, 22 Jan 2100 12:00:10 GMT; " + "HttpOnly; Max-Age=10; Path=/; SameSite=none; Secure" + ) + + +@pytest.mark.parametrize( + "expires", + [ + pytest.param( + dt.datetime(2100, 1, 22, 12, 0, 10, tzinfo=dt.timezone.utc), id="datetime" + ), + pytest.param("Fri, 22 Jan 2100 12:00:10 GMT", id="str"), + pytest.param(10, id="int"), + ], +) +def test_expires_on_set_cookie(test_client_factory, monkeypatch, expires): + # Mock time used as a reference for `Expires` by stdlib `SimpleCookie`. + mocked_now = dt.datetime(2100, 1, 22, 12, 0, 0, tzinfo=dt.timezone.utc) + monkeypatch.setattr(time, "time", lambda: mocked_now.timestamp()) + + async def app(scope, receive, send): + response = Response("Hello, world!", media_type="text/plain") + response.set_cookie("mycookie", "myvalue", expires=expires) + await response(scope, receive, send) + + client = test_client_factory(app) + response = client.get("/") + cookie: SimpleCookie = SimpleCookie(response.headers.get("set-cookie")) + assert cookie["mycookie"]["expires"] == "Fri, 22 Jan 2100 12:00:10 GMT" def test_delete_cookie(test_client_factory): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/starlette-0.23.1/tests/test_staticfiles.py new/starlette-0.24.0/tests/test_staticfiles.py --- old/starlette-0.23.1/tests/test_staticfiles.py 2022-12-09 15:45:37.000000000 +0100 +++ new/starlette-0.24.0/tests/test_staticfiles.py 2023-02-06 17:01:14.000000000 +0100 @@ -1,6 +1,7 @@ import os import pathlib import stat +import tempfile import time import anyio @@ -448,3 +449,70 @@ response = client.get("/example.txt") assert response.status_code == 500 assert response.text == "Internal Server Error" + + +def test_staticfiles_follows_symlinks(tmpdir, test_client_factory): + statics_path = os.path.join(tmpdir, "statics") + os.mkdir(statics_path) + + source_path = tempfile.mkdtemp() + source_file_path = os.path.join(source_path, "page.html") + with open(source_file_path, "w") as file: + file.write("<h1>Hello</h1>") + + statics_file_path = os.path.join(statics_path, "index.html") + os.symlink(source_file_path, statics_file_path) + + app = StaticFiles(directory=statics_path, follow_symlink=True) + client = test_client_factory(app) + + response = client.get("/index.html") + assert response.url == "http://testserver/index.html" + assert response.status_code == 200 + assert response.text == "<h1>Hello</h1>" + + +def test_staticfiles_follows_symlink_directories(tmpdir, test_client_factory): + statics_path = os.path.join(tmpdir, "statics") + statics_html_path = os.path.join(statics_path, "html") + os.mkdir(statics_path) + + source_path = tempfile.mkdtemp() + source_file_path = os.path.join(source_path, "page.html") + with open(source_file_path, "w") as file: + file.write("<h1>Hello</h1>") + + os.symlink(source_path, statics_html_path) + + app = StaticFiles(directory=statics_path, follow_symlink=True) + client = test_client_factory(app) + + response = client.get("/html/page.html") + assert response.url == "http://testserver/html/page.html" + assert response.status_code == 200 + assert response.text == "<h1>Hello</h1>" + + +def test_staticfiles_disallows_path_traversal_with_symlinks(tmpdir): + statics_path = os.path.join(tmpdir, "statics") + + root_source_path = tempfile.mkdtemp() + source_path = os.path.join(root_source_path, "statics") + os.mkdir(source_path) + + source_file_path = os.path.join(root_source_path, "index.html") + with open(source_file_path, "w") as file: + file.write("<h1>Hello</h1>") + + os.symlink(source_path, statics_path) + + app = StaticFiles(directory=statics_path, follow_symlink=True) + # We can't test this with 'httpx', so we test the app directly here. + path = app.get_path({"path": "/../index.html"}) + scope = {"method": "GET"} + + with pytest.raises(HTTPException) as exc_info: + anyio.run(app.get_response, path, scope) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "Not Found" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/starlette-0.23.1/tests/test_templates.py new/starlette-0.24.0/tests/test_templates.py --- old/starlette-0.23.1/tests/test_templates.py 2022-12-09 15:45:37.000000000 +0100 +++ new/starlette-0.24.0/tests/test_templates.py 2023-02-06 17:01:14.000000000 +0100 @@ -3,6 +3,8 @@ import pytest from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.base import BaseHTTPMiddleware from starlette.routing import Route from starlette.templating import Jinja2Templates @@ -32,3 +34,57 @@ templates = Jinja2Templates(str(tmpdir)) with pytest.raises(ValueError): templates.TemplateResponse("", {}) + + +def test_calls_context_processors(tmp_path, test_client_factory): + path = tmp_path / "index.html" + path.write_text("<html>Hello {{ username }}</html>") + + async def homepage(request): + return templates.TemplateResponse("index.html", {"request": request}) + + def hello_world_processor(request): + return {"username": "World"} + + app = Starlette( + debug=True, + routes=[Route("/", endpoint=homepage)], + ) + templates = Jinja2Templates( + directory=tmp_path, + context_processors=[ + hello_world_processor, + ], + ) + + client = test_client_factory(app) + response = client.get("/") + assert response.text == "<html>Hello World</html>" + assert response.template.name == "index.html" + assert set(response.context.keys()) == {"request", "username"} + + +def test_template_with_middleware(tmpdir, test_client_factory): + path = os.path.join(tmpdir, "index.html") + with open(path, "w") as file: + file.write("<html>Hello, <a href='{{ url_for('homepage') }}'>world</a></html>") + + async def homepage(request): + return templates.TemplateResponse("index.html", {"request": request}) + + class CustomMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + return await call_next(request) + + app = Starlette( + debug=True, + routes=[Route("/", endpoint=homepage)], + middleware=[Middleware(CustomMiddleware)], + ) + templates = Jinja2Templates(directory=str(tmpdir)) + + client = test_client_factory(app) + response = client.get("/") + assert response.text == "<html>Hello, <a href='http://testserver/'>world</a></html>" + assert response.template.name == "index.html" + assert set(response.context.keys()) == {"request"}