diff --git a/.flake8 b/.flake8 index 18c72168cb..3a7da2c652 100644 --- a/.flake8 +++ b/.flake8 @@ -13,5 +13,7 @@ ignore = # flake8 and black disagree about # W503 line break before binary operator # E203 whitespace before ':' - W503,E203 + # E701/E704 multiple statements on one line + # https://black.readthedocs.io/en/latest/guides/using_black_with_other_tools.html#labels-why-pycodestyle-warnings + W503,E203,E701,E704 doctests = true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000000..d58a50757d --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,107 @@ +# The "build" workflow produces wheels (and the sdist) for all python +# versions/platforms. Where possible (i.e. the build is not a cross-compile), +# the test suite is also run for the wheel (this test covers fewer +# configurations than the "test" workflow and tox.ini). +name: Build + +on: + push: + branches: + # Run on release branches. This gives us a chance to detect rot in this + # configuration before pushing a tag (which we'd rather not have to undo). + - "branch[0-9]*" + tags: + # The main purpose of this workflow is to build wheels for release tags. + # It runs automatically on tags matching this pattern and pushes to pypi. + - "v*" + workflow_dispatch: + # Allow this workflow to be run manually (pushing to testpypi instead of pypi) + +env: + python-version: '3.9' + +jobs: + build_sdist: + name: Build sdist + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + name: Install Python + with: + python-version: ${{ env.python-version }} + + - name: Check metadata + run: "python setup.py check" + - name: Build sdist + run: "python setup.py sdist && ls -l dist" + + - uses: actions/upload-artifact@v4 + with: + name: artifacts-sdist + path: ./dist/tornado-*.tar.gz + + build_wheels: + name: Build wheels on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, windows-2022, macos-12] + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + name: Install Python + with: + python-version: ${{ env.python-version }} + - name: Set up QEMU + if: runner.os == 'Linux' + uses: docker/setup-qemu-action@v3 + with: + platforms: all + + - name: Build wheels + uses: pypa/cibuildwheel@v2.18 + + - uses: actions/upload-artifact@v4 + with: + name: artifacts-${{ matrix.os }} + path: ./wheelhouse/*.whl + + upload_pypi_test: + name: Upload to PyPI (test) + needs: [build_wheels, build_sdist] + runs-on: ubuntu-22.04 + if: github.repository == 'tornadoweb/tornado' && github.event_name == 'workflow_dispatch' + permissions: + # This permission is required for pypi's "trusted publisher" feature + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: dist + merge-multiple: true + + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + skip-existing: true + + upload_pypi: + name: Upload to PyPI (prod) + needs: [build_wheels, build_sdist] + runs-on: ubuntu-22.04 + if: github.repository == 'tornadoweb/tornado' && github.event_name == 'push' && github.ref_type == 'tag' && startsWith(github.ref_name, 'v') + permissions: + # This permission is required for pypi's "trusted publisher" feature + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: dist + merge-multiple: true + + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000000..93b87d5ef1 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,95 @@ +# The "test" workflow is run on every PR and runs tests across all +# supported python versions and a range of configurations +# specified in tox.ini. Also see the "build" workflow which is only +# run for release branches and covers platforms other than linux-amd64 +# (Platform-specific issues are rare these days so we don't want to +# take that time on every build). + +name: Test + +on: pull_request + +jobs: + # Before starting the full build matrix, run one test configuration + # and the linter (the `black` linter is especially likely to catch + # first-time contributors). + test_quick: + name: Run quick tests + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + name: Install Python + with: + # Lint python version must be synced with tox.ini + python-version: '3.11' + - name: Install tox + run: python -m pip install tox -c requirements.txt + + - name: Run test suite + run: python -m tox -e py311,lint + + test_tox: + name: Run full tests + needs: test_quick + runs-on: ubuntu-22.04 + strategy: + matrix: + include: + - python: '3.8' + tox_env: py38-full + - python: '3.9' + tox_env: py39-full + - python: '3.10' + tox_env: py310-full + - python: '3.10.8' + # Early versions of 3.10 and 3.11 had different deprecation + # warnings in asyncio. Test with them too to make sure everything + # works the same way. + tox_env: py310-full + - python: '3.11' + tox_env: py311-full + - python: '3.11.0' + tox_env: py311-full + - python: '3.12.0-beta.3 - 3.12' + tox_env: py312-full + - python: 'pypy-3.8' + # Pypy is a lot slower due to jit warmup costs, so don't run the + # "full" test config there. + tox_env: pypy3 + - python: '3.11' + # Docs python version must be synced with tox.ini + tox_env: docs + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + name: Install Python + with: + python-version: ${{ matrix.python}} + - name: Install apt packages + run: sudo apt-get update && sudo apt-get install libcurl4-openssl-dev + - name: Install tox + run: python -m pip install tox -c requirements.txt + + - name: Run test suite + run: python -m tox -e ${{ matrix.tox_env }} + + test_win: + # Windows tests are fairly slow, so only run one configuration here. + # We test on windows but not mac because even though mac is a more + # fully-supported platform, it's similar enough to linux that we + # don't generally need to test it separately. Windows is different + # enough that we'll break it if we don't test it in CI. + name: Run windows tests + needs: test_quick + runs-on: windows-2022 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + name: Install Python + with: + python-version: '3.11' + - name: Run test suite + # TODO: figure out what's up with these log messages + run: py -m tornado.test --fail-if-logs=false diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000000..d9b6cb21ba --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,17 @@ +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.8" + +sphinx: + configuration: docs/conf.py + +formats: + - pdf + - epub + +python: + install: + - requirements: requirements.txt diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 83943b296f..0000000000 --- a/.travis.yml +++ /dev/null @@ -1,110 +0,0 @@ -# https://travis-ci.org/tornadoweb/tornado - -os: linux -dist: xenial -language: python -addons: - apt: - packages: - - libgnutls-dev - -env: - global: - - CIBW_BUILD="cp3[56789]*" - - CIBW_TEST_COMMAND="python3 -m tornado.test" - - CIBW_TEST_COMMAND_WINDOWS="python -m tornado.test --fail-if-logs=false" - -# Before starting the full build matrix, run one test configuration -# and the linter (the `black` linter is especially likely to catch -# first-time contributors). -stages: - - quick - - test - -jobs: - fast_finish: true - include: - # We have two and a half types of builds: Wheel builds run on all supported - # platforms and run the basic test suite for all supported python versions. - # Sdist builds (the "and a half") just build an sdist and run some basic - # validation. Both of these upload their artifacts to pypi if there is a - # tag on the build and the key is available. - # - # Tox builds run a more comprehensive test suite with more configurations - # and dependencies (getting all these dependencies installed for wheel - # builds is a pain, and slows things down because we don't use as much - # parallelism there. We could parallelize wheel builds more but we're also - # amortizing download costs across the different builds). The wheel builds - # take longer, so we run them before the tox builds for better bin packing - # in our allotted concurrency. - - python: '3.8' - arch: amd64 - services: docker - env: BUILD_WHEEL=1 - - python: '3.8' - arch: arm64 - services: docker - env: BUILD_WHEEL=1 ASYNC_TEST_TIMEOUT=15 - - os: windows - env: PATH=/c/Python38:/c/Python38/Scripts:$PATH BUILD_WHEEL=1 - language: shell - before_install: - - choco install python --version 3.8.0 - # Windows build images have outdated root certificates; until that's - # fixed use certifi instead. - # https://github.com/joerick/cibuildwheel/issues/452 - - python -m pip install certifi - - export SSL_CERT_FILE=`python -c "import certifi; print(certifi.where())"` - - os: osx - env: BUILD_WHEEL=1 - language: shell - - - python: '3.8' - arch: amd64 - env: BUILD_SDIST=1 - - # 3.5.2 is interesting because it's the version in ubuntu 16.04, and due to python's - # "provisional feature" rules there are significant differences between patch - # versions for asyncio and typing. - - python: 3.5.2 - # Twisted doesn't install on python 3.5.2, so don't run the "full" tests. - env: TOX_ENV=py35 - stage: test - - python: '3.5' - env: TOX_ENV=py35-full - - python: '3.6' - env: TOX_ENV=py36-full - - python: '3.7' - env: TOX_ENV=py37-full - - python: '3.8' - env: TOX_ENV=py38-full - - python: '3.9-dev' - env: TOX_ENV=py39-full - - python: nightly - env: TOX_ENV=py3 - - python: pypy3.6-7.3.1 - # Pypy is a lot slower due to jit warmup costs, so don't run the "full" - # test config there. - env: TOX_ENV=pypy3 - # Docs and lint python versions must be synced with those in tox.ini - - python: '3.8' - env: TOX_ENV=docs - - # the quick stage runs first, but putting this at the end lets us take - # advantage of travis-ci's defaults and not repeat stage:test in the others. - - python: '3.8' - env: TOX_ENV=py38,lint - stage: quick - -install: - - if [[ -n "$TOX_ENV" ]]; then pip3 install tox; fi - - if [[ -n "$BUILD_WHEEL" ]]; then pip3 install cibuildwheel; fi - - if [[ -n "$BUILD_WHEEL" || -n "$BUILD_SDIST" ]]; then pip3 install twine; fi - -script: - - if [[ -n "$TOX_ENV" ]]; then tox -e $TOX_ENV -- $TOX_ARGS; fi - - if [[ -n "$BUILD_WHEEL" ]]; then cibuildwheel --output-dir dist && ls -l dist; fi - - if [[ -n "$BUILD_SDIST" ]]; then python setup.py check sdist && ls -l dist; fi - -after_success: - - if [[ ( -n "$BUILD_WHEEL" || -n "$BUILD_SDIST" ) && -n "$TRAVIS_TAG" && -n "$TWINE_PASSWORD" ]]; then twine upload -u __token__ dist/*; fi diff --git a/MANIFEST.in b/MANIFEST.in index d99e4bb930..b99a2e2c82 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,3 @@ -recursive-include demos *.py *.yaml *.html *.css *.js *.xml *.sql README recursive-include docs * prune docs/build include tornado/py.typed @@ -19,4 +18,7 @@ include tornado/test/test.crt include tornado/test/test.key include LICENSE include README.rst +include requirements.in +include requirements.txt include runtests.sh +include tox.ini diff --git a/README.rst b/README.rst index 2c9561d527..1c689f5c15 100644 --- a/README.rst +++ b/README.rst @@ -20,8 +20,8 @@ Here is a simple "Hello, world" example web app for Tornado: .. code-block:: python - import tornado.ioloop - import tornado.web + import asyncio + import tornado class MainHandler(tornado.web.RequestHandler): def get(self): @@ -32,10 +32,13 @@ Here is a simple "Hello, world" example web app for Tornado: (r"/", MainHandler), ]) - if __name__ == "__main__": + async def main(): app = make_app() app.listen(8888) - tornado.ioloop.IOLoop.current().start() + await asyncio.Event().wait() + + if __name__ == "__main__": + asyncio.run(main()) This example does not use any of Tornado's asynchronous features; for that see this `simple chat room diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..5cd35cdfce --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,14 @@ +# Security Policy + +## Supported Versions + +In general, due to limited maintainer bandwidth, only the latest version of +Tornado is supported with patch releases. Exceptions may be made depending +on the severity of the bug and the feasibility of backporting a fix to +older releases. + +## Reporting a Vulnerability + +Tornado uses GitHub's security advisory functionality for private vulnerability +reports. To make a private report, use the "Report a vulnerability" button on +https://github.com/tornadoweb/tornado/security/advisories diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index a6b6c6a039..0000000000 --- a/appveyor.yml +++ /dev/null @@ -1,101 +0,0 @@ -# Appveyor is Windows CI: https://ci.appveyor.com/project/bdarnell/tornado -environment: - global: - TORNADO_EXTENSION: "1" - - # We only build with 3.5+ because it works out of the box, while other - # versions require lots of machinery. - # - # We produce binary wheels for 32- and 64-bit builds, but because - # the tests are so slow on Windows (6 minutes vs 15 seconds on Linux - # or MacOS), we don't want to test the full matrix. We do full - # tests on a couple of configurations and on the others we limit - # the tests to the websocket module (which, because it exercises the - # C extension module, is most likely to exhibit differences between - # 32- and 64-bits) - matrix: - - PYTHON: "C:\\Python35" - PYTHON_VERSION: "3.5.x" - PYTHON_ARCH: "32" - TOX_ENV: "py35" - TOX_ARGS: "" - - - PYTHON: "C:\\Python35-x64" - PYTHON_VERSION: "3.5.x" - PYTHON_ARCH: "64" - TOX_ENV: "py35" - TOX_ARGS: "tornado.test.websocket_test" - - - PYTHON: "C:\\Python36" - PYTHON_VERSION: "3.6.x" - PYTHON_ARCH: "32" - TOX_ENV: "py36" - TOX_ARGS: "tornado.test.websocket_test" - - - PYTHON: "C:\\Python36-x64" - PYTHON_VERSION: "3.6.x" - PYTHON_ARCH: "64" - TOX_ENV: "py36" - TOX_ARGS: "" - - - PYTHON: "C:\\Python37" - PYTHON_VERSION: "3.7.x" - PYTHON_ARCH: "32" - TOX_ENV: "py37" - TOX_ARGS: "tornado.test.websocket_test" - - - PYTHON: "C:\\Python37-x64" - PYTHON_VERSION: "3.7.x" - PYTHON_ARCH: "64" - TOX_ENV: "py37" - TOX_ARGS: "" - - - PYTHON: "C:\\Python38" - PYTHON_VERSION: "3.8.x" - PYTHON_ARCH: "32" - TOX_ENV: "py38" - TOX_ARGS: "--fail-if-logs=false tornado.test.websocket_test" - - - PYTHON: "C:\\Python38-x64" - PYTHON_VERSION: "3.8.x" - PYTHON_ARCH: "64" - TOX_ENV: "py38" - # Suppress the log-cleanliness assertions because of https://bugs.python.org/issue39010 - TOX_ARGS: "--fail-if-logs=false" - -install: - # Make sure the right python version is first on the PATH. - - "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%" - - # Check that we have the expected version and architecture for Python - - "python --version" - - "python -c \"import struct; print(struct.calcsize('P') * 8)\"" - - # Upgrade to the latest version of pip to avoid it displaying warnings - # about it being out of date. - - "python -m pip install --disable-pip-version-check --user --upgrade pip" - - - "python -m pip install tox wheel" - -build: false # Not a C# project, build stuff at the test step instead. - -test_script: - # Build the compiled extension and run the project tests. - # This is a bit of a hack that doesn't scale with new python versions, - # but for now it lets us avoid duplication with .travis.yml and tox.ini. - # Running "py3x-full" would be nice but it's failing on installing - # dependencies with no useful logs. - - "tox -e %TOX_ENV% -- %TOX_ARGS%" - -after_test: - # If tests are successful, create binary packages for the project. - - "python setup.py bdist_wheel" - - ps: "ls dist" - -artifacts: - # Archive the generated packages in the ci.appveyor.com build report. - - path: dist\* - -#on_success: -# - TODO: upload the content of dist/*.whl to a public wheelhouse -# diff --git a/demos/README.rst b/demos/README.rst new file mode 100644 index 0000000000..0429761dd9 --- /dev/null +++ b/demos/README.rst @@ -0,0 +1,39 @@ +Tornado Demo Apps +----------------- + +This directory contains several example apps that illustrate the usage of +various Tornado features. If you're not sure where to start, try the ``chat``, +``blog``, or ``websocket`` demos. + +.. note:: + + These applications require features due to be introduced in Tornado 6.3 + which is not yet released. Unless you are testing the new release, + use the GitHub branch selector to access the ``stable`` branch + (or the ``branchX.y`` branch corresponding to the version of Tornado you + are using) to get a suitable version of the demos. + + TODO: remove this when 6.3 ships. + +Web Applications +~~~~~~~~~~~~~~~~ + +- ``blog``: A simple database-backed blogging platform, including + HTML templates and authentication. +- ``chat``: A chat room demonstrating live updates via long polling. +- ``websocket``: Similar to ``chat`` but with WebSockets instead of + long polling. +- ``helloworld``: The simplest possible Tornado web page. +- ``s3server``: Implements a basic subset of the Amazon S3 API. + +Feature demos +~~~~~~~~~~~~~ + +- ``facebook``: Authentication with the Facebook Graph API. +- ``file_upload``: Client and server support for streaming HTTP request + payloads. +- ``tcpecho``: Using the lower-level ``IOStream`` interfaces for non-HTTP + networking. +- ``webspider``: Concurrent usage of ``AsyncHTTPClient``, using queues and + semaphores. + diff --git a/demos/blog/README b/demos/blog/README index d674c876e4..f54ad0abc8 100644 --- a/demos/blog/README +++ b/demos/blog/README @@ -15,7 +15,7 @@ its prerequisites can be installed with `docker-compose up`. 2. Install Python prerequisites - This demo requires Python 3.5 or newer, and the packages listed in + This demo requires Python 3.6 or newer, and the packages listed in requirements.txt. Install them with `pip -r requirements.txt` 3. Create a database and user for the blog. diff --git a/demos/blog/blog.py b/demos/blog/blog.py index 3f1db9ecff..e6e23f85b4 100755 --- a/demos/blog/blog.py +++ b/demos/blog/blog.py @@ -15,17 +15,13 @@ # under the License. import aiopg +import asyncio import bcrypt import markdown import os.path import psycopg2 import re -import tornado.escape -import tornado.httpserver -import tornado.ioloop -import tornado.locks -import tornado.options -import tornado.web +import tornado import unicodedata from tornado.options import define, options @@ -44,13 +40,13 @@ class NoResultError(Exception): async def maybe_create_tables(db): try: - with (await db.cursor()) as cur: + with await db.cursor() as cur: await cur.execute("SELECT COUNT(*) FROM entries LIMIT 1") await cur.fetchone() except psycopg2.ProgrammingError: with open("schema.sql") as f: schema = f.read() - with (await db.cursor()) as cur: + with await db.cursor() as cur: await cur.execute(schema) @@ -68,7 +64,7 @@ def __init__(self, db): (r"/auth/logout", AuthLogoutHandler), ] settings = dict( - blog_title=u"Tornado Blog", + blog_title="Tornado Blog", template_path=os.path.join(os.path.dirname(__file__), "templates"), static_path=os.path.join(os.path.dirname(__file__), "static"), ui_modules={"Entry": EntryModule}, @@ -93,7 +89,7 @@ async def execute(self, stmt, *args): Must be called with ``await self.execute(...)`` """ - with (await self.application.db.cursor()) as cur: + with await self.application.db.cursor() as cur: await cur.execute(stmt, args) async def query(self, stmt, *args): @@ -107,7 +103,7 @@ async def query(self, stmt, *args): for row in await self.query(...) """ - with (await self.application.db.cursor()) as cur: + with await self.application.db.cursor() as cur: await cur.execute(stmt, args) return [self.row_to_obj(row, cur) for row in await cur.fetchall()] @@ -127,7 +123,7 @@ async def queryone(self, stmt, *args): async def prepare(self): # get_current_user cannot be a coroutine, so set # self.current_user in prepare instead. - user_id = self.get_secure_cookie("blogdemo_user") + user_id = self.get_signed_cookie("blogdemo_user") if user_id: self.current_user = await self.queryone( "SELECT * FROM authors WHERE id = %s", int(user_id) @@ -246,7 +242,7 @@ async def post(self): self.get_argument("name"), tornado.escape.to_unicode(hashed_password), ) - self.set_secure_cookie("blogdemo_user", str(author.id)) + self.set_signed_cookie("blogdemo_user", str(author.id)) self.redirect(self.get_argument("next", "/")) @@ -273,7 +269,7 @@ async def post(self): tornado.escape.utf8(author.hashed_password), ) if password_equal: - self.set_secure_cookie("blogdemo_user", str(author.id)) + self.set_signed_cookie("blogdemo_user", str(author.id)) self.redirect(self.get_argument("next", "/")) else: self.render("login.html", error="incorrect password") @@ -313,4 +309,4 @@ async def main(): if __name__ == "__main__": - tornado.ioloop.IOLoop.current().run_sync(main) + asyncio.run(main()) diff --git a/demos/blog/templates/feed.xml b/demos/blog/templates/feed.xml index a98826c8d3..c63ef306a9 100644 --- a/demos/blog/templates/feed.xml +++ b/demos/blog/templates/feed.xml @@ -5,7 +5,7 @@ {% if len(entries) > 0 %} {{ max(e.updated for e in entries).strftime(date_format) }} {% else %} - {{ datetime.datetime.utcnow().strftime(date_format) }} + {{ datetime.datetime.now(datetime.timezone.utc).strftime(date_format) }} {% end %} http://{{ request.host }}/ diff --git a/demos/chat/chatdemo.py b/demos/chat/chatdemo.py index c109b222a2..28c12108f2 100755 --- a/demos/chat/chatdemo.py +++ b/demos/chat/chatdemo.py @@ -15,10 +15,7 @@ # under the License. import asyncio -import tornado.escape -import tornado.ioloop -import tornado.locks -import tornado.web +import tornado import os.path import uuid @@ -107,7 +104,7 @@ def on_connection_close(self): self.wait_future.cancel() -def main(): +async def main(): parse_command_line() app = tornado.web.Application( [ @@ -122,8 +119,8 @@ def main(): debug=options.debug, ) app.listen(options.port) - tornado.ioloop.IOLoop.current().start() + await asyncio.Event().wait() if __name__ == "__main__": - main() + asyncio.run(main()) diff --git a/demos/facebook/facebook.py b/demos/facebook/facebook.py index dc054b9a9d..9b608aaf0d 100755 --- a/demos/facebook/facebook.py +++ b/demos/facebook/facebook.py @@ -14,13 +14,9 @@ # License for the specific language governing permissions and limitations # under the License. +import asyncio import os.path -import tornado.auth -import tornado.escape -import tornado.httpserver -import tornado.ioloop -import tornado.options -import tornado.web +import tornado from tornado.options import define, options @@ -53,7 +49,7 @@ def __init__(self): class BaseHandler(tornado.web.RequestHandler): def get_current_user(self): - user_json = self.get_secure_cookie("fbdemo_user") + user_json = self.get_signed_cookie("fbdemo_user") if not user_json: return None return tornado.escape.json_decode(user_json) @@ -88,7 +84,7 @@ async def get(self): client_secret=self.settings["facebook_secret"], code=self.get_argument("code"), ) - self.set_secure_cookie("fbdemo_user", tornado.escape.json_encode(user)) + self.set_signed_cookie("fbdemo_user", tornado.escape.json_encode(user)) self.redirect(self.get_argument("next", "/")) return self.authorize_redirect( @@ -109,15 +105,15 @@ def render(self, post): return self.render_string("modules/post.html", post=post) -def main(): +async def main(): tornado.options.parse_command_line() if not (options.facebook_api_key and options.facebook_secret): print("--facebook_api_key and --facebook_secret must be set") return http_server = tornado.httpserver.HTTPServer(Application()) http_server.listen(options.port) - tornado.ioloop.IOLoop.current().start() + await asyncio.Event().wait() if __name__ == "__main__": - main() + asyncio.run(main()) diff --git a/demos/file_upload/file_receiver.py b/demos/file_upload/file_receiver.py index 53489704c4..5390715e5f 100755 --- a/demos/file_upload/file_receiver.py +++ b/demos/file_upload/file_receiver.py @@ -8,16 +8,11 @@ See file_uploader.py in this directory for code that uploads files in this format. """ +import asyncio import logging +from urllib.parse import unquote -try: - from urllib.parse import unquote -except ImportError: - # Python 2. - from urllib import unquote - -import tornado.ioloop -import tornado.web +import tornado from tornado import options @@ -53,9 +48,12 @@ def make_app(): return tornado.web.Application([(r"/post", POSTHandler), (r"/(.*)", PUTHandler)]) -if __name__ == "__main__": - # Tornado configures logging. +async def main(): options.parse_command_line() app = make_app() app.listen(8888) - tornado.ioloop.IOLoop.current().start() + await asyncio.Event().wait() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/demos/file_upload/file_uploader.py b/demos/file_upload/file_uploader.py index f48991407f..67fce7ed04 100755 --- a/demos/file_upload/file_uploader.py +++ b/demos/file_upload/file_uploader.py @@ -9,19 +9,15 @@ See also file_receiver.py in this directory, a server that receives uploads. """ +import asyncio import mimetypes import os import sys from functools import partial +from urllib.parse import quote from uuid import uuid4 -try: - from urllib.parse import quote -except ImportError: - # Python 2. - from urllib import quote - -from tornado import gen, httpclient, ioloop +from tornado import gen, httpclient from tornado.options import define, options @@ -115,4 +111,4 @@ def put(filenames): sys.exit(1) method = put if options.put else post - ioloop.IOLoop.current().run_sync(lambda: method(filenames)) + asyncio.run(method(filenames)) diff --git a/demos/google_auth/.gitignore b/demos/google_auth/.gitignore new file mode 100644 index 0000000000..5cfc307c04 --- /dev/null +++ b/demos/google_auth/.gitignore @@ -0,0 +1 @@ +main.cfg diff --git a/demos/google_auth/main.py b/demos/google_auth/main.py new file mode 100644 index 0000000000..40cdd7a4fd --- /dev/null +++ b/demos/google_auth/main.py @@ -0,0 +1,114 @@ +"""Demo app for GoogleOAuth2Mixin + +Recommended usage: +- Register an app with Google following the instructions at + https://www.tornadoweb.org/en/stable/auth.html#tornado.auth.GoogleOAuth2Mixin +- Use "http://localhost:8888/auth/google" as the redirect URI. +- Create a file in this directory called main.cfg, containing two lines (python syntax): + google_oauth_key="..." + google_oauth_secret="..." +- Run this file with `python main.py --config_file=main.cfg` +- Visit "http://localhost:8888" in your browser. +""" + +import asyncio +import json +import tornado +import urllib.parse + +from tornado.options import define, options +from tornado.web import url + +define("port", default=8888, help="run on the given port", type=int) +define("google_oauth_key", help="Google OAuth Key") +define("google_oauth_secret", help="Google OAuth Secret") +define( + "config_file", + help="tornado config file", + callback=lambda path: tornado.options.parse_config_file(path, final=False), +) + + +class BaseHandler(tornado.web.RequestHandler): + def get_current_user(self): + user_cookie = self.get_signed_cookie("googledemo_user") + if user_cookie: + return json.loads(user_cookie) + return None + + +class IndexHandler(BaseHandler, tornado.auth.GoogleOAuth2Mixin): + @tornado.web.authenticated + async def get(self): + try: + # This is redundant: we got the userinfo in the login handler. + # But this demonstrates the usage of oauth2_request outside of + # the login flow, and getting anything more than userinfo + # leads to more approval prompts and complexity. + user_info = await self.oauth2_request( + "https://www.googleapis.com/oauth2/v1/userinfo", + access_token=self.current_user["access_token"], + ) + except tornado.httpclient.HTTPClientError as e: + print(e.response.body) + raise + self.write(f"Hello {user_info['name']}") + + +class LoginHandler(BaseHandler, tornado.auth.GoogleOAuth2Mixin): + async def get(self): + redirect_uri = urllib.parse.urljoin( + self.application.settings["redirect_base_uri"], + self.reverse_url("google_oauth"), + ) + if self.get_argument("code", False): + access = await self.get_authenticated_user( + redirect_uri=redirect_uri, code=self.get_argument("code") + ) + user = await self.oauth2_request( + "https://www.googleapis.com/oauth2/v1/userinfo", + access_token=access["access_token"], + ) + # Save the user and access token. + user_cookie = dict(id=user["id"], access_token=access["access_token"]) + self.set_signed_cookie("googledemo_user", json.dumps(user_cookie)) + self.redirect("/") + else: + self.authorize_redirect( + redirect_uri=redirect_uri, + client_id=self.get_google_oauth_settings()["key"], + scope=["profile", "email"], + response_type="code", + extra_params={"approval_prompt": "auto"}, + ) + + +class LogoutHandler(BaseHandler): + def get(self): + self.clear_cookie("user") + self.redirect("/") + + +async def main(): + tornado.options.parse_command_line() + app = tornado.web.Application( + [ + url(r"/", IndexHandler), + url(r"/auth/google", LoginHandler, name="google_oauth"), + url(r"/logout", LogoutHandler), + ], + redirect_base_uri=f"http://localhost:{options.port}", + google_oauth=dict( + key=options.google_oauth_key, secret=options.google_oauth_secret + ), + debug=True, + cookie_secret="__TODO:_GENERATE_YOUR_OWN_RANDOM_VALUE_HERE__", + login_url="/auth/google", + ) + app.listen(options.port) + shutdown_event = asyncio.Event() + await shutdown_event.wait() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/demos/helloworld/helloworld.py b/demos/helloworld/helloworld.py index 7f64e82405..f33440cff6 100755 --- a/demos/helloworld/helloworld.py +++ b/demos/helloworld/helloworld.py @@ -14,10 +14,8 @@ # License for the specific language governing permissions and limitations # under the License. -import tornado.httpserver -import tornado.ioloop -import tornado.options -import tornado.web +import asyncio +import tornado from tornado.options import define, options @@ -29,13 +27,13 @@ def get(self): self.write("Hello, world") -def main(): +async def main(): tornado.options.parse_command_line() application = tornado.web.Application([(r"/", MainHandler)]) http_server = tornado.httpserver.HTTPServer(application) http_server.listen(options.port) - tornado.ioloop.IOLoop.current().start() + await asyncio.Event().wait() if __name__ == "__main__": - main() + asyncio.run(main()) diff --git a/demos/s3server/s3server.py b/demos/s3server/s3server.py index 11be1c2c24..b798c6b64b 100644 --- a/demos/s3server/s3server.py +++ b/demos/s3server/s3server.py @@ -30,6 +30,7 @@ """ +import asyncio import bisect import datetime import hashlib @@ -39,7 +40,6 @@ from tornado import escape from tornado import httpserver -from tornado import ioloop from tornado import web from tornado.util import unicode_type from tornado.options import options, define @@ -54,12 +54,12 @@ define("bucket_depth", default=0, help="Bucket file system depth limit") -def start(port, root_directory, bucket_depth): +async def start(port, root_directory, bucket_depth): """Starts the mock S3 server on the given port at the given path.""" application = S3Application(root_directory, bucket_depth) http_server = httpserver.HTTPServer(application) http_server.listen(port) - ioloop.IOLoop.current().start() + await asyncio.Event().wait() class S3Application(web.Application): @@ -138,7 +138,9 @@ def get(self): buckets.append( { "Name": name, - "CreationDate": datetime.datetime.utcfromtimestamp(info.st_ctime), + "CreationDate": datetime.datetime.fromtimestamp( + info.st_ctime, datetime.timezone.utc + ), } ) self.render_xml({"ListAllMyBucketsResult": {"Buckets": {"Bucket": buckets}}}) @@ -146,8 +148,8 @@ def get(self): class BucketHandler(BaseRequestHandler): def get(self, bucket_name): - prefix = self.get_argument("prefix", u"") - marker = self.get_argument("marker", u"") + prefix = self.get_argument("prefix", "") + marker = self.get_argument("marker", "") max_keys = int(self.get_argument("max-keys", 50000)) path = os.path.abspath(os.path.join(self.application.directory, bucket_name)) terse = int(self.get_argument("terse", 0)) @@ -265,4 +267,4 @@ def delete(self, bucket, object_name): if __name__ == "__main__": options.parse_command_line() - start(options.port, options.root_directory, options.bucket_depth) + asyncio.run(start(options.port, options.root_directory, options.bucket_depth)) diff --git a/demos/tcpecho/client.py b/demos/tcpecho/client.py index a2ead08bc8..e39b5e7e95 100755 --- a/demos/tcpecho/client.py +++ b/demos/tcpecho/client.py @@ -1,7 +1,6 @@ #!/usr/bin/env python -from tornado.ioloop import IOLoop -from tornado import gen +import asyncio from tornado.tcpclient import TCPClient from tornado.options import options, define @@ -10,15 +9,14 @@ define("message", default="ping", help="Message to send") -@gen.coroutine -def send_message(): - stream = yield TCPClient().connect(options.host, options.port) - yield stream.write((options.message + "\n").encode()) +async def send_message(): + stream = await TCPClient().connect(options.host, options.port) + await stream.write((options.message + "\n").encode()) print("Sent to server:", options.message) - reply = yield stream.read_until(b"\n") + reply = await stream.read_until(b"\n") print("Response from server:", reply.decode().strip()) if __name__ == "__main__": options.parse_command_line() - IOLoop.current().run_sync(send_message) + asyncio.run(send_message()) diff --git a/demos/tcpecho/server.py b/demos/tcpecho/server.py index ebe683cf0f..e7da4bff30 100755 --- a/demos/tcpecho/server.py +++ b/demos/tcpecho/server.py @@ -1,7 +1,7 @@ #!/usr/bin/env python +import asyncio import logging -from tornado.ioloop import IOLoop from tornado import gen from tornado.iostream import StreamClosedError from tornado.tcpserver import TCPServer @@ -28,9 +28,13 @@ def handle_stream(self, stream, address): print(e) -if __name__ == "__main__": +async def main(): options.parse_command_line() + logger.info("Listening on TCP port %d", options.port) server = EchoServer() server.listen(options.port) - logger.info("Listening on TCP port %d", options.port) - IOLoop.current().start() + await asyncio.Event().wait() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/demos/twitter/home.html b/demos/twitter/home.html deleted file mode 100644 index a2c159c58b..0000000000 --- a/demos/twitter/home.html +++ /dev/null @@ -1,12 +0,0 @@ - - - Tornado Twitter Demo - - - - - diff --git a/demos/twitter/twitterdemo.py b/demos/twitter/twitterdemo.py deleted file mode 100755 index 4bd3022531..0000000000 --- a/demos/twitter/twitterdemo.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env python -"""A simplistic Twitter viewer to demonstrate the use of TwitterMixin. - -To run this app, you must first register an application with Twitter: - 1) Go to https://dev.twitter.com/apps and create an application. - Your application must have a callback URL registered with Twitter. - It doesn't matter what it is, but it has to be there (Twitter won't - let you use localhost in a registered callback URL, but that won't stop - you from running this demo on localhost). - 2) Create a file called "secrets.cfg" and put your consumer key and - secret (which Twitter gives you when you register an app) in it: - twitter_consumer_key = 'asdf1234' - twitter_consumer_secret = 'qwer5678' - (you could also generate a random value for "cookie_secret" and put it - in the same file, although it's not necessary to run this demo) - 3) Run this program and go to http://localhost:8888 (by default) in your - browser. -""" - -import logging - -from tornado.auth import TwitterMixin -from tornado.escape import json_decode, json_encode -from tornado.ioloop import IOLoop -from tornado import gen -from tornado.options import define, options, parse_command_line, parse_config_file -from tornado.web import Application, RequestHandler, authenticated - -define("port", default=8888, help="port to listen on") -define( - "config_file", default="secrets.cfg", help="filename for additional configuration" -) - -define( - "debug", - default=False, - group="application", - help="run in debug mode (with automatic reloading)", -) -# The following settings should probably be defined in secrets.cfg -define("twitter_consumer_key", type=str, group="application") -define("twitter_consumer_secret", type=str, group="application") -define( - "cookie_secret", - type=str, - group="application", - default="__TODO:_GENERATE_YOUR_OWN_RANDOM_VALUE__", - help="signing key for secure cookies", -) - - -class BaseHandler(RequestHandler): - COOKIE_NAME = "twitterdemo_user" - - def get_current_user(self): - user_json = self.get_secure_cookie(self.COOKIE_NAME) - if not user_json: - return None - return json_decode(user_json) - - -class MainHandler(BaseHandler, TwitterMixin): - @authenticated - @gen.coroutine - def get(self): - timeline = yield self.twitter_request( - "/statuses/home_timeline", access_token=self.current_user["access_token"] - ) - self.render("home.html", timeline=timeline) - - -class LoginHandler(BaseHandler, TwitterMixin): - @gen.coroutine - def get(self): - if self.get_argument("oauth_token", None): - user = yield self.get_authenticated_user() - del user["description"] - self.set_secure_cookie(self.COOKIE_NAME, json_encode(user)) - self.redirect(self.get_argument("next", "/")) - else: - yield self.authorize_redirect(callback_uri=self.request.full_url()) - - -class LogoutHandler(BaseHandler): - def get(self): - self.clear_cookie(self.COOKIE_NAME) - - -def main(): - parse_command_line(final=False) - parse_config_file(options.config_file) - - app = Application( - [("/", MainHandler), ("/login", LoginHandler), ("/logout", LogoutHandler)], - login_url="/login", - **options.group_dict("application") - ) - app.listen(options.port) - - logging.info("Listening on http://localhost:%d" % options.port) - IOLoop.current().start() - - -if __name__ == "__main__": - main() diff --git a/demos/websocket/chatdemo.py b/demos/websocket/chatdemo.py index 1a7a3042c3..05781c757e 100755 --- a/demos/websocket/chatdemo.py +++ b/demos/websocket/chatdemo.py @@ -18,12 +18,9 @@ Authentication, error handling, etc are left as an exercise for the reader :) """ +import asyncio import logging -import tornado.escape -import tornado.ioloop -import tornado.options -import tornado.web -import tornado.websocket +import tornado import os.path import uuid @@ -91,12 +88,12 @@ def on_message(self, message): ChatSocketHandler.send_updates(chat) -def main(): +async def main(): tornado.options.parse_command_line() app = Application() app.listen(options.port) - tornado.ioloop.IOLoop.current().start() + await asyncio.Event().wait() if __name__ == "__main__": - main() + asyncio.run(main()) diff --git a/demos/webspider/webspider.py b/demos/webspider/webspider.py index 16c3840fa7..d7757917f4 100755 --- a/demos/webspider/webspider.py +++ b/demos/webspider/webspider.py @@ -1,12 +1,13 @@ #!/usr/bin/env python3 +import asyncio import time from datetime import timedelta from html.parser import HTMLParser from urllib.parse import urljoin, urldefrag -from tornado import gen, httpclient, ioloop, queues +from tornado import gen, httpclient, queues base_url = "http://www.tornadoweb.org/en/stable/" concurrency = 10 @@ -85,7 +86,7 @@ async def worker(): await q.join(timeout=timedelta(seconds=300)) assert fetching == (fetched | dead) print("Done in %d seconds, fetched %s URLs." % (time.time() - start, len(fetched))) - print("Unable to fetch %s URLS." % len(dead)) + print("Unable to fetch %s URLs." % len(dead)) # Signal all the workers to exit. for _ in range(concurrency): @@ -94,5 +95,4 @@ async def worker(): if __name__ == "__main__": - io_loop = ioloop.IOLoop.current() - io_loop.run_sync(main) + asyncio.run(main()) diff --git a/docs/auth.rst b/docs/auth.rst index dfbe3ed984..5033948155 100644 --- a/docs/auth.rst +++ b/docs/auth.rst @@ -3,7 +3,7 @@ .. testsetup:: - import tornado.auth, tornado.gen, tornado.web + import tornado .. automodule:: tornado.auth diff --git a/docs/caresresolver.rst b/docs/caresresolver.rst index b5d6ddd101..4e0058eac0 100644 --- a/docs/caresresolver.rst +++ b/docs/caresresolver.rst @@ -18,3 +18,7 @@ wrapper ``pycares``). so it is only recommended for use in ``AF_INET`` (i.e. IPv4). This is the default for ``tornado.simple_httpclient``, but other libraries may default to ``AF_UNSPEC``. + + .. deprecated:: 6.2 + This class is deprecated and will be removed in Tornado 7.0. Use the default + thread-based resolver instead. diff --git a/docs/conf.py b/docs/conf.py index f0cfa9c292..424d844ee6 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,7 +1,10 @@ -# Ensure we get the local copy of tornado instead of what's on the standard path import os +import sphinx.errors import sys +import sphinx_rtd_theme + +# Ensure we get the local copy of tornado instead of what's on the standard path sys.path.insert(0, os.path.abspath("..")) import tornado @@ -81,15 +84,63 @@ ) ] -intersphinx_mapping = {"python": ("https://docs.python.org/3.6/", None)} - -on_rtd = os.environ.get("READTHEDOCS", None) == "True" - -# On RTD we can't import sphinx_rtd_theme, but it will be applied by -# default anyway. This block will use the same theme when building locally -# as on RTD. -if not on_rtd: - import sphinx_rtd_theme - - html_theme = "sphinx_rtd_theme" - html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] +intersphinx_mapping = {"python": ("https://docs.python.org/3/", None)} + +html_theme = "sphinx_rtd_theme" +html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] + +# Suppress warnings about "class reference target not found" for these types. +# In most cases these types come from type annotations and are for mypy's use. +missing_references = { + # Generic type variables; nothing to link to. + "_IOStreamType", + "_S", + "_T", + # Standard library types which are defined in one module and documented + # in another. We could probably remap them to their proper location if + # there's not an upstream fix in python and/or sphinx. + "_asyncio.Future", + "_io.BytesIO", + "asyncio.AbstractEventLoop.run_forever", + "asyncio.events.AbstractEventLoop", + "concurrent.futures._base.Executor", + "concurrent.futures._base.Future", + "futures.Future", + "socket.socket", + "TextIO", + # Other stuff. I'm not sure why some of these are showing up, but + # I'm just listing everything here to avoid blocking the upgrade of sphinx. + "Future", + "httputil.HTTPServerConnectionDelegate", + "httputil.HTTPServerRequest", + "OutputTransform", + "Pattern", + "RAISE", + "Rule", + "socket.AddressFamily", + "tornado.concurrent._T", + "tornado.gen._T", + "tornado.ioloop._S", + "tornado.ioloop._T", + "tornado.ioloop._Selectable", + "tornado.iostream._IOStreamType", + "tornado.locks._ReleasingContextManager", + "tornado.queues._T", + "tornado.options._Mockable", + "tornado.web._ArgDefaultMarker", + "tornado.web._HandlerDelegate", + "tornado.web._RequestHandlerType", + "_RequestHandlerType", + "traceback", + "WSGIAppType", + "Yieldable", +} + + +def missing_reference_handler(app, env, node, contnode): + if node["reftarget"] in missing_references: + raise sphinx.errors.NoUri + + +def setup(app): + app.connect("missing-reference", missing_reference_handler) diff --git a/docs/guide/coroutines.rst b/docs/guide/coroutines.rst index aa8f62896d..811c084f1a 100644 --- a/docs/guide/coroutines.rst +++ b/docs/guide/coroutines.rst @@ -6,7 +6,7 @@ Coroutines from tornado import gen **Coroutines** are the recommended way to write asynchronous code in -Tornado. Coroutines use the Python ``await`` or ``yield`` keyword to +Tornado. Coroutines use the Python ``await`` keyword to suspend and resume execution instead of a chain of callbacks (cooperative lightweight threads as seen in frameworks like `gevent `_ are sometimes called coroutines as well, but @@ -232,11 +232,11 @@ immediately, so you can start another operation before waiting. # This is equivalent to asyncio.ensure_future() (both work in Tornado). fetch_future = convert_yielded(self.fetch_next_chunk()) while True: - chunk = yield fetch_future + chunk = await fetch_future if chunk is None: break self.write(chunk) fetch_future = convert_yielded(self.fetch_next_chunk()) - yield self.flush() + await self.flush() .. testoutput:: :hide: @@ -281,7 +281,7 @@ loop condition from accessing the results, as in this example from Running in the background ^^^^^^^^^^^^^^^^^^^^^^^^^ -`.PeriodicCallback` is not normally used with coroutines. Instead, a +As an alternative to `.PeriodicCallback`, a coroutine can contain a ``while True:`` loop and use `tornado.gen.sleep`:: diff --git a/docs/guide/intro.rst b/docs/guide/intro.rst index 8d87ba62b2..2684c3890e 100644 --- a/docs/guide/intro.rst +++ b/docs/guide/intro.rst @@ -9,7 +9,7 @@ can scale to tens of thousands of open connections, making it ideal for `WebSockets `_, and other applications that require a long-lived connection to each user. -Tornado can be roughly divided into four major components: +Tornado can be roughly divided into three major components: * A web framework (including `.RequestHandler` which is subclassed to create web applications, and various supporting classes). @@ -18,11 +18,6 @@ Tornado can be roughly divided into four major components: * An asynchronous networking library including the classes `.IOLoop` and `.IOStream`, which serve as the building blocks for the HTTP components and can also be used to implement other protocols. -* A coroutine library (`tornado.gen`) which allows asynchronous - code to be written in a more straightforward way than chaining - callbacks. This is similar to the native coroutine feature introduced - in Python 3.5 (``async def``). Native coroutines are recommended - in place of the `tornado.gen` module when available. The Tornado web framework and HTTP server together offer a full-stack alternative to `WSGI `_. diff --git a/docs/guide/queues.rst b/docs/guide/queues.rst index eb2f73f749..c8684e500a 100644 --- a/docs/guide/queues.rst +++ b/docs/guide/queues.rst @@ -3,9 +3,10 @@ .. currentmodule:: tornado.queues -Tornado's `tornado.queues` module implements an asynchronous producer / -consumer pattern for coroutines, analogous to the pattern implemented for -threads by the Python standard library's `queue` module. +Tornado's `tornado.queues` module (and the very similar ``Queue`` classes in +`asyncio`) implements an asynchronous producer / consumer pattern for +coroutines, analogous to the pattern implemented for threads by the Python +standard library's `queue` module. A coroutine that yields `Queue.get` pauses until there is an item in the queue. If the queue has a maximum size set, a coroutine that yields `Queue.put` pauses diff --git a/docs/guide/running.rst b/docs/guide/running.rst index 8cf34f0502..99d18275a3 100644 --- a/docs/guide/running.rst +++ b/docs/guide/running.rst @@ -8,13 +8,15 @@ configuring a WSGI container to find your application, you write a .. testcode:: - def main(): + import asyncio + + async def main(): app = make_app() app.listen(8888) - IOLoop.current().start() + await asyncio.Event().wait() if __name__ == '__main__': - main() + asyncio.run(main()) .. testoutput:: :hide: @@ -33,24 +35,28 @@ Due to the Python GIL (Global Interpreter Lock), it is necessary to run multiple Python processes to take full advantage of multi-CPU machines. Typically it is best to run one process per CPU. -Tornado includes a built-in multi-process mode to start several -processes at once (note that multi-process mode does not work on -Windows). This requires a slight alteration to the standard main -function: +The simplest way to do this is to add ``reuse_port=True`` to your ``listen()`` +calls and then simply run multiple copies of your application. + +Tornado also has the ability to start multiple processes from a single parent +process (note that this does not work on Windows). This requires some +alterations to application startup. .. testcode:: def main(): - app = make_app() - server = tornado.httpserver.HTTPServer(app) - server.bind(8888) - server.start(0) # forks one process per cpu - IOLoop.current().start() + sockets = bind_sockets(8888) + tornado.process.fork_processes(0) + async def post_fork_main(): + server = TCPServer() + server.add_sockets(sockets) + await asyncio.Event().wait() + asyncio.run(post_fork_main()) .. testoutput:: :hide: -This is the easiest way to start multiple processes and have them all +This is another way to start multiple processes and have them all share the same port, although it has some limitations. First, each child process will have its own ``IOLoop``, so it is important that nothing touches the global ``IOLoop`` instance (even indirectly) before the diff --git a/docs/guide/security.rst b/docs/guide/security.rst index b65cd3f370..ee33141ee0 100644 --- a/docs/guide/security.rst +++ b/docs/guide/security.rst @@ -3,10 +3,9 @@ Authentication and security .. testsetup:: - import tornado.auth - import tornado.web + import tornado -Cookies and secure cookies +Cookies and signed cookies ~~~~~~~~~~~~~~~~~~~~~~~~~~ You can set cookies in the user's browser with the ``set_cookie`` @@ -28,8 +27,8 @@ method: Cookies are not secure and can easily be modified by clients. If you need to set cookies to, e.g., identify the currently logged in user, you need to sign your cookies to prevent forgery. Tornado supports -signed cookies with the `~.RequestHandler.set_secure_cookie` and -`~.RequestHandler.get_secure_cookie` methods. To use these methods, +signed cookies with the `~.RequestHandler.set_signed_cookie` and +`~.RequestHandler.get_signed_cookie` methods. To use these methods, you need to specify a secret key named ``cookie_secret`` when you create your application. You can pass in application settings as keyword arguments to your application: @@ -46,15 +45,15 @@ keyword arguments to your application: Signed cookies contain the encoded value of the cookie in addition to a timestamp and an `HMAC `_ signature. If the cookie is old or if the signature doesn't match, -``get_secure_cookie`` will return ``None`` just as if the cookie isn't +``get_signed_cookie`` will return ``None`` just as if the cookie isn't set. The secure version of the example above: .. testcode:: class MainHandler(tornado.web.RequestHandler): def get(self): - if not self.get_secure_cookie("mycookie"): - self.set_secure_cookie("mycookie", "myvalue") + if not self.get_signed_cookie("mycookie"): + self.set_signed_cookie("mycookie", "myvalue") self.write("Your cookie was not set yet!") else: self.write("Your cookie was set!") @@ -62,15 +61,15 @@ set. The secure version of the example above: .. testoutput:: :hide: -Tornado's secure cookies guarantee integrity but not confidentiality. +Tornado's signed cookies guarantee integrity but not confidentiality. That is, the cookie cannot be modified but its contents can be seen by the user. The ``cookie_secret`` is a symmetric key and must be kept secret -- anyone who obtains the value of this key could produce their own signed cookies. -By default, Tornado's secure cookies expire after 30 days. To change this, -use the ``expires_days`` keyword argument to ``set_secure_cookie`` *and* the -``max_age_days`` argument to ``get_secure_cookie``. These two values are +By default, Tornado's signed cookies expire after 30 days. To change this, +use the ``expires_days`` keyword argument to ``set_signed_cookie`` *and* the +``max_age_days`` argument to ``get_signed_cookie``. These two values are passed separately so that you may e.g. have a cookie that is valid for 30 days for most purposes, but for certain sensitive actions (such as changing billing information) you use a smaller ``max_age_days`` when reading the cookie. @@ -82,7 +81,7 @@ signing key must then be set as ``key_version`` application setting but all other keys in the dict are allowed for cookie signature validation, if the correct key version is set in the cookie. To implement cookie updates, the current signing key version can be -queried via `~.RequestHandler.get_secure_cookie_key_version`. +queried via `~.RequestHandler.get_signed_cookie_key_version`. .. _user-authentication: @@ -104,7 +103,7 @@ specifying a nickname, which is then saved in a cookie: class BaseHandler(tornado.web.RequestHandler): def get_current_user(self): - return self.get_secure_cookie("user") + return self.get_signed_cookie("user") class MainHandler(BaseHandler): def get(self): @@ -122,7 +121,7 @@ specifying a nickname, which is then saved in a cookie: '') def post(self): - self.set_secure_cookie("user", self.get_argument("name")) + self.set_signed_cookie("user", self.get_argument("name")) self.redirect("/") application = tornado.web.Application([ @@ -194,7 +193,7 @@ the Google credentials in a cookie for later access: user = await self.get_authenticated_user( redirect_uri='http://your.site.com/auth/google', code=self.get_argument('code')) - # Save the user with e.g. set_secure_cookie + # Save the user with e.g. set_signed_cookie else: await self.authorize_redirect( redirect_uri='http://your.site.com/auth/google', @@ -215,10 +214,7 @@ Cross-site request forgery protection `Cross-site request forgery `_, or -XSRF, is a common problem for personalized web applications. See the -`Wikipedia -article `_ for -more information on how XSRF works. +XSRF, is a common problem for personalized web applications. The generally accepted solution to prevent XSRF is to cookie every user with an unpredictable value and include that value as an additional diff --git a/docs/guide/structure.rst b/docs/guide/structure.rst index 407edf414b..d120ea407a 100644 --- a/docs/guide/structure.rst +++ b/docs/guide/structure.rst @@ -2,7 +2,7 @@ .. testsetup:: - import tornado.web + import tornado Structure of a Tornado web application ====================================== @@ -16,8 +16,8 @@ A minimal "hello world" example looks something like this: .. testcode:: - import tornado.ioloop - import tornado.web + import asyncio + import tornado class MainHandler(tornado.web.RequestHandler): def get(self): @@ -28,14 +28,36 @@ A minimal "hello world" example looks something like this: (r"/", MainHandler), ]) - if __name__ == "__main__": + async def main(): app = make_app() app.listen(8888) - tornado.ioloop.IOLoop.current().start() + shutdown_event = asyncio.Event() + await shutdown_event.wait() + + if __name__ == "__main__": + asyncio.run(main()) .. testoutput:: :hide: +The ``main`` coroutine +~~~~~~~~~~~~~~~~~~~~~~ + +Beginning with Tornado 6.2 and Python 3.10, the recommended pattern for starting +a Tornado application is to create a ``main`` coroutine to be run with +`asyncio.run`. (In older versions, it was common to do initialization in a +regular function and then start the event loop with +``IOLoop.current().start()``. However, this pattern produces deprecation +warnings starting in Python 3.10 and will break in some future version of +Python.) + +When the ``main`` function returns, the program exits, so most of the time for a +web server ``main`` should run forever. Waiting on an `asyncio.Event` whose +``set()`` method is never called is a convenient way to make an asynchronus +function run forever. (and if you wish to have ``main`` exit early as a part of +a graceful shutdown procedure, you can call ``shutdown_event.set()`` to make it +exit). + The ``Application`` object ~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -154,7 +176,7 @@ handle files that are too large to comfortably keep in memory see the `.stream_request_body` class decorator. In the demos directory, -`file_receiver.py `_ +`file_receiver.py `_ shows both methods of receiving file uploads. Due to the quirks of the HTML form encoding (e.g. the ambiguity around diff --git a/docs/guide/templates.rst b/docs/guide/templates.rst index 61ce753e6a..7c5a8f4192 100644 --- a/docs/guide/templates.rst +++ b/docs/guide/templates.rst @@ -3,7 +3,7 @@ Templates and UI .. testsetup:: - import tornado.web + import tornado Tornado includes a simple, fast, and flexible templating language. This section describes that language as well as related issues @@ -192,7 +192,7 @@ by overriding `.RequestHandler.get_user_locale`: class BaseHandler(tornado.web.RequestHandler): def get_current_user(self): - user_id = self.get_secure_cookie("user") + user_id = self.get_signed_cookie("user") if not user_id: return None return self.backend.get_user_by_id(user_id) diff --git a/docs/httpclient.rst b/docs/httpclient.rst index 178dc1480a..3837d4c4f9 100644 --- a/docs/httpclient.rst +++ b/docs/httpclient.rst @@ -57,10 +57,20 @@ Implementations ``libcurl``-based HTTP client. + This implementation supports the following arguments, which can be passed + to ``configure()`` to control the global singleton, or to the constructor + when ``force_instance=True``. + + ``max_clients`` is the number of concurrent requests that can be in progress; + when this limit is reached additional requests will be queued. + + ``defaults`` is a dict of parameters that will be used as defaults on all + `.HTTPRequest` objects submitted to this client. + Example Code ~~~~~~~~~~~~ -* `A simple webspider `_ +* `A simple webspider `_ shows how to fetch URLs concurrently. -* `The file uploader demo `_ +* `The file uploader demo `_ uses either HTTP POST or HTTP PUT to upload files to a server. diff --git a/docs/index.rst b/docs/index.rst index 8a663c307c..c33fb0b080 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -31,8 +31,8 @@ Hello, world Here is a simple "Hello, world" example web app for Tornado:: - import tornado.ioloop - import tornado.web + import asyncio + import tornado class MainHandler(tornado.web.RequestHandler): def get(self): @@ -43,10 +43,13 @@ Here is a simple "Hello, world" example web app for Tornado:: (r"/", MainHandler), ]) - if __name__ == "__main__": + async def main(): app = make_app() app.listen(8888) - tornado.ioloop.IOLoop.current().start() + await asyncio.Event().wait() + + if __name__ == "__main__": + asyncio.run(main()) This example does not use any of Tornado's asynchronous features; for that see this `simple chat room @@ -96,15 +99,11 @@ installed in this way, so you may wish to download a copy of the source tarball or clone the `git repository `_ as well. -**Prerequisites**: Tornado 6.0 requires Python 3.5.2 or newer (See -`Tornado 5.1 `_ if -compatibility with Python 2.7 is required). The following optional -packages may be useful: +**Prerequisites**: Tornado 6.3 requires Python 3.8 or newer. The following +optional packages may be useful: * `pycurl `_ is used by the optional ``tornado.curl_httpclient``. Libcurl version 7.22 or higher is required. -* `Twisted `_ may be used with the classes in - `tornado.platform.twisted`. * `pycares `_ is an alternative non-blocking DNS resolver that can be used when threads are not appropriate. diff --git a/docs/releases.rst b/docs/releases.rst index a478821d78..5c7a106d87 100644 --- a/docs/releases.rst +++ b/docs/releases.rst @@ -4,6 +4,14 @@ Release notes .. toctree:: :maxdepth: 2 + releases/v6.4.2 + releases/v6.4.1 + releases/v6.4.0 + releases/v6.3.3 + releases/v6.3.2 + releases/v6.3.1 + releases/v6.3.0 + releases/v6.2.0 releases/v6.1.0 releases/v6.0.4 releases/v6.0.3 diff --git a/docs/releases/v3.0.1.rst b/docs/releases/v3.0.1.rst index 4511838c85..4d289f5004 100644 --- a/docs/releases/v3.0.1.rst +++ b/docs/releases/v3.0.1.rst @@ -11,7 +11,7 @@ Apr 8, 2013 * The `tornado.testing.gen_test` decorator will no longer be recognized as a (broken) test by ``nose``. * Work around a bug in Ubuntu 13.04 betas involving an incomplete backport - of the `ssl.match_hostname` function. + of the ``ssl.match_hostname`` function. * `tornado.websocket.websocket_connect` now fails cleanly when it attempts to connect to a non-websocket url. * ``tornado.testing.LogTrapTestCase`` once again works with byte strings diff --git a/docs/releases/v4.5.0.rst b/docs/releases/v4.5.0.rst index 5a4ce9e258..831fe5ce0e 100644 --- a/docs/releases/v4.5.0.rst +++ b/docs/releases/v4.5.0.rst @@ -178,10 +178,10 @@ Demos ~~~~~ - A new file upload demo is available in the `file_upload - `_ + `_ directory. - A new `.TCPClient` and `.TCPServer` demo is available in the - `tcpecho `_ directory. + `tcpecho `_ directory. - Minor updates have been made to several existing demos, including updates to more recent versions of jquery. diff --git a/docs/releases/v5.0.0.rst b/docs/releases/v5.0.0.rst index dd0bd02439..950b2e1739 100644 --- a/docs/releases/v5.0.0.rst +++ b/docs/releases/v5.0.0.rst @@ -27,7 +27,7 @@ Backwards-compatibility notes longer supported. (The `ssl` module was updated in version 2.7.9, although in some distributions the updates are present in builds with a lower version number. Tornado requires `ssl.SSLContext`, - `ssl.create_default_context`, and `ssl.match_hostname`) + `ssl.create_default_context`, and ``ssl.match_hostname``) - Versions of Python 3.5 prior to 3.5.2 are no longer supported due to a change in the async iterator protocol in that version. - The ``trollius`` project (`asyncio` backported to Python 2) is no diff --git a/docs/releases/v6.2.0.rst b/docs/releases/v6.2.0.rst new file mode 100644 index 0000000000..b0a69b293d --- /dev/null +++ b/docs/releases/v6.2.0.rst @@ -0,0 +1,130 @@ +What's new in Tornado 6.2.0 +=========================== + +Jul 3, 2022 +----------- + +Deprecation notice +~~~~~~~~~~~~~~~~~~ + +- April 2023 update: Python 3.12 reversed some of the changes described below. + In Tornado 6.3, `.AsyncTestCase`, `.AsyncHTTPTestCase`, and the behavior + of the `.IOLoop` constructor related to the ``make_current`` parameter + are no longer deprecated. +- Python 3.10 has begun the process of significant changes to the APIs for + managing the event loop. Calls to methods such as `asyncio.get_event_loop` may + now raise `DeprecationWarning` if no event loop is running. This has + significant impact on the patterns for initializing applications, and in + particular invalidates patterns that have long been the norm in Tornado's + documentation and actual usage. In the future (with some as-yet-unspecified + future version of Python), the old APIs will be removed. The new recommended + pattern is to start the event loop with `asyncio.run`. More detailed migration + guides will be coming in the future. + + - The `.IOLoop` constructor is deprecated unless the ``make_current=False`` + argument is used. Use `.IOLoop.current` when the loop is already running + instead. + - `.AsyncTestCase` (and `.AsyncHTTPTestCase`) are deprecated. Use + `unittest.IsolatedAsyncioTestCase` instead. + - Multi-process `.TCPServer.bind`/`.TCPServer.start` is deprecated. See + `.TCPServer` docs for supported alternatives. + - `.AnyThreadEventLoopPolicy` is deprecated. This class controls the creation of + the "current" event loop so it will be removed when that concept is no longer + supported. + - `.IOLoop.make_current` and `.IOLoop.clear_current` are deprecated. In the + future the concept of a "current" event loop as distinct from one that is + currently running will be removed. + +- ``TwistedResolver`` and ``CaresResolver`` are deprecated and will be + removed in Tornado 7.0. + +General changes +~~~~~~~~~~~~~~~ + +- The minimum supported Python version is now 3.7. +- Wheels are now published with the Python stable ABI (``abi3``) for + compatibility across versions of Python. +- SSL certificate verification and hostname checks are now enabled by default in + more places (primarily in client-side usage of `.SSLIOStream`). +- Various improvements to type hints throughout the package. +- CI has moved from Travis and Appveyor to Github Actions. + +`tornado.gen` +~~~~~~~~~~~~~ + +- Fixed a bug in which ``WaitIterator.current_index`` could be incorrect. +- ``tornado.gen.TimeoutError`` is now an alias for `asyncio.TimeoutError`. + +`tornado.http1connection` +~~~~~~~~~~~~~~~~~~~~~~~~~ + +- ``max_body_size`` may now be set to zero to disallow a non-empty body. +- ``Content-Encoding: gzip`` is now recognized case-insensitively. + +`tornado.httpclient` +~~~~~~~~~~~~~~~~~~~~ + +- ``curl_httpclient`` now supports non-ASCII (ISO-8859-1) header values, same as + ``simple_httpclient``. + +`tornado.ioloop` +~~~~~~~~~~~~~~~~ + +- `.PeriodicCallback` now understands coroutines and will not start multiple + copies if a previous invocation runs too long. +- `.PeriodicCallback` now accepts `datetime.timedelta` objects in addition to + numbers of milliseconds. +- Avoid logging "Event loop is closed" during shutdown-related race conditions. +- Tornado no longer calls `logging.basicConfig` when starting an IOLoop; this + has been unnecessary since Python 3.2 added a logger of last resort. +- The `.IOLoop` constructor now accepts an ``asyncio_loop`` keyword argument to + initialize with a specfied asyncio event loop. +- It is now possible to construct an `.IOLoop` on one thread (with + ``make_current=False``) and start it on a different thread. + +`tornado.iostream` +~~~~~~~~~~~~~~~~~~ + +- `.SSLIOStream` now supports reading more than 2GB at a time. +- ``IOStream.write`` now supports typed `memoryview` objects. + +`tornado.locale` +~~~~~~~~~~~~~~~~ + +- `.load_gettext_translations` no longer logs errors when language directories + exist but do not contain the expected file. + +`tornado.netutil` +~~~~~~~~~~~~~~~~~ + +- `.is_valid_ip` no longer raises exceptions when the input is too long. +- The default resolver now uses the same methods (and thread pool) as `asyncio`. + +`tornado.tcpserver` +~~~~~~~~~~~~~~~~~~~ + +- `.TCPServer.listen` now supports more arguments to pass through to + `.netutil.bind_sockets`. + +`tornado.testing` +~~~~~~~~~~~~~~~~~ + +- `.bind_unused_port` now takes an optional ``address`` argument. +- Wrapped test methods now include the ``__wrapped__`` attribute. + +`tornado.web` +~~~~~~~~~~~~~ + +- When using a custom `.StaticFileHandler` subclass, the ``reset()`` method is + now called on this subclass instead of the base class. +- Improved handling of the ``Accept-Language`` header. +- `.Application.listen` now supports more arguments to pass through to + `.netutil.bind_sockets`. + +`tornado.websocket` +~~~~~~~~~~~~~~~~~~~ + +- `.WebSocketClientConnection.write_message` now accepts `dict` arguments for + consistency with `.WebSocketHandler.write_message`. +- `.WebSocketClientConnection.write_message` now raises an exception as + documented if the connection is already closed. diff --git a/docs/releases/v6.3.0.rst b/docs/releases/v6.3.0.rst new file mode 100644 index 0000000000..218fc6530e --- /dev/null +++ b/docs/releases/v6.3.0.rst @@ -0,0 +1,101 @@ +What's new in Tornado 6.3.0 +=========================== + +Apr 17, 2023 +------------ + +Highlights +~~~~~~~~~~ + +- The new `.Application` setting ``xsrf_cookie_name`` can now be used to + take advantage of the ``__Host`` cookie prefix for improved security. + To use it, add ``{"xsrf_cookie_name": "__Host-xsrf", "xsrf_cookie_kwargs": + {"secure": True}}`` to your `.Application` settings. Note that this feature + currently only works when HTTPS is used. +- `.WSGIContainer` now supports running the application in a ``ThreadPoolExecutor`` so + the event loop is no longer blocked. +- `.AsyncTestCase` and `.AsyncHTTPTestCase`, which were deprecated in Tornado 6.2, + are no longer deprecated. +- WebSockets are now much faster at receiving large messages split into many + fragments. + +General changes +~~~~~~~~~~~~~~~ + +- Python 3.7 is no longer supported; the minimum supported Python version is 3.8. + Python 3.12 is now supported. +- To avoid spurious deprecation warnings, users of Python 3.10 should upgrade + to at least version 3.10.9, and users of Python 3.11 should upgrade to at least + version 3.11.1. +- Tornado submodules are now imported automatically on demand. This means it is + now possible to use a single ``import tornado`` statement and refer to objects + in submodules such as `tornado.web.RequestHandler`. + +Deprecation notices +~~~~~~~~~~~~~~~~~~~ + +- In Tornado 7.0, `tornado.testing.ExpectLog` will match ``WARNING`` + and above regardless of the current logging configuration, unless the + ``level`` argument is used. +- `.RequestHandler.get_secure_cookie` is now a deprecated alias for + `.RequestHandler.get_signed_cookie`. `.RequestHandler.set_secure_cookie` + is now a deprecated alias for `.RequestHandler.set_signed_cookie`. +- `.RequestHandler.clear_all_cookies` is deprecated. No direct replacement + is provided; `.RequestHandler.clear_cookie` should be used on individual + cookies. +- Calling the `.IOLoop` constructor without a ``make_current`` argument, which was + deprecated in Tornado 6.2, is no longer deprecated. +- `.AsyncTestCase` and `.AsyncHTTPTestCase`, which were deprecated in Tornado 6.2, + are no longer deprecated. +- `.AsyncTestCase.get_new_ioloop` is deprecated. + +``tornado.auth`` +~~~~~~~~~~~~~~~~ + +- New method `.GoogleOAuth2Mixin.get_google_oauth_settings` can now be overridden + to get credentials from a source other than the `.Application` settings. + +``tornado.gen`` +~~~~~~~~~~~~~~~ + +- `contextvars` now work properly when a ``@gen.coroutine`` calls a native coroutine. + +``tornado.options`` +~~~~~~~~~~~~~~~~~~~ + +- `~.OptionParser.parse_config_file` now recognizes single comma-separated strings (in addition to + lists of strings) for options with ``multiple=True``. + +``tornado.web`` +~~~~~~~~~~~~~~~ + +- New `.Application` setting ``xsrf_cookie_name`` can be used to change the + name of the XSRF cookie. This is most useful to take advantage of the + ``__Host-`` cookie prefix. +- `.RequestHandler.get_secure_cookie` and `.RequestHandler.set_secure_cookie` + (and related methods and attributes) have been renamed to + `~.RequestHandler.get_signed_cookie` and `~.RequestHandler.set_signed_cookie`. + This makes it more explicit what kind of security is provided, and avoids + confusion with the ``Secure`` cookie attribute and ``__Secure-`` cookie prefix. + The old names remain supported as deprecated aliases. +- `.RequestHandler.clear_cookie` now accepts all keyword arguments accepted by + `~.RequestHandler.set_cookie`. In some cases clearing a cookie requires certain + arguments to be passed the same way in which it was set. +- `.RequestHandler.clear_all_cookies` now accepts additional keyword arguments + for the same reason as ``clear_cookie``. However, since the requirements + for additional arguments mean that it cannot reliably clear all cookies, + this method is now deprecated. + + +``tornado.websocket`` +~~~~~~~~~~~~~~~~~~~~~ + +- It is now much faster (no longer quadratic) to receive large messages that + have been split into many fragments. +- `.websocket_connect` now accepts a ``resolver`` parameter. + +``tornado.wsgi`` +~~~~~~~~~~~~~~~~ + +- `.WSGIContainer` now accepts an ``executor`` parameter which can be used + to run the WSGI application on a thread pool. \ No newline at end of file diff --git a/docs/releases/v6.3.1.rst b/docs/releases/v6.3.1.rst new file mode 100644 index 0000000000..11886d0079 --- /dev/null +++ b/docs/releases/v6.3.1.rst @@ -0,0 +1,12 @@ +What's new in Tornado 6.3.1 +=========================== + +Apr 21, 2023 +------------ + +``tornado.web`` +~~~~~~~~~~~~~~~ + +- `.RequestHandler.set_cookie` once again accepts capitalized keyword arguments + for backwards compatibility. This is deprecated and in Tornado 7.0 only lowercase + arguments will be accepted. \ No newline at end of file diff --git a/docs/releases/v6.3.2.rst b/docs/releases/v6.3.2.rst new file mode 100644 index 0000000000..250a6e4eb4 --- /dev/null +++ b/docs/releases/v6.3.2.rst @@ -0,0 +1,11 @@ +What's new in Tornado 6.3.2 +=========================== + +May 13, 2023 +------------ + +Security improvements +~~~~~~~~~~~~~~~~~~~~~ + +- Fixed an open redirect vulnerability in StaticFileHandler under certain + configurations. \ No newline at end of file diff --git a/docs/releases/v6.3.3.rst b/docs/releases/v6.3.3.rst new file mode 100644 index 0000000000..7fe0110fda --- /dev/null +++ b/docs/releases/v6.3.3.rst @@ -0,0 +1,12 @@ +What's new in Tornado 6.3.3 +=========================== + +Aug 11, 2023 +------------ + +Security improvements +~~~~~~~~~~~~~~~~~~~~~ + +- The ``Content-Length`` header and ``chunked`` ``Transfer-Encoding`` sizes are now parsed + more strictly (according to the relevant RFCs) to avoid potential request-smuggling + vulnerabilities when deployed behind certain proxies. diff --git a/docs/releases/v6.4.0.rst b/docs/releases/v6.4.0.rst new file mode 100644 index 0000000000..d1e099a2df --- /dev/null +++ b/docs/releases/v6.4.0.rst @@ -0,0 +1,91 @@ +What's new in Tornado 6.4.0 +=========================== + +Nov 28, 2023 +------------ + +General Changes +~~~~~~~~~~~~~~~ + +- Python 3.12 is now supported. Older versions of Tornado will work on Python 3.12 but may log + deprecation warnings. + +Deprecation Notices +~~~~~~~~~~~~~~~~~~~ + +- `.IOLoop.add_callback_from_signal` is suspected to have been broken since Tornado 5.0 and will be + removed in version 7.0. Use `asyncio.loop.add_signal_handler` instead. +- The ``client_secret`` argument to `.OAuth2Mixin.authorize_redirect` is deprecated and will be + removed in Tornado 7.0. This argument has never been used and other similar methods in this module + don't have it. +- `.TwitterMixin` is deprecated and will be removed in the future. + +``tornado.auth`` +~~~~~~~~~~~~~~~~ + +- The ``client_secret`` argument to `.OAuth2Mixin.authorize_redirect` is deprecated and will be + removed in Tornado 7.0. This argument has never been used and other similar methods in this module + don't have it. +- `.TwitterMixin` is deprecated and will be removed in the future. + +``tornado.autoreload`` +~~~~~~~~~~~~~~~~~~~~~~ + +- Autoreload can now be used when the program is run as a directory rather than a file or module. +- New CLI flag ``--until-success`` re-runs the program on any failure but stops after the first + successful run. + +``tornado.concurrent`` +~~~~~~~~~~~~~~~~~~~~~~ + +- Fixed reference cycles that could lead to increased memory usage. + +``tornado.escape`` +~~~~~~~~~~~~~~~~~~ + +- Several methods in this module now simply pass through to their equivalents in the standard + library. + +``tornado.gen`` +~~~~~~~~~~~~~~~ + +- This module now holds a strong reference to all running `asyncio.Task` objects it creates. This + prevents premature garbage collection which could cause warnings like "Task was destroyed but it + is pending!". + +``tornado.ioloop`` +~~~~~~~~~~~~~~~~~~ + +- `.IOLoop.add_callback_from_signal` is suspected to have been broken since Tornado 5.0 and will be + removed in version 7.0. Use `asyncio.loop.add_signal_handler` instead. +- The type annotation for `.IOLoop.run_in_executor` has been updated to match the updated signature + of `asyncio.loop.run_in_executor`. +- Fixed reference cycles that could lead to increased memory usage. + +``tornado.locale`` +~~~~~~~~~~~~~~~~~~ + +- `.format_timestamp` now supports "aware" datetime objects. + +``tornado.platform.asyncio`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- The shutdown protocol for `.AddThreadSelectorEventLoop` now requires the use of `asyncio.run` or + `asyncio.loop.shutdown_asyncgens` to avoid leaking the thread. +- Introduced `.SelectorThread` class containing the core functionality of + `.AddThreadSelectorEventLoop`. +- The ``close()`` method of `.AddThreadSelectorEventLoop` is now idempotent. + +``tornado.web`` +~~~~~~~~~~~~~~~ + +- `.StaticFileHandler.get_modified_time` now supports "aware" datetime objects and the default + implementation now returns aware objects. + +``tornado.websocket`` +~~~~~~~~~~~~~~~~~~~~~ + +- Unclosed client connections now reliably log a warning. Previously the warning was dependent on + garbage collection and whether the ``ping_interval`` option was used. +- The ``subprotocols`` argument to `.WebSocketClientConnection` now defaults to None instead of an + empty list (which was mutable and reused) diff --git a/docs/releases/v6.4.1.rst b/docs/releases/v6.4.1.rst new file mode 100644 index 0000000000..8d72b2b2f8 --- /dev/null +++ b/docs/releases/v6.4.1.rst @@ -0,0 +1,41 @@ +What's new in Tornado 6.4.1 +=========================== + +Jun 6, 2024 +----------- + +Security Improvements +~~~~~~~~~~~~~~~~~~~~~ + +- Parsing of the ``Transfer-Encoding`` header is now stricter. Unexpected transfer-encoding values + were previously ignored and treated as the HTTP/1.0 default of read-until-close. This can lead to + framing issues with certain proxies. We now treat any unexpected value as an error. +- Handling of whitespace in headers now matches the RFC more closely. Only space and tab characters + are treated as whitespace and stripped from the beginning and end of header values. Other unicode + whitespace characters are now left alone. This could also lead to framing issues with certain + proxies. +- ``tornado.curl_httpclient`` now prohibits carriage return and linefeed headers in HTTP headers + (matching the behavior of ``simple_httpclient``). These characters could be used for header + injection or request smuggling if untrusted data were used in headers. + +General Changes +~~~~~~~~~~~~~~~ + +`tornado.iostream` +~~~~~~~~~~~~~~~~~~ + +- `.SSLIOStream` now understands changes to error codes from OpenSSL 3.2. The main result of this + change is to reduce the noise in the logs for certain errors. + +``tornado.simple_httpclient`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- ``simple_httpclient`` now prohibits carriage return characters in HTTP headers. It had previously + prohibited only linefeed characters. + +`tornado.testing` +~~~~~~~~~~~~~~~~~ + +- `.AsyncTestCase` subclasses can now be instantiated without being associated with a test + method. This improves compatibility with test discovery in Pytest 8.2. + diff --git a/docs/releases/v6.4.2.rst b/docs/releases/v6.4.2.rst new file mode 100644 index 0000000000..0dc567d171 --- /dev/null +++ b/docs/releases/v6.4.2.rst @@ -0,0 +1,12 @@ +What's new in Tornado 6.4.2 +=========================== + +Nov 21, 2024 +------------ + +Security Improvements +~~~~~~~~~~~~~~~~~~~~~ + +- Parsing of the cookie header is now much more efficient. The older algorithm sometimes had + quadratic performance which allowed for a denial-of-service attack in which the server would spend + excessive CPU time parsing cookies and block the event loop. This change fixes CVE-2024-7592. \ No newline at end of file diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index 7599072be1..0000000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -sphinx>1.8.2,<2.1 -sphinxcontrib-asyncio==0.2.0 -sphinx_rtd_theme -Twisted diff --git a/docs/twisted.rst b/docs/twisted.rst index a5d971a040..5d8fe8fbc8 100644 --- a/docs/twisted.rst +++ b/docs/twisted.rst @@ -1,10 +1,60 @@ ``tornado.platform.twisted`` --- Bridges between Twisted and Tornado -======================================================================== +==================================================================== -.. automodule:: tornado.platform.twisted +.. module:: tornado.platform.twisted - Twisted DNS resolver - -------------------- +.. deprecated:: 6.0 + + This module is no longer recommended for new code. Instead of using + direct integration between Tornado and Twisted, new applications should + rely on the integration with ``asyncio`` provided by both packages. + +Importing this module has the side effect of registering Twisted's ``Deferred`` +class with Tornado's ``@gen.coroutine`` so that ``Deferred`` objects can be +used with ``yield`` in coroutines using this decorator (importing this module has +no effect on native coroutines using ``async def``). + +.. function:: install() + + Install ``AsyncioSelectorReactor`` as the default Twisted reactor. + + .. deprecated:: 5.1 + + This function is provided for backwards compatibility; code + that does not require compatibility with older versions of + Tornado should use + ``twisted.internet.asyncioreactor.install()`` directly. + + .. versionchanged:: 6.0.3 + + In Tornado 5.x and before, this function installed a reactor + based on the Tornado ``IOLoop``. When that reactor + implementation was removed in Tornado 6.0.0, this function was + removed as well. It was restored in Tornado 6.0.3 using the + ``asyncio`` reactor instead. + +Twisted DNS resolver +-------------------- + +.. class:: TwistedResolver + + Twisted-based asynchronous resolver. + + This is a non-blocking and non-threaded resolver. It is + recommended only when threads cannot be used, since it has + limitations compared to the standard ``getaddrinfo``-based + `~tornado.netutil.Resolver` and + `~tornado.netutil.DefaultExecutorResolver`. Specifically, it returns at + most one result, and arguments other than ``host`` and ``family`` + are ignored. It may fail to resolve when ``family`` is not + ``socket.AF_UNSPEC``. + + Requires Twisted 12.1 or newer. + + .. versionchanged:: 5.0 + The ``io_loop`` argument (deprecated since version 4.1) has been removed. + + .. deprecated:: 6.2 + This class is deprecated and will be removed in Tornado 7.0. Use the default + thread-based resolver instead. - .. autoclass:: TwistedResolver - :members: diff --git a/docs/util.rst b/docs/util.rst index 0a2012942e..a4e35ad652 100644 --- a/docs/util.rst +++ b/docs/util.rst @@ -7,3 +7,15 @@ .. automodule:: tornado.util :members: + + .. class:: TimeoutError + + Exception raised by `.gen.with_timeout` and `.IOLoop.run_sync`. + + .. versionchanged:: 5.0 + Unified ``tornado.gen.TimeoutError`` and + ``tornado.ioloop.TimeoutError`` as ``tornado.util.TimeoutError``. + Both former names remain as aliases. + + .. versionchanged:: 6.2 + ``tornado.util.TimeoutError`` is an alias to :py:class:`asyncio.TimeoutError` diff --git a/docs/web.rst b/docs/web.rst index 720d75678e..956336bda0 100644 --- a/docs/web.rst +++ b/docs/web.rst @@ -117,9 +117,27 @@ .. automethod:: RequestHandler.set_cookie .. automethod:: RequestHandler.clear_cookie .. automethod:: RequestHandler.clear_all_cookies - .. automethod:: RequestHandler.get_secure_cookie - .. automethod:: RequestHandler.get_secure_cookie_key_version - .. automethod:: RequestHandler.set_secure_cookie + .. automethod:: RequestHandler.get_signed_cookie + .. automethod:: RequestHandler.get_signed_cookie_key_version + .. automethod:: RequestHandler.set_signed_cookie + .. method:: RequestHandler.get_secure_cookie + + Deprecated alias for ``get_signed_cookie``. + + .. deprecated:: 6.3 + + .. method:: RequestHandler.get_secure_cookie_key_version + + Deprecated alias for ``get_signed_cookie_key_version``. + + .. deprecated:: 6.3 + + .. method:: RequestHandler.set_secure_cookie + + Deprecated alias for ``set_signed_cookie``. + + .. deprecated:: 6.3 + .. automethod:: RequestHandler.create_signed_value .. autodata:: MIN_SUPPORTED_SIGNED_VALUE_VERSION .. autodata:: MAX_SUPPORTED_SIGNED_VALUE_VERSION @@ -217,9 +235,9 @@ Authentication and security settings: - * ``cookie_secret``: Used by `RequestHandler.get_secure_cookie` - and `.set_secure_cookie` to sign cookies. - * ``key_version``: Used by requestHandler `.set_secure_cookie` + * ``cookie_secret``: Used by `RequestHandler.get_signed_cookie` + and `.set_signed_cookie` to sign cookies. + * ``key_version``: Used by requestHandler `.set_signed_cookie` to sign cookies with a specific key when ``cookie_secret`` is a key dictionary. * ``login_url``: The `authenticated` decorator will redirect @@ -235,12 +253,21 @@ * ``xsrf_cookie_kwargs``: May be set to a dictionary of additional arguments to be passed to `.RequestHandler.set_cookie` for the XSRF cookie. + * ``xsrf_cookie_name``: Controls the name used for the XSRF + cookie (default ``_xsrf``). The intended use is to take + advantage of `cookie prefixes`_. Note that cookie prefixes + interact with other cookie flags, so they must be combined + with ``xsrf_cookie_kwargs``, such as + ``{"xsrf_cookie_name": "__Host-xsrf", "xsrf_cookie_kwargs": + {"secure": True}}`` * ``twitter_consumer_key``, ``twitter_consumer_secret``, ``friendfeed_consumer_key``, ``friendfeed_consumer_secret``, ``google_consumer_key``, ``google_consumer_secret``, ``facebook_api_key``, ``facebook_secret``: Used in the `tornado.auth` module to authenticate to various APIs. + .. _cookie prefixes: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#cookie_prefixes + Template settings: * ``autoescape``: Controls automatic escaping for templates. diff --git a/docs/websocket.rst b/docs/websocket.rst index 76bc05227e..b56a4ec30a 100644 --- a/docs/websocket.rst +++ b/docs/websocket.rst @@ -3,7 +3,7 @@ .. testsetup:: - import tornado.websocket + import tornado .. automodule:: tornado.websocket diff --git a/maint/benchmark/benchmark.py b/maint/benchmark/benchmark.py index d1f32d33ef..845c3ff2e8 100755 --- a/maint/benchmark/benchmark.py +++ b/maint/benchmark/benchmark.py @@ -14,18 +14,11 @@ # % sort time # % stats 20 -from tornado.ioloop import IOLoop from tornado.options import define, options, parse_command_line from tornado.web import RequestHandler, Application +import asyncio import random -import signal -import subprocess - -try: - xrange -except NameError: - xrange = range # choose a random port to avoid colliding with TIME_WAIT sockets left over # from previous runs. @@ -44,8 +37,6 @@ # --n=15000 for its JIT to reach full effectiveness define("num_runs", type=int, default=1) -define("ioloop", type=str, default=None) - class RootHandler(RequestHandler): def get(self): @@ -55,24 +46,16 @@ def _log(self): pass -def handle_sigchld(sig, frame): - IOLoop.current().add_callback_from_signal(IOLoop.current().stop) - - def main(): parse_command_line() - if options.ioloop: - IOLoop.configure(options.ioloop) - for i in xrange(options.num_runs): - run() + for i in range(options.num_runs): + asyncio.run(run()) -def run(): - io_loop = IOLoop(make_current=True) +async def run(): app = Application([("/", RootHandler)]) port = random.randrange(options.min_port, options.max_port) - app.listen(port, address='127.0.0.1') - signal.signal(signal.SIGCHLD, handle_sigchld) + app.listen(port, address="127.0.0.1") args = ["ab"] args.extend(["-n", str(options.n)]) args.extend(["-c", str(options.c)]) @@ -82,11 +65,9 @@ def run(): # just stops the progress messages printed to stderr args.append("-q") args.append("http://127.0.0.1:%d/" % port) - subprocess.Popen(args) - io_loop.start() - io_loop.close() - io_loop.clear_current() + proc = await asyncio.create_subprocess_exec(*args) + await proc.wait() -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/maint/circlerefs/circlerefs.py b/maint/circlerefs/circlerefs.py deleted file mode 100755 index bd8214aa82..0000000000 --- a/maint/circlerefs/circlerefs.py +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env python -"""Test script to find circular references. - -Circular references are not leaks per se, because they will eventually -be GC'd. However, on CPython, they prevent the reference-counting fast -path from being used and instead rely on the slower full GC. This -increases memory footprint and CPU overhead, so we try to eliminate -circular references created by normal operation. -""" - -import gc -import traceback -import types -from tornado import web, ioloop, gen, httpclient - - -def find_circular_references(garbage=None): - def inner(level): - for item in level: - item_id = id(item) - if item_id not in garbage_ids: - continue - if item_id in visited_ids: - continue - if item_id in stack_ids: - candidate = stack[stack.index(item):] - candidate.append(item) - found.append(candidate) - continue - - stack.append(item) - stack_ids.add(item_id) - inner(gc.get_referents(item)) - stack.pop() - stack_ids.remove(item_id) - visited_ids.add(item_id) - - garbage = garbage or gc.garbage - found = [] - stack = [] - stack_ids = set() - garbage_ids = set(map(id, garbage)) - visited_ids = set() - - inner(garbage) - inner = None - return found - - -class CollectHandler(web.RequestHandler): - @gen.coroutine - def get(self): - self.write("Collected: {}\n".format(gc.collect())) - self.write("Garbage: {}\n".format(len(gc.garbage))) - for circular in find_circular_references(): - print('\n==========\n Circular \n==========') - for item in circular: - print(' ', repr(item)) - for item in circular: - if isinstance(item, types.FrameType): - print('\nLocals:', item.f_locals) - print('\nTraceback:', repr(item)) - traceback.print_stack(item) - - -class DummyHandler(web.RequestHandler): - @gen.coroutine - def get(self): - self.write('ok\n') - - -class DummyAsyncHandler(web.RequestHandler): - @gen.coroutine - def get(self): - raise web.Finish('ok\n') - - -application = web.Application([ - (r'/dummy/', DummyHandler), - (r'/dummyasync/', DummyAsyncHandler), - (r'/collect/', CollectHandler), -], debug=True) - - -@gen.coroutine -def main(): - gc.disable() - gc.collect() - gc.set_debug(gc.DEBUG_STATS | gc.DEBUG_SAVEALL) - print('GC disabled') - - print("Start on 8888") - application.listen(8888, '127.0.0.1') - - # Do a little work. Alternately, could leave this script running and - # poke at it with a browser. - client = httpclient.AsyncHTTPClient() - yield client.fetch('http://127.0.0.1:8888/dummy/') - yield client.fetch('http://127.0.0.1:8888/dummyasync/', raise_error=False) - - # Now report on the results. - resp = yield client.fetch('http://127.0.0.1:8888/collect/') - print(resp.body) - - -if __name__ == "__main__": - ioloop.IOLoop.current().run_sync(main) diff --git a/maint/requirements.in b/maint/requirements.in deleted file mode 100644 index df4dc637a3..0000000000 --- a/maint/requirements.in +++ /dev/null @@ -1,25 +0,0 @@ -# Requirements for tools used in the development of tornado. -# Use virtualenv instead of venv; tox seems to get confused otherwise. -# -# maint/requirements.txt contains the pinned versions of all direct and -# indirect dependencies; this file only contains direct dependencies -# and is useful for upgrading. - -# Tornado's optional dependencies -Twisted -pycares -pycurl - -# Other useful tools -Sphinx>1.8.2 -black -coverage -flake8 -mypy==0.630 -pep8 -pyflakes -sphinxcontrib-asyncio -sphinx-rtd-theme -tox -twine -virtualenv diff --git a/maint/requirements.txt b/maint/requirements.txt deleted file mode 100644 index 6707ee8766..0000000000 --- a/maint/requirements.txt +++ /dev/null @@ -1,64 +0,0 @@ -alabaster==0.7.12 -appdirs==1.4.3 -attrs==19.1.0 -automat==0.7.0 -babel==2.6.0 -black==19.3b0 -bleach==3.1.1 -certifi==2019.3.9 -cffi==1.12.3 -chardet==3.0.4 -click==7.0 -constantly==15.1.0 -coverage==4.5.3 -docutils==0.14 -entrypoints==0.3 -filelock==3.0.10 -flake8==3.7.7 -hyperlink==19.0.0 -idna==2.8 -imagesize==1.1.0 -incremental==17.5.0 -jinja2==2.10.1 -markupsafe==1.1.1 -mccabe==0.6.1 -mypy-extensions==0.4.1 -mypy==0.630 -packaging==19.0 -pep8==1.7.1 -pkginfo==1.5.0.1 -pluggy==0.9.0 -py==1.8.0 -pycares==3.0.0 -pycodestyle==2.5.0 -pycparser==2.19 -pycurl==7.43.0.2 -pyflakes==2.1.1 -pygments==2.3.1 -pyhamcrest==1.9.0 -pyparsing==2.4.0 -pytz==2019.1 -readme-renderer==24.0 -requests-toolbelt==0.9.1 -requests==2.21.0 -six==1.12.0 -snowballstemmer==1.2.1 -sphinx-rtd-theme==0.4.3 -sphinx==2.0.1 -sphinxcontrib-applehelp==1.0.1 -sphinxcontrib-asyncio==0.2.0 -sphinxcontrib-devhelp==1.0.1 -sphinxcontrib-htmlhelp==1.0.2 -sphinxcontrib-jsmath==1.0.1 -sphinxcontrib-qthelp==1.0.2 -sphinxcontrib-serializinghtml==1.1.3 -toml==0.10.0 -tox==3.9.0 -tqdm==4.31.1 -twine==1.13.0 -twisted==19.7.0 -typed-ast==1.1.2 -urllib3==1.24.3 -virtualenv==16.5.0 -webencodings==0.5.1 -zope.interface==4.6.0 diff --git a/maint/scripts/download_wheels.py b/maint/scripts/download_wheels.py deleted file mode 100755 index 03379058d5..0000000000 --- a/maint/scripts/download_wheels.py +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env python3 - -import asyncio -import json -import pathlib -import sys -from tornado.httpclient import AsyncHTTPClient - -BASE_URL = "https://ci.appveyor.com/api" - - -async def fetch_job(directory, job): - http = AsyncHTTPClient() - artifacts = await http.fetch(f"{BASE_URL}/buildjobs/{job}/artifacts") - paths = [pathlib.PurePosixPath(a["fileName"]) for a in json.loads(artifacts.body)] - - for path in paths: - artifact = await http.fetch(f"{BASE_URL}/buildjobs/{job}/artifacts/{path}") - with open(directory.joinpath(path.name), "wb") as f: - f.write(artifact.body) - - -async def main(): - http = AsyncHTTPClient() - try: - _, version = sys.argv - except ValueError: - print("usage: maint/scripts/download_wheels.py v6.0.1", file=sys.stderr) - sys.exit(1) - - directory = pathlib.Path(f"downloads-{version}") - directory.mkdir(exist_ok=True) - - build = await http.fetch(f"{BASE_URL}/projects/bdarnell/tornado/branch/{version}") - jobs = [job["jobId"] for job in json.loads(build.body)["build"]["jobs"]] - - await asyncio.gather(*(fetch_job(directory, job) for job in jobs)) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/maint/scripts/test_resolvers.py b/maint/scripts/test_resolvers.py index 82dec30e66..aea3a61f54 100755 --- a/maint/scripts/test_resolvers.py +++ b/maint/scripts/test_resolvers.py @@ -4,7 +4,7 @@ from tornado import gen from tornado.ioloop import IOLoop -from tornado.netutil import Resolver, ThreadedResolver +from tornado.netutil import Resolver, ThreadedResolver, DefaultExecutorResolver from tornado.options import parse_command_line, define, options try: @@ -29,7 +29,7 @@ def main(): args = ['localhost', 'www.google.com', 'www.facebook.com', 'www.dropbox.com'] - resolvers = [Resolver(), ThreadedResolver()] + resolvers = [Resolver(), ThreadedResolver(), DefaultExecutorResolver()] if twisted is not None: from tornado.platform.twisted import TwistedResolver diff --git a/maint/test/cython/tox.ini b/maint/test/cython/tox.ini index bbf8f15748..c79ab7db5e 100644 --- a/maint/test/cython/tox.ini +++ b/maint/test/cython/tox.ini @@ -1,6 +1,6 @@ [tox] # This currently segfaults on pypy. -envlist = py27,py35,py36 +envlist = py27,py36 [testenv] deps = @@ -13,5 +13,4 @@ commands = python -m unittest cythonapp_test # defaults for the others. basepython = py27: python2.7 - py35: python3.5 py36: python3.6 diff --git a/maint/test/websocket/fuzzingclient.json b/maint/test/websocket/fuzzingclient.json index 7b4cb318cf..2ac091f37a 100644 --- a/maint/test/websocket/fuzzingclient.json +++ b/maint/test/websocket/fuzzingclient.json @@ -1,17 +1,42 @@ { - "options": {"failByDrop": false}, - "outdir": "./reports/servers", - - "servers": [ - {"agent": "Tornado/py27", "url": "ws://localhost:9001", - "options": {"version": 18}}, - {"agent": "Tornado/py35", "url": "ws://localhost:9002", - "options": {"version": 18}}, - {"agent": "Tornado/pypy", "url": "ws://localhost:9003", - "options": {"version": 18}} - ], - - "cases": ["*"], - "exclude-cases": ["9.*", "12.*.1","12.2.*", "12.3.*", "12.4.*", "12.5.*", "13.*.1"], - "exclude-agent-cases": {} -} + "options": { + "failByDrop": false + }, + "outdir": "./reports/servers", + "servers": [ + { + "agent": "Tornado/py27", + "url": "ws://localhost:9001", + "options": { + "version": 18 + } + }, + { + "agent": "Tornado/py39", + "url": "ws://localhost:9002", + "options": { + "version": 18 + } + }, + { + "agent": "Tornado/pypy", + "url": "ws://localhost:9003", + "options": { + "version": 18 + } + } + ], + "cases": [ + "*" + ], + "exclude-cases": [ + "9.*", + "12.*.1", + "12.2.*", + "12.3.*", + "12.4.*", + "12.5.*", + "13.*.1" + ], + "exclude-agent-cases": {} +} \ No newline at end of file diff --git a/maint/test/websocket/run-client.sh b/maint/test/websocket/run-client.sh index bd35f4dca8..f32e72aff9 100755 --- a/maint/test/websocket/run-client.sh +++ b/maint/test/websocket/run-client.sh @@ -10,7 +10,7 @@ FUZZING_SERVER_PID=$! sleep 1 .tox/py27/bin/python client.py --name='Tornado/py27' -.tox/py35/bin/python client.py --name='Tornado/py35' +.tox/py39/bin/python client.py --name='Tornado/py39' .tox/pypy/bin/python client.py --name='Tornado/pypy' kill $FUZZING_SERVER_PID diff --git a/maint/test/websocket/run-server.sh b/maint/test/websocket/run-server.sh index 2a83871366..401795a005 100755 --- a/maint/test/websocket/run-server.sh +++ b/maint/test/websocket/run-server.sh @@ -15,8 +15,8 @@ tox .tox/py27/bin/python server.py --port=9001 & PY27_SERVER_PID=$! -.tox/py35/bin/python server.py --port=9002 & -PY35_SERVER_PID=$! +.tox/py39/bin/python server.py --port=9002 & +PY39_SERVER_PID=$! .tox/pypy/bin/python server.py --port=9003 & PYPY_SERVER_PID=$! @@ -26,7 +26,7 @@ sleep 1 .tox/py27/bin/wstest -m fuzzingclient kill $PY27_SERVER_PID -kill $PY35_SERVER_PID +kill $PY39_SERVER_PID kill $PYPY_SERVER_PID wait diff --git a/maint/test/websocket/tox.ini b/maint/test/websocket/tox.ini index 289d127b10..7c4b72ebc6 100644 --- a/maint/test/websocket/tox.ini +++ b/maint/test/websocket/tox.ini @@ -2,7 +2,7 @@ # to install autobahn and build the speedups module. # See run.sh for the real test runner. [tox] -envlist = py27, py35, pypy +envlist = py27, py39, pypy setupdir=../../.. [testenv] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000..fa75e066f7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.cibuildwheel] +build = "cp3[89]* cp310* cp311* cp312*" +test-command = "python -m tornado.test" + +[tool.cibuildwheel.macos] +archs = "x86_64 universal2" +# The arm portion of a universal wheel is a cross-compile and cannot +# be tested on an x86 host. This must be configured explicitly to silence +# a warning. +test-skip = "*_universal2:arm64" + +[tool.cibuildwheel.windows] +# TODO: figure out what's going on with these occasional log messages. +test-command = "python -m tornado.test --fail-if-logs=false" + +[tool.cibuildwheel.linux] +# Build wheels for the native platform (i.e. x86) as well as an emulated +# build for aarch64. +archs = "auto aarch64" + +[[tool.cibuildwheel.overrides]] +# The emulated arm build is quite slow, so only run a portion of the test +# suite. websocket_test is the most platform-dependent part of the tests +# because it uses the C speedups module. +select = "*linux_aarch64" +test-command = "python -m tornado.test tornado.test.websocket_test" diff --git a/requirements.in b/requirements.in new file mode 100644 index 0000000000..997f8953a5 --- /dev/null +++ b/requirements.in @@ -0,0 +1,9 @@ +black +flake8 +mypy>=0.941 +pip-tools +sphinx<6 +sphinxcontrib-asyncio +sphinx_rtd_theme +types-pycurl +tox diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000..9118bdfde6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,127 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile +# +alabaster==0.7.13 + # via sphinx +babel==2.11.0 + # via sphinx +black==24.4.2 + # via -r requirements.in +build==0.10.0 + # via pip-tools +cachetools==5.3.1 + # via tox +certifi==2023.7.22 + # via requests +chardet==5.1.0 + # via tox +charset-normalizer==3.0.1 + # via requests +click==8.1.3 + # via + # black + # pip-tools +colorama==0.4.6 + # via tox +distlib==0.3.6 + # via virtualenv +docutils==0.17.1 + # via + # sphinx + # sphinx-rtd-theme +filelock==3.12.0 + # via + # tox + # virtualenv +flake8==6.0.0 + # via -r requirements.in +idna==3.7 + # via requests +imagesize==1.4.1 + # via sphinx +jinja2==3.1.4 + # via sphinx +markupsafe==2.1.2 + # via jinja2 +mccabe==0.7.0 + # via flake8 +mypy==1.0.1 + # via -r requirements.in +mypy-extensions==0.4.3 + # via + # black + # mypy +packaging==23.1 + # via + # black + # build + # pyproject-api + # sphinx + # tox +pathspec==0.10.3 + # via black +pip-tools==7.1.0 + # via -r requirements.in +platformdirs==3.5.1 + # via + # black + # tox + # virtualenv +pluggy==1.0.0 + # via tox +pycodestyle==2.10.0 + # via flake8 +pyflakes==3.0.1 + # via flake8 +pygments==2.15.0 + # via sphinx +pyproject-api==1.5.1 + # via tox +pyproject-hooks==1.0.0 + # via build +pytz==2022.7.1 + # via babel +requests==2.32.2 + # via sphinx +snowballstemmer==2.2.0 + # via sphinx +sphinx==5.3.0 + # via + # -r requirements.in + # sphinx-rtd-theme + # sphinxcontrib-asyncio +sphinx-rtd-theme==1.1.1 + # via -r requirements.in +sphinxcontrib-applehelp==1.0.3 + # via sphinx +sphinxcontrib-asyncio==0.3.0 + # via -r requirements.in +sphinxcontrib-devhelp==1.0.2 + # via sphinx +sphinxcontrib-htmlhelp==2.0.0 + # via sphinx +sphinxcontrib-jsmath==1.0.1 + # via sphinx +sphinxcontrib-qthelp==1.0.3 + # via sphinx +sphinxcontrib-serializinghtml==1.1.5 + # via sphinx +tox==4.6.0 + # via -r requirements.in +types-pycurl==7.45.2.0 + # via -r requirements.in +typing-extensions==4.4.0 + # via mypy +urllib3==1.26.18 + # via requests +virtualenv==20.23.0 + # via tox +wheel==0.38.4 + # via pip-tools + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/setup.cfg b/setup.cfg index e00a914f3c..53b16dff17 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,7 +2,7 @@ license_file = LICENSE [mypy] -python_version = 3.5 +python_version = 3.8 no_implicit_optional = True [mypy-tornado.*,tornado.platform.*] diff --git a/setup.py b/setup.py index e627b1a79f..8025ad972b 100644 --- a/setup.py +++ b/setup.py @@ -17,98 +17,12 @@ import os import platform -import sys -import warnings +import setuptools try: - # Use setuptools if available, for install_requires (among other things). - import setuptools - from setuptools import setup + import wheel.bdist_wheel except ImportError: - setuptools = None - from distutils.core import setup - -from distutils.core import Extension - -# The following code is copied from -# https://github.com/mongodb/mongo-python-driver/blob/master/setup.py -# to support installing without the extension on platforms where -# no compiler is available. -from distutils.command.build_ext import build_ext - - -class custom_build_ext(build_ext): - """Allow C extension building to fail. - - The C extension speeds up websocket masking, but is not essential. - """ - - warning_message = """ -******************************************************************** -WARNING: %s could not -be compiled. No C extensions are essential for Tornado to run, -although they do result in significant speed improvements for -websockets. -%s - -Here are some hints for popular operating systems: - -If you are seeing this message on Linux you probably need to -install GCC and/or the Python development package for your -version of Python. - -Debian and Ubuntu users should issue the following command: - - $ sudo apt-get install build-essential python-dev - -RedHat and CentOS users should issue the following command: - - $ sudo yum install gcc python-devel - -Fedora users should issue the following command: - - $ sudo dnf install gcc python-devel - -MacOS users should run: - - $ xcode-select --install - -******************************************************************** -""" - - def run(self): - try: - build_ext.run(self) - except Exception: - e = sys.exc_info()[1] - sys.stdout.write("%s\n" % str(e)) - warnings.warn( - self.warning_message - % ( - "Extension modules", - "There was an issue with " - "your platform configuration" - " - see above.", - ) - ) - - def build_extension(self, ext): - name = ext.name - try: - build_ext.build_extension(self, ext) - except Exception: - e = sys.exc_info()[1] - sys.stdout.write("%s\n" % str(e)) - warnings.warn( - self.warning_message - % ( - "The %s extension " "module" % (name,), - "The output above " - "this warning shows how " - "the compilation " - "failed.", - ) - ) + wheel = None kwargs = {} @@ -120,6 +34,7 @@ def build_extension(self, ext): with open("README.rst") as f: kwargs["long_description"] = f.read() + kwargs["long_description_content_type"] = "text/x-rst" if ( platform.python_implementation() == "CPython" @@ -128,22 +43,36 @@ def build_extension(self, ext): # This extension builds and works on pypy as well, although pypy's jit # produces equivalent performance. kwargs["ext_modules"] = [ - Extension("tornado.speedups", sources=["tornado/speedups.c"]) + setuptools.Extension( + "tornado.speedups", + sources=["tornado/speedups.c"], + # Unless the user has specified that the extension is mandatory, + # fall back to the pure-python implementation on any build failure. + optional=os.environ.get("TORNADO_EXTENSION") != "1", + # Use the stable ABI so our wheels are compatible across python + # versions. + py_limited_api=True, + define_macros=[("Py_LIMITED_API", "0x03080000")], + ) ] - if os.environ.get("TORNADO_EXTENSION") != "1": - # Unless the user has specified that the extension is mandatory, - # fall back to the pure-python implementation on any build failure. - kwargs["cmdclass"] = {"build_ext": custom_build_ext} +if wheel is not None: + # From https://github.com/joerick/python-abi3-package-sample/blob/main/setup.py + class bdist_wheel_abi3(wheel.bdist_wheel.bdist_wheel): + def get_tag(self): + python, abi, plat = super().get_tag() + if python.startswith("cp"): + return "cp38", "abi3", plat + return python, abi, plat -if setuptools is not None: - python_requires = ">= 3.5" - kwargs["python_requires"] = python_requires + kwargs["cmdclass"] = {"bdist_wheel": bdist_wheel_abi3} -setup( + +setuptools.setup( name="tornado", version=version, + python_requires=">= 3.8", packages=["tornado", "tornado.test", "tornado.platform"], package_data={ # data files need to be listed both here (which determines what gets @@ -172,7 +101,10 @@ def build_extension(self, ext): author="Facebook", author_email="python-tornado@googlegroups.com", url="http://www.tornadoweb.org/", - license="http://www.apache.org/licenses/LICENSE-2.0", + project_urls={ + "Source": "https://github.com/tornadoweb/tornado", + }, + license="Apache-2.0", description=( "Tornado is a Python web framework and asynchronous networking library," " originally developed at FriendFeed." @@ -180,11 +112,10 @@ def build_extension(self, ext): classifiers=[ "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ], diff --git a/tornado/__init__.py b/tornado/__init__.py index a5f45e526f..91e4cdec1c 100644 --- a/tornado/__init__.py +++ b/tornado/__init__.py @@ -22,5 +22,46 @@ # is zero for an official release, positive for a development branch, # or negative for a release candidate or beta (after the base version # number has been incremented) -version = "6.1" -version_info = (6, 1, 0, 0) +version = "6.4.2" +version_info = (6, 4, 2, 0) + +import importlib +import typing + +__all__ = [ + "auth", + "autoreload", + "concurrent", + "curl_httpclient", + "escape", + "gen", + "http1connection", + "httpclient", + "httpserver", + "httputil", + "ioloop", + "iostream", + "locale", + "locks", + "log", + "netutil", + "options", + "platform", + "process", + "queues", + "routing", + "simple_httpclient", + "tcpclient", + "tcpserver", + "template", + "testing", + "util", + "web", +] + + +# Copied from https://peps.python.org/pep-0562/ +def __getattr__(name: str) -> typing.Any: + if name in __all__: + return importlib.import_module("." + name, __name__) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/tornado/__init__.pyi b/tornado/__init__.pyi new file mode 100644 index 0000000000..60c2a7e754 --- /dev/null +++ b/tornado/__init__.pyi @@ -0,0 +1,33 @@ +import typing + +version: str +version_info: typing.Tuple[int, int, int, int] + +from . import auth +from . import autoreload +from . import concurrent +from . import curl_httpclient +from . import escape +from . import gen +from . import http1connection +from . import httpclient +from . import httpserver +from . import httputil +from . import ioloop +from . import iostream +from . import locale +from . import locks +from . import log +from . import netutil +from . import options +from . import platform +from . import process +from . import queues +from . import routing +from . import simple_httpclient +from . import tcpclient +from . import tcpserver +from . import template +from . import testing +from . import util +from . import web diff --git a/tornado/_locale_data.py b/tornado/_locale_data.py index c706230ee5..7a5d285218 100644 --- a/tornado/_locale_data.py +++ b/tornado/_locale_data.py @@ -15,66 +15,66 @@ """Data used by the tornado.locale module.""" LOCALE_NAMES = { - "af_ZA": {"name_en": u"Afrikaans", "name": u"Afrikaans"}, - "am_ET": {"name_en": u"Amharic", "name": u"አማርኛ"}, - "ar_AR": {"name_en": u"Arabic", "name": u"العربية"}, - "bg_BG": {"name_en": u"Bulgarian", "name": u"Български"}, - "bn_IN": {"name_en": u"Bengali", "name": u"বাংলা"}, - "bs_BA": {"name_en": u"Bosnian", "name": u"Bosanski"}, - "ca_ES": {"name_en": u"Catalan", "name": u"Català"}, - "cs_CZ": {"name_en": u"Czech", "name": u"Čeština"}, - "cy_GB": {"name_en": u"Welsh", "name": u"Cymraeg"}, - "da_DK": {"name_en": u"Danish", "name": u"Dansk"}, - "de_DE": {"name_en": u"German", "name": u"Deutsch"}, - "el_GR": {"name_en": u"Greek", "name": u"Ελληνικά"}, - "en_GB": {"name_en": u"English (UK)", "name": u"English (UK)"}, - "en_US": {"name_en": u"English (US)", "name": u"English (US)"}, - "es_ES": {"name_en": u"Spanish (Spain)", "name": u"Español (España)"}, - "es_LA": {"name_en": u"Spanish", "name": u"Español"}, - "et_EE": {"name_en": u"Estonian", "name": u"Eesti"}, - "eu_ES": {"name_en": u"Basque", "name": u"Euskara"}, - "fa_IR": {"name_en": u"Persian", "name": u"فارسی"}, - "fi_FI": {"name_en": u"Finnish", "name": u"Suomi"}, - "fr_CA": {"name_en": u"French (Canada)", "name": u"Français (Canada)"}, - "fr_FR": {"name_en": u"French", "name": u"Français"}, - "ga_IE": {"name_en": u"Irish", "name": u"Gaeilge"}, - "gl_ES": {"name_en": u"Galician", "name": u"Galego"}, - "he_IL": {"name_en": u"Hebrew", "name": u"עברית"}, - "hi_IN": {"name_en": u"Hindi", "name": u"हिन्दी"}, - "hr_HR": {"name_en": u"Croatian", "name": u"Hrvatski"}, - "hu_HU": {"name_en": u"Hungarian", "name": u"Magyar"}, - "id_ID": {"name_en": u"Indonesian", "name": u"Bahasa Indonesia"}, - "is_IS": {"name_en": u"Icelandic", "name": u"Íslenska"}, - "it_IT": {"name_en": u"Italian", "name": u"Italiano"}, - "ja_JP": {"name_en": u"Japanese", "name": u"日本語"}, - "ko_KR": {"name_en": u"Korean", "name": u"한국어"}, - "lt_LT": {"name_en": u"Lithuanian", "name": u"Lietuvių"}, - "lv_LV": {"name_en": u"Latvian", "name": u"Latviešu"}, - "mk_MK": {"name_en": u"Macedonian", "name": u"Македонски"}, - "ml_IN": {"name_en": u"Malayalam", "name": u"മലയാളം"}, - "ms_MY": {"name_en": u"Malay", "name": u"Bahasa Melayu"}, - "nb_NO": {"name_en": u"Norwegian (bokmal)", "name": u"Norsk (bokmål)"}, - "nl_NL": {"name_en": u"Dutch", "name": u"Nederlands"}, - "nn_NO": {"name_en": u"Norwegian (nynorsk)", "name": u"Norsk (nynorsk)"}, - "pa_IN": {"name_en": u"Punjabi", "name": u"ਪੰਜਾਬੀ"}, - "pl_PL": {"name_en": u"Polish", "name": u"Polski"}, - "pt_BR": {"name_en": u"Portuguese (Brazil)", "name": u"Português (Brasil)"}, - "pt_PT": {"name_en": u"Portuguese (Portugal)", "name": u"Português (Portugal)"}, - "ro_RO": {"name_en": u"Romanian", "name": u"Română"}, - "ru_RU": {"name_en": u"Russian", "name": u"Русский"}, - "sk_SK": {"name_en": u"Slovak", "name": u"Slovenčina"}, - "sl_SI": {"name_en": u"Slovenian", "name": u"Slovenščina"}, - "sq_AL": {"name_en": u"Albanian", "name": u"Shqip"}, - "sr_RS": {"name_en": u"Serbian", "name": u"Српски"}, - "sv_SE": {"name_en": u"Swedish", "name": u"Svenska"}, - "sw_KE": {"name_en": u"Swahili", "name": u"Kiswahili"}, - "ta_IN": {"name_en": u"Tamil", "name": u"தமிழ்"}, - "te_IN": {"name_en": u"Telugu", "name": u"తెలుగు"}, - "th_TH": {"name_en": u"Thai", "name": u"ภาษาไทย"}, - "tl_PH": {"name_en": u"Filipino", "name": u"Filipino"}, - "tr_TR": {"name_en": u"Turkish", "name": u"Türkçe"}, - "uk_UA": {"name_en": u"Ukraini ", "name": u"Українська"}, - "vi_VN": {"name_en": u"Vietnamese", "name": u"Tiếng Việt"}, - "zh_CN": {"name_en": u"Chinese (Simplified)", "name": u"中文(简体)"}, - "zh_TW": {"name_en": u"Chinese (Traditional)", "name": u"中文(繁體)"}, + "af_ZA": {"name_en": "Afrikaans", "name": "Afrikaans"}, + "am_ET": {"name_en": "Amharic", "name": "አማርኛ"}, + "ar_AR": {"name_en": "Arabic", "name": "العربية"}, + "bg_BG": {"name_en": "Bulgarian", "name": "Български"}, + "bn_IN": {"name_en": "Bengali", "name": "বাংলা"}, + "bs_BA": {"name_en": "Bosnian", "name": "Bosanski"}, + "ca_ES": {"name_en": "Catalan", "name": "Català"}, + "cs_CZ": {"name_en": "Czech", "name": "Čeština"}, + "cy_GB": {"name_en": "Welsh", "name": "Cymraeg"}, + "da_DK": {"name_en": "Danish", "name": "Dansk"}, + "de_DE": {"name_en": "German", "name": "Deutsch"}, + "el_GR": {"name_en": "Greek", "name": "Ελληνικά"}, + "en_GB": {"name_en": "English (UK)", "name": "English (UK)"}, + "en_US": {"name_en": "English (US)", "name": "English (US)"}, + "es_ES": {"name_en": "Spanish (Spain)", "name": "Español (España)"}, + "es_LA": {"name_en": "Spanish", "name": "Español"}, + "et_EE": {"name_en": "Estonian", "name": "Eesti"}, + "eu_ES": {"name_en": "Basque", "name": "Euskara"}, + "fa_IR": {"name_en": "Persian", "name": "فارسی"}, + "fi_FI": {"name_en": "Finnish", "name": "Suomi"}, + "fr_CA": {"name_en": "French (Canada)", "name": "Français (Canada)"}, + "fr_FR": {"name_en": "French", "name": "Français"}, + "ga_IE": {"name_en": "Irish", "name": "Gaeilge"}, + "gl_ES": {"name_en": "Galician", "name": "Galego"}, + "he_IL": {"name_en": "Hebrew", "name": "עברית"}, + "hi_IN": {"name_en": "Hindi", "name": "हिन्दी"}, + "hr_HR": {"name_en": "Croatian", "name": "Hrvatski"}, + "hu_HU": {"name_en": "Hungarian", "name": "Magyar"}, + "id_ID": {"name_en": "Indonesian", "name": "Bahasa Indonesia"}, + "is_IS": {"name_en": "Icelandic", "name": "Íslenska"}, + "it_IT": {"name_en": "Italian", "name": "Italiano"}, + "ja_JP": {"name_en": "Japanese", "name": "日本語"}, + "ko_KR": {"name_en": "Korean", "name": "한국어"}, + "lt_LT": {"name_en": "Lithuanian", "name": "Lietuvių"}, + "lv_LV": {"name_en": "Latvian", "name": "Latviešu"}, + "mk_MK": {"name_en": "Macedonian", "name": "Македонски"}, + "ml_IN": {"name_en": "Malayalam", "name": "മലയാളം"}, + "ms_MY": {"name_en": "Malay", "name": "Bahasa Melayu"}, + "nb_NO": {"name_en": "Norwegian (bokmal)", "name": "Norsk (bokmål)"}, + "nl_NL": {"name_en": "Dutch", "name": "Nederlands"}, + "nn_NO": {"name_en": "Norwegian (nynorsk)", "name": "Norsk (nynorsk)"}, + "pa_IN": {"name_en": "Punjabi", "name": "ਪੰਜਾਬੀ"}, + "pl_PL": {"name_en": "Polish", "name": "Polski"}, + "pt_BR": {"name_en": "Portuguese (Brazil)", "name": "Português (Brasil)"}, + "pt_PT": {"name_en": "Portuguese (Portugal)", "name": "Português (Portugal)"}, + "ro_RO": {"name_en": "Romanian", "name": "Română"}, + "ru_RU": {"name_en": "Russian", "name": "Русский"}, + "sk_SK": {"name_en": "Slovak", "name": "Slovenčina"}, + "sl_SI": {"name_en": "Slovenian", "name": "Slovenščina"}, + "sq_AL": {"name_en": "Albanian", "name": "Shqip"}, + "sr_RS": {"name_en": "Serbian", "name": "Српски"}, + "sv_SE": {"name_en": "Swedish", "name": "Svenska"}, + "sw_KE": {"name_en": "Swahili", "name": "Kiswahili"}, + "ta_IN": {"name_en": "Tamil", "name": "தமிழ்"}, + "te_IN": {"name_en": "Telugu", "name": "తెలుగు"}, + "th_TH": {"name_en": "Thai", "name": "ภาษาไทย"}, + "tl_PH": {"name_en": "Filipino", "name": "Filipino"}, + "tr_TR": {"name_en": "Turkish", "name": "Türkçe"}, + "uk_UA": {"name_en": "Ukraini ", "name": "Українська"}, + "vi_VN": {"name_en": "Vietnamese", "name": "Tiếng Việt"}, + "zh_CN": {"name_en": "Chinese (Simplified)", "name": "中文(简体)"}, + "zh_TW": {"name_en": "Chinese (Traditional)", "name": "中文(繁體)"}, } diff --git a/tornado/auth.py b/tornado/auth.py index 5f1068c95b..d1edcc6550 100644 --- a/tornado/auth.py +++ b/tornado/auth.py @@ -33,23 +33,39 @@ Example usage for Google OAuth: +.. testsetup:: + + import urllib + .. testcode:: class GoogleOAuth2LoginHandler(tornado.web.RequestHandler, - tornado.auth.GoogleOAuth2Mixin): + tornado.auth.GoogleOAuth2Mixin): async def get(self): - if self.get_argument('code', False): - user = await self.get_authenticated_user( - redirect_uri='http://your.site.com/auth/google', - code=self.get_argument('code')) - # Save the user with e.g. set_secure_cookie - else: - self.authorize_redirect( - redirect_uri='http://your.site.com/auth/google', - client_id=self.settings['google_oauth']['key'], - scope=['profile', 'email'], - response_type='code', - extra_params={'approval_prompt': 'auto'}) + # Google requires an exact match for redirect_uri, so it's + # best to get it from your app configuration instead of from + # self.request.full_uri(). + redirect_uri = urllib.parse.urljoin(self.application.settings['redirect_base_uri'], + self.reverse_url('google_oauth')) + async def get(self): + if self.get_argument('code', False): + access = await self.get_authenticated_user( + redirect_uri=redirect_uri, + code=self.get_argument('code')) + user = await self.oauth2_request( + "https://www.googleapis.com/oauth2/v1/userinfo", + access_token=access["access_token"]) + # Save the user and access token. For example: + user_cookie = dict(id=user["id"], access_token=access["access_token"]) + self.set_signed_cookie("user", json.dumps(user_cookie)) + self.redirect("/") + else: + self.authorize_redirect( + redirect_uri=redirect_uri, + client_id=self.get_google_oauth_settings()['key'], + scope=['profile', 'email'], + response_type='code', + extra_params={'approval_prompt': 'auto'}) .. testoutput:: :hide: @@ -63,6 +79,7 @@ async def get(self): import time import urllib.parse import uuid +import warnings from tornado import httpclient from tornado import escape @@ -136,7 +153,7 @@ async def get_authenticated_user( args = dict( (k, v[-1]) for k, v in handler.request.arguments.items() ) # type: Dict[str, Union[str, bytes]] - args["openid.mode"] = u"check_authentication" + args["openid.mode"] = "check_authentication" url = self._OPENID_ENDPOINT # type: ignore if http_client is None: http_client = self.get_auth_http_client() @@ -211,14 +228,14 @@ def _on_authentication_verified( for key in handler.request.arguments: if ( key.startswith("openid.ns.") - and handler.get_argument(key) == u"http://openid.net/srv/ax/1.0" + and handler.get_argument(key) == "http://openid.net/srv/ax/1.0" ): ax_ns = key[10:] break def get_ax_arg(uri: str) -> str: if not ax_ns: - return u"" + return "" prefix = "openid." + ax_ns + ".type." ax_name = None for name in handler.request.arguments.keys(): @@ -227,8 +244,8 @@ def get_ax_arg(uri: str) -> str: ax_name = "openid." + ax_ns + ".value." + part break if not ax_name: - return u"" - return handler.get_argument(ax_name, u"") + return "" + return handler.get_argument(ax_name, "") email = get_ax_arg("http://axschema.org/contact/email") name = get_ax_arg("http://axschema.org/namePerson") @@ -247,7 +264,7 @@ def get_ax_arg(uri: str) -> str: if name: user["name"] = name elif name_parts: - user["name"] = u" ".join(name_parts) + user["name"] = " ".join(name_parts) elif email: user["name"] = email.split("@")[0] if email: @@ -571,7 +588,13 @@ def authorize_redirect( The ``callback`` argument and returned awaitable were removed; this is now an ordinary synchronous function. + + .. deprecated:: 6.4 + The ``client_secret`` argument (which has never had any effect) + is deprecated and will be removed in Tornado 7.0. """ + if client_secret is not None: + warnings.warn("client_secret argument is deprecated", DeprecationWarning) handler = cast(RequestHandler, self) args = {"response_type": response_type} if redirect_uri is not None: @@ -694,7 +717,7 @@ class TwitterLoginHandler(tornado.web.RequestHandler, async def get(self): if self.get_argument("oauth_token", None): user = await self.get_authenticated_user() - # Save the user using e.g. set_secure_cookie() + # Save the user using e.g. set_signed_cookie() else: await self.authorize_redirect() @@ -705,6 +728,12 @@ async def get(self): includes the attributes ``username``, ``name``, ``access_token``, and all of the custom Twitter user attributes described at https://dev.twitter.com/docs/api/1.1/get/users/show + + .. deprecated:: 6.3 + This class refers to version 1.1 of the Twitter API, which has been + deprecated by Twitter. Since Twitter has begun to limit access to its + API, this class will no longer be updated and will be removed in the + future. """ _OAUTH_REQUEST_TOKEN_URL = "https://api.twitter.com/oauth/request_token" @@ -839,13 +868,18 @@ class GoogleOAuth2Mixin(OAuth2Mixin): * Go to the Google Dev Console at http://console.developers.google.com * Select a project, or create a new one. - * In the sidebar on the left, select APIs & Auth. - * In the list of APIs, find the Google+ API service and set it to ON. + * Depending on permissions required, you may need to set your app to + "testing" mode and add your account as a test user, or go through + a verfication process. You may also need to use the "Enable + APIs and Services" command to enable specific services. * In the sidebar on the left, select Credentials. - * In the OAuth section of the page, select Create New Client ID. - * Set the Redirect URI to point to your auth handler + * Click CREATE CREDENTIALS and click OAuth client ID. + * Under Application type, select Web application. + * Name OAuth 2.0 client and click Create. * Copy the "Client secret" and "Client ID" to the application settings as ``{"google_oauth": {"key": CLIENT_ID, "secret": CLIENT_SECRET}}`` + * You must register the ``redirect_uri`` you plan to use with this class + on the Credentials page. .. versionadded:: 3.2 """ @@ -856,8 +890,28 @@ class GoogleOAuth2Mixin(OAuth2Mixin): _OAUTH_NO_CALLBACKS = False _OAUTH_SETTINGS_KEY = "google_oauth" + def get_google_oauth_settings(self) -> Dict[str, str]: + """Return the Google OAuth 2.0 credentials that you created with + [Google Cloud + Platform](https://console.cloud.google.com/apis/credentials). The dict + format is:: + + { + "key": "your_client_id", "secret": "your_client_secret" + } + + If your credentials are stored differently (e.g. in a db) you can + override this method for custom provision. + """ + handler = cast(RequestHandler, self) + return handler.settings[self._OAUTH_SETTINGS_KEY] + async def get_authenticated_user( - self, redirect_uri: str, code: str + self, + redirect_uri: str, + code: str, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, ) -> Dict[str, Any]: """Handles the login for the Google user, returning an access token. @@ -871,27 +925,39 @@ async def get_authenticated_user( Example usage: + .. testsetup:: + + import urllib + .. testcode:: class GoogleOAuth2LoginHandler(tornado.web.RequestHandler, tornado.auth.GoogleOAuth2Mixin): async def get(self): - if self.get_argument('code', False): - access = await self.get_authenticated_user( - redirect_uri='http://your.site.com/auth/google', - code=self.get_argument('code')) - user = await self.oauth2_request( - "https://www.googleapis.com/oauth2/v1/userinfo", - access_token=access["access_token"]) - # Save the user and access token with - # e.g. set_secure_cookie. - else: - self.authorize_redirect( - redirect_uri='http://your.site.com/auth/google', - client_id=self.settings['google_oauth']['key'], - scope=['profile', 'email'], - response_type='code', - extra_params={'approval_prompt': 'auto'}) + # Google requires an exact match for redirect_uri, so it's + # best to get it from your app configuration instead of from + # self.request.full_uri(). + redirect_uri = urllib.parse.urljoin(self.application.settings['redirect_base_uri'], + self.reverse_url('google_oauth')) + async def get(self): + if self.get_argument('code', False): + access = await self.get_authenticated_user( + redirect_uri=redirect_uri, + code=self.get_argument('code')) + user = await self.oauth2_request( + "https://www.googleapis.com/oauth2/v1/userinfo", + access_token=access["access_token"]) + # Save the user and access token. For example: + user_cookie = dict(id=user["id"], access_token=access["access_token"]) + self.set_signed_cookie("user", json.dumps(user_cookie)) + self.redirect("/") + else: + self.authorize_redirect( + redirect_uri=redirect_uri, + client_id=self.get_google_oauth_settings()['key'], + scope=['profile', 'email'], + response_type='code', + extra_params={'approval_prompt': 'auto'}) .. testoutput:: :hide: @@ -900,14 +966,20 @@ async def get(self): The ``callback`` argument was removed. Use the returned awaitable object instead. """ # noqa: E501 - handler = cast(RequestHandler, self) + + if client_id is None or client_secret is None: + settings = self.get_google_oauth_settings() + if client_id is None: + client_id = settings["key"] + if client_secret is None: + client_secret = settings["secret"] http = self.get_auth_http_client() body = urllib.parse.urlencode( { "redirect_uri": redirect_uri, "code": code, - "client_id": handler.settings[self._OAUTH_SETTINGS_KEY]["key"], - "client_secret": handler.settings[self._OAUTH_SETTINGS_KEY]["secret"], + "client_id": client_id, + "client_secret": client_secret, "grant_type": "authorization_code", } ) @@ -946,18 +1018,21 @@ async def get_authenticated_user( class FacebookGraphLoginHandler(tornado.web.RequestHandler, tornado.auth.FacebookGraphMixin): async def get(self): - if self.get_argument("code", False): - user = await self.get_authenticated_user( - redirect_uri='/auth/facebookgraph/', - client_id=self.settings["facebook_api_key"], - client_secret=self.settings["facebook_secret"], - code=self.get_argument("code")) - # Save the user with e.g. set_secure_cookie - else: - self.authorize_redirect( - redirect_uri='/auth/facebookgraph/', - client_id=self.settings["facebook_api_key"], - extra_params={"scope": "read_stream,offline_access"}) + redirect_uri = urllib.parse.urljoin( + self.application.settings['redirect_base_uri'], + self.reverse_url('facebook_oauth')) + if self.get_argument("code", False): + user = await self.get_authenticated_user( + redirect_uri=redirect_uri, + client_id=self.settings["facebook_api_key"], + client_secret=self.settings["facebook_secret"], + code=self.get_argument("code")) + # Save the user with e.g. set_signed_cookie + else: + self.authorize_redirect( + redirect_uri=redirect_uri, + client_id=self.settings["facebook_api_key"], + extra_params={"scope": "user_posts"}) .. testoutput:: :hide: diff --git a/tornado/autoreload.py b/tornado/autoreload.py index 3299a3b34c..c6a6e82da0 100644 --- a/tornado/autoreload.py +++ b/tornado/autoreload.py @@ -60,8 +60,7 @@ # may become relative in spite of the future import. # # We address the former problem by reconstructing the original command -# line (Python >= 3.4) or by setting the $PYTHONPATH environment -# variable (Python < 3.4) before re-execution so the new process will +# line before re-execution so the new process will # see the correct path. We attempt to address the latter problem when # tornado.autoreload is run as __main__. @@ -76,9 +75,9 @@ del sys.path[0] import functools -import logging +import importlib.abc import os -import pkgutil # type: ignore +import pkgutil import sys import traceback import types @@ -88,18 +87,13 @@ from tornado import ioloop from tornado.log import gen_log from tornado import process -from tornado.util import exec_in try: import signal except ImportError: signal = None # type: ignore -import typing -from typing import Callable, Dict - -if typing.TYPE_CHECKING: - from typing import List, Optional, Union # noqa: F401 +from typing import Callable, Dict, Optional, List, Union # os.execv is broken on Windows and can't properly parse command line # arguments and executable name if they contain whitespaces. subprocess @@ -109,9 +103,11 @@ _watched_files = set() _reload_hooks = [] _reload_attempted = False -_io_loops = weakref.WeakKeyDictionary() # type: ignore +_io_loops: "weakref.WeakKeyDictionary[ioloop.IOLoop, bool]" = ( + weakref.WeakKeyDictionary() +) _autoreload_is_main = False -_original_argv = None # type: Optional[List[str]] +_original_argv: Optional[List[str]] = None _original_spec = None @@ -127,7 +123,7 @@ def start(check_time: int = 500) -> None: _io_loops[io_loop] = True if len(_io_loops) > 1: gen_log.warning("tornado.autoreload started more than once in the same process") - modify_times = {} # type: Dict[str, float] + modify_times: Dict[str, float] = {} callback = functools.partial(_reload_on_update, modify_times) scheduler = ioloop.PeriodicCallback(callback, check_time) scheduler.start() @@ -207,7 +203,7 @@ def _reload() -> None: _reload_attempted = True for fn in _reload_hooks: fn() - if hasattr(signal, "setitimer"): + if sys.platform != "win32": # Clear the alarm signal set by # ioloop.set_blocking_log_threshold so it doesn't fire # after the exec. @@ -215,10 +211,7 @@ def _reload() -> None: # sys.path fixes: see comments at top of file. If __main__.__spec__ # exists, we were invoked with -m and the effective path is about to # change on re-exec. Reconstruct the original command line to - # ensure that the new process sees the same path we did. If - # __spec__ is not available (Python < 3.4), check instead if - # sys.path[0] is an empty string and add the current directory to - # $PYTHONPATH. + # ensure that the new process sees the same path we did. if _autoreload_is_main: assert _original_argv is not None spec = _original_spec @@ -226,43 +219,25 @@ def _reload() -> None: else: spec = getattr(sys.modules["__main__"], "__spec__", None) argv = sys.argv - if spec: + if spec and spec.name != "__main__": + # __spec__ is set in two cases: when running a module, and when running a directory. (when + # running a file, there is no spec). In the former case, we must pass -m to maintain the + # module-style behavior (setting sys.path), even though python stripped -m from its argv at + # startup. If sys.path is exactly __main__, we're running a directory and should fall + # through to the non-module behavior. + # + # Some of this, including the use of exactly __main__ as a spec for directory mode, + # is documented at https://docs.python.org/3/library/runpy.html#runpy.run_path argv = ["-m", spec.name] + argv[1:] - else: - path_prefix = "." + os.pathsep - if sys.path[0] == "" and not os.environ.get("PYTHONPATH", "").startswith( - path_prefix - ): - os.environ["PYTHONPATH"] = path_prefix + os.environ.get("PYTHONPATH", "") + if not _has_execv: subprocess.Popen([sys.executable] + argv) os._exit(0) else: - try: - os.execv(sys.executable, [sys.executable] + argv) - except OSError: - # Mac OS X versions prior to 10.6 do not support execv in - # a process that contains multiple threads. Instead of - # re-executing in the current process, start a new one - # and cause the current process to exit. This isn't - # ideal since the new process is detached from the parent - # terminal and thus cannot easily be killed with ctrl-C, - # but it's better than not being able to autoreload at - # all. - # Unfortunately the errno returned in this case does not - # appear to be consistent, so we can't easily check for - # this error specifically. - os.spawnv( - os.P_NOWAIT, sys.executable, [sys.executable] + argv # type: ignore - ) - # At this point the IOLoop has been closed and finally - # blocks will experience errors if we allow the stack to - # unwind, so just exit uncleanly. - os._exit(0) - - -_USAGE = """\ -Usage: + os.execv(sys.executable, [sys.executable] + argv) + + +_USAGE = """ python -m tornado.autoreload -m module.to.run [args...] python -m tornado.autoreload path/to/script.py [args...] """ @@ -284,6 +259,12 @@ def main() -> None: # Remember that we were launched with autoreload as main. # The main module can be tricky; set the variables both in our globals # (which may be __main__) and the real importable version. + # + # We use optparse instead of the newer argparse because we want to + # mimic the python command-line interface which requires stopping + # parsing at the first positional argument. optparse supports + # this but as far as I can tell argparse does not. + import optparse import tornado.autoreload global _autoreload_is_main @@ -293,67 +274,73 @@ def main() -> None: tornado.autoreload._original_argv = _original_argv = original_argv original_spec = getattr(sys.modules["__main__"], "__spec__", None) tornado.autoreload._original_spec = _original_spec = original_spec - sys.argv = sys.argv[:] - if len(sys.argv) >= 3 and sys.argv[1] == "-m": - mode = "module" - module = sys.argv[2] - del sys.argv[1:3] - elif len(sys.argv) >= 2: - mode = "script" - script = sys.argv[1] - sys.argv = sys.argv[1:] + + parser = optparse.OptionParser( + prog="python -m tornado.autoreload", + usage=_USAGE, + epilog="Either -m or a path must be specified, but not both", + ) + parser.disable_interspersed_args() + parser.add_option("-m", dest="module", metavar="module", help="module to run") + parser.add_option( + "--until-success", + action="store_true", + help="stop reloading after the program exist successfully (status code 0)", + ) + opts, rest = parser.parse_args() + if opts.module is None: + if not rest: + print("Either -m or a path must be specified", file=sys.stderr) + sys.exit(1) + path = rest[0] + sys.argv = rest[:] else: - print(_USAGE, file=sys.stderr) - sys.exit(1) + path = None + sys.argv = [sys.argv[0]] + rest + # SystemExit.code is typed funny: https://github.com/python/typeshed/issues/8513 + # All we care about is truthiness + exit_status: Union[int, str, None] = 1 try: - if mode == "module": - import runpy - - runpy.run_module(module, run_name="__main__", alter_sys=True) - elif mode == "script": - with open(script) as f: - # Execute the script in our namespace instead of creating - # a new one so that something that tries to import __main__ - # (e.g. the unittest module) will see names defined in the - # script instead of just those defined in this module. - global __file__ - __file__ = script - # If __package__ is defined, imports may be incorrectly - # interpreted as relative to this module. - global __package__ - del __package__ - exec_in(f.read(), globals(), globals()) + import runpy + + if opts.module is not None: + runpy.run_module(opts.module, run_name="__main__", alter_sys=True) + else: + assert path is not None + runpy.run_path(path, run_name="__main__") except SystemExit as e: - logging.basicConfig() + exit_status = e.code gen_log.info("Script exited with status %s", e.code) except Exception as e: - logging.basicConfig() gen_log.warning("Script exited with uncaught exception", exc_info=True) # If an exception occurred at import time, the file with the error # never made it into sys.modules and so we won't know to watch it. # Just to make sure we've covered everything, walk the stack trace # from the exception and watch every file. - for (filename, lineno, name, line) in traceback.extract_tb(sys.exc_info()[2]): + for filename, lineno, name, line in traceback.extract_tb(sys.exc_info()[2]): watch(filename) if isinstance(e, SyntaxError): # SyntaxErrors are special: their innermost stack frame is fake # so extract_tb won't see it and we have to get the filename # from the exception object. - watch(e.filename) + if e.filename is not None: + watch(e.filename) else: - logging.basicConfig() + exit_status = 0 gen_log.info("Script exited normally") # restore sys.argv so subsequent executions will include autoreload sys.argv = original_argv - if mode == "module": + if opts.module is not None: + assert opts.module is not None # runpy did a fake import of the module as __main__, but now it's # no longer in sys.modules. Figure out where it is and watch it. - loader = pkgutil.get_loader(module) - if loader is not None: - watch(loader.get_filename()) # type: ignore - + loader = pkgutil.get_loader(opts.module) + if loader is not None and isinstance(loader, importlib.abc.FileLoader): + watch(loader.get_filename()) + if opts.until_success and not exit_status: + return wait() diff --git a/tornado/concurrent.py b/tornado/concurrent.py index 7638fcfc98..5047c5389f 100644 --- a/tornado/concurrent.py +++ b/tornado/concurrent.py @@ -54,7 +54,7 @@ def is_future(x: Any) -> bool: class DummyExecutor(futures.Executor): - def submit( + def submit( # type: ignore[override] self, fn: Callable[..., _T], *args: Any, **kwargs: Any ) -> "futures.Future[_T]": future = futures.Future() # type: futures.Future[_T] @@ -64,8 +64,15 @@ def submit( future_set_exc_info(future, sys.exc_info()) return future - def shutdown(self, wait: bool = True) -> None: - pass + if sys.version_info >= (3, 9): + + def shutdown(self, wait: bool = True, cancel_futures: bool = False) -> None: + pass + + else: + + def shutdown(self, wait: bool = True) -> None: + pass dummy_executor = DummyExecutor() @@ -111,6 +118,7 @@ def foo(self): The ``callback`` argument was removed. """ + # Fully type-checking decorators is tricky, and this one is # discouraged anyway so it doesn't have all the generic magic. def run_on_executor_decorator(fn: Callable) -> Callable[..., Future]: @@ -150,16 +158,17 @@ def chain_future(a: "Future[_T]", b: "Future[_T]") -> None: """ - def copy(future: "Future[_T]") -> None: - assert future is a + def copy(a: "Future[_T]") -> None: if b.done(): return if hasattr(a, "exc_info") and a.exc_info() is not None: # type: ignore future_set_exc_info(b, a.exc_info()) # type: ignore - elif a.exception() is not None: - b.set_exception(a.exception()) else: - b.set_result(a.result()) + a_exc = a.exception() + if a_exc is not None: + b.set_exception(a_exc) + else: + b.set_result(a.result()) if isinstance(a, Future): future_add_done_callback(a, copy) diff --git a/tornado/curl_httpclient.py b/tornado/curl_httpclient.py index 655399991b..397c3a9752 100644 --- a/tornado/curl_httpclient.py +++ b/tornado/curl_httpclient.py @@ -19,6 +19,7 @@ import functools import logging import pycurl +import re import threading import time from io import BytesIO @@ -36,14 +37,16 @@ ) from tornado.log import app_log -from typing import Dict, Any, Callable, Union, Tuple, Optional +from typing import Dict, Any, Callable, Union, Optional import typing if typing.TYPE_CHECKING: - from typing import Deque # noqa: F401 + from typing import Deque, Tuple # noqa: F401 curl_log = logging.getLogger("tornado.curl_httpclient") +CR_OR_LF_RE = re.compile(b"\r|\n") + class CurlAsyncHTTPClient(AsyncHTTPClient): def initialize( # type: ignore @@ -228,7 +231,7 @@ def _process_queue(self) -> None: "callback": callback, "queue_start_time": queue_start_time, "curl_start_time": time.time(), - "curl_start_ioloop_time": self.io_loop.current().time(), + "curl_start_ioloop_time": self.io_loop.current().time(), # type: ignore } try: self._curl_setup_request( @@ -347,13 +350,15 @@ def _curl_setup_request( if "Pragma" not in request.headers: request.headers["Pragma"] = "" - curl.setopt( - pycurl.HTTPHEADER, - [ - "%s: %s" % (native_str(k), native_str(v)) - for k, v in request.headers.get_all() - ], - ) + encoded_headers = [ + b"%s: %s" + % (native_str(k).encode("ASCII"), native_str(v).encode("ISO8859-1")) + for k, v in request.headers.get_all() + ] + for line in encoded_headers: + if CR_OR_LF_RE.search(line): + raise ValueError("Illegal characters in header (CR or LF): %r" % line) + curl.setopt(pycurl.HTTPHEADER, encoded_headers) curl.setopt( pycurl.HEADERFUNCTION, @@ -369,7 +374,7 @@ def write_function(b: Union[bytes, bytearray]) -> int: return len(b) else: - write_function = buffer.write + write_function = buffer.write # type: ignore curl.setopt(pycurl.WRITEFUNCTION, write_function) curl.setopt(pycurl.FOLLOWLOCATION, request.follow_redirects) curl.setopt(pycurl.MAXREDIRS, request.max_redirects) diff --git a/tornado/escape.py b/tornado/escape.py index 3cf7ff2e4a..84abfca604 100644 --- a/tornado/escape.py +++ b/tornado/escape.py @@ -17,9 +17,15 @@ Also includes a few other miscellaneous string manipulation functions that have crept in over time. + +Many functions in this module have near-equivalents in the standard library +(the differences mainly relate to handling of bytes and unicode strings, +and were more relevant in Python 2). In new code, the standard library +functions are encouraged instead of this module where applicable. See the +docstrings on each function for details. """ -import html.entities +import html import json import re import urllib.parse @@ -30,16 +36,6 @@ from typing import Union, Any, Optional, Dict, List, Callable -_XHTML_ESCAPE_RE = re.compile("[&<>\"']") -_XHTML_ESCAPE_DICT = { - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", -} - - def xhtml_escape(value: Union[str, bytes]) -> str: """Escapes a string so it is valid within HTML or XML. @@ -47,25 +43,50 @@ def xhtml_escape(value: Union[str, bytes]) -> str: When used in attribute values the escaped strings must be enclosed in quotes. + Equivalent to `html.escape` except that this function always returns + type `str` while `html.escape` returns `bytes` if its input is `bytes`. + .. versionchanged:: 3.2 Added the single quote to the list of escaped characters. + + .. versionchanged:: 6.4 + + Now simply wraps `html.escape`. This is equivalent to the old behavior + except that single quotes are now escaped as ``'`` instead of + ``'`` and performance may be different. """ - return _XHTML_ESCAPE_RE.sub( - lambda match: _XHTML_ESCAPE_DICT[match.group(0)], to_basestring(value) - ) + return html.escape(to_unicode(value)) def xhtml_unescape(value: Union[str, bytes]) -> str: - """Un-escapes an XML-escaped string.""" - return re.sub(r"&(#?)(\w+?);", _convert_entity, _unicode(value)) + """Un-escapes an XML-escaped string. + + Equivalent to `html.unescape` except that this function always returns + type `str` while `html.unescape` returns `bytes` if its input is `bytes`. + + .. versionchanged:: 6.4 + + Now simply wraps `html.unescape`. This changes behavior for some inputs + as required by the HTML 5 specification + https://html.spec.whatwg.org/multipage/parsing.html#numeric-character-reference-end-state + + Some invalid inputs such as surrogates now raise an error, and numeric + references to certain ISO-8859-1 characters are now handled correctly. + """ + return html.unescape(to_unicode(value)) # The fact that json_encode wraps json.dumps is an implementation detail. # Please see https://github.com/tornadoweb/tornado/pull/706 # before sending a pull request that adds **kwargs to this function. def json_encode(value: Any) -> str: - """JSON-encodes the given Python object.""" + """JSON-encodes the given Python object. + + Equivalent to `json.dumps` with the additional guarantee that the output + will never contain the character sequence ```` tag. + """ # JSON permits but does not require forward slashes to be escaped. # This is useful when json data is emitted in a tags from prematurely terminating @@ -78,9 +99,9 @@ def json_encode(value: Any) -> str: def json_decode(value: Union[str, bytes]) -> Any: """Returns Python objects for the given JSON string. - Supports both `str` and `bytes` inputs. + Supports both `str` and `bytes` inputs. Equvalent to `json.loads`. """ - return json.loads(to_basestring(value)) + return json.loads(value) def squeeze(value: str) -> str: @@ -91,16 +112,20 @@ def squeeze(value: str) -> str: def url_escape(value: Union[str, bytes], plus: bool = True) -> str: """Returns a URL-encoded version of the given value. - If ``plus`` is true (the default), spaces will be represented - as "+" instead of "%20". This is appropriate for query strings - but not for the path component of a URL. Note that this default - is the reverse of Python's urllib module. + Equivalent to either `urllib.parse.quote_plus` or `urllib.parse.quote` depending on the ``plus`` + argument. + + If ``plus`` is true (the default), spaces will be represented as ``+`` and slashes will be + represented as ``%2F``. This is appropriate for query strings. If ``plus`` is false, spaces + will be represented as ``%20`` and slashes are left as-is. This is appropriate for the path + component of a URL. Note that the default of ``plus=True`` is effectively the + reverse of Python's urllib module. .. versionadded:: 3.1 The ``plus`` argument """ quote = urllib.parse.quote_plus if plus else urllib.parse.quote - return quote(utf8(value)) + return quote(value) @typing.overload @@ -108,28 +133,29 @@ def url_unescape(value: Union[str, bytes], encoding: None, plus: bool = True) -> pass -@typing.overload # noqa: F811 +@typing.overload def url_unescape( value: Union[str, bytes], encoding: str = "utf-8", plus: bool = True ) -> str: pass -def url_unescape( # noqa: F811 +def url_unescape( value: Union[str, bytes], encoding: Optional[str] = "utf-8", plus: bool = True ) -> Union[str, bytes]: """Decodes the given value from a URL. The argument may be either a byte or unicode string. - If encoding is None, the result will be a byte string. Otherwise, - the result is a unicode string in the specified encoding. + If encoding is None, the result will be a byte string and this function is equivalent to + `urllib.parse.unquote_to_bytes` if ``plus=False``. Otherwise, the result is a unicode string in + the specified encoding and this function is equivalent to either `urllib.parse.unquote_plus` or + `urllib.parse.unquote` except that this function also accepts `bytes` as input. - If ``plus`` is true (the default), plus signs will be interpreted - as spaces (literal plus signs must be represented as "%2B"). This - is appropriate for query strings and form-encoded values but not - for the path component of a URL. Note that this default is the - reverse of Python's urllib module. + If ``plus`` is true (the default), plus signs will be interpreted as spaces (literal plus signs + must be represented as "%2B"). This is appropriate for query strings and form-encoded values + but not for the path component of a URL. Note that this default is the reverse of Python's + urllib module. .. versionadded:: 3.1 The ``plus`` argument @@ -175,17 +201,17 @@ def utf8(value: bytes) -> bytes: pass -@typing.overload # noqa: F811 +@typing.overload def utf8(value: str) -> bytes: pass -@typing.overload # noqa: F811 +@typing.overload def utf8(value: None) -> None: pass -def utf8(value: Union[None, str, bytes]) -> Optional[bytes]: # noqa: F811 +def utf8(value: Union[None, str, bytes]) -> Optional[bytes]: """Converts a string argument to a byte string. If the argument is already a byte string or None, it is returned unchanged. @@ -206,17 +232,17 @@ def to_unicode(value: str) -> str: pass -@typing.overload # noqa: F811 +@typing.overload def to_unicode(value: bytes) -> str: pass -@typing.overload # noqa: F811 +@typing.overload def to_unicode(value: None) -> None: pass -def to_unicode(value: Union[None, str, bytes]) -> Optional[str]: # noqa: F811 +def to_unicode(value: Union[None, str, bytes]) -> Optional[str]: """Converts a string argument to a unicode string. If the argument is already a unicode string or None, it is returned @@ -368,35 +394,10 @@ def make_link(m: typing.Match) -> str: # have a status bar, such as Safari by default) params += ' title="%s"' % href - return u'%s' % (href, params, url) + return '%s' % (href, params, url) # First HTML-escape so that our strings are all safe. # The regex is modified to avoid character entites other than & so # that we won't pick up ", etc. text = _unicode(xhtml_escape(text)) return _URL_RE.sub(make_link, text) - - -def _convert_entity(m: typing.Match) -> str: - if m.group(1) == "#": - try: - if m.group(2)[:1].lower() == "x": - return chr(int(m.group(2)[1:], 16)) - else: - return chr(int(m.group(2))) - except ValueError: - return "&#%s;" % m.group(2) - try: - return _HTML_UNICODE_MAP[m.group(2)] - except KeyError: - return "&%s;" % m.group(2) - - -def _build_unicode_map() -> Dict[str, str]: - unicode_map = {} - for name, value in html.entities.name2codepoint.items(): - unicode_map[name] = chr(value) - return unicode_map - - -_HTML_UNICODE_MAP = _build_unicode_map() diff --git a/tornado/gen.py b/tornado/gen.py index cab9689375..0e3c7a6fc2 100644 --- a/tornado/gen.py +++ b/tornado/gen.py @@ -66,6 +66,7 @@ def get(self): via ``singledispatch``. """ + import asyncio import builtins import collections @@ -165,13 +166,11 @@ def _fake_ctx_run(f: Callable[..., _T], *args: Any, **kw: Any) -> _T: @overload def coroutine( func: Callable[..., "Generator[Any, Any, _T]"] -) -> Callable[..., "Future[_T]"]: - ... +) -> Callable[..., "Future[_T]"]: ... @overload -def coroutine(func: Callable[..., _T]) -> Callable[..., "Future[_T]"]: - ... +def coroutine(func: Callable[..., _T]) -> Callable[..., "Future[_T]"]: ... def coroutine( @@ -400,7 +399,7 @@ def next(self) -> Future: self._running_future = Future() if self._finished: - self._return_result(self._finished.popleft()) + return self._return_result(self._finished.popleft()) return self._running_future @@ -410,7 +409,7 @@ def _done_callback(self, done: Future) -> None: else: self._finished.append(done) - def _return_result(self, done: Future) -> None: + def _return_result(self, done: Future) -> Future: """Called set the returned future's state that of the future we yielded, and set the current future for the iterator. """ @@ -418,9 +417,13 @@ def _return_result(self, done: Future) -> None: raise Exception("no future is running") chain_future(done, self._running_future) + res = self._running_future + self._running_future = None self.current_future = done self.current_index = self._unfinished.pop(done) + return res + def __aiter__(self) -> typing.AsyncIterator: return self @@ -602,6 +605,9 @@ def with_timeout( .. versionchanged:: 6.0.3 ``asyncio.CancelledError`` is now always considered "quiet". + .. versionchanged:: 6.2 + ``tornado.util.TimeoutError`` is now an alias to ``asyncio.TimeoutError``. + """ # It's tempting to optimize this by cancelling the input future on timeout # instead of creating a new one, but A) we can't know if we are the only @@ -736,7 +742,7 @@ def __init__( self.running = False self.finished = False self.io_loop = IOLoop.current() - if self.handle_yield(first_yielded): + if self.ctx_run(self.handle_yield, first_yielded): gen = result_future = first_yielded = None # type: ignore self.ctx_run(self.run) @@ -756,21 +762,25 @@ def run(self) -> None: return self.future = None try: - exc_info = None - try: value = future.result() - except Exception: - exc_info = sys.exc_info() - future = None + except Exception as e: + # Save the exception for later. It's important that + # gen.throw() not be called inside this try/except block + # because that makes sys.exc_info behave unexpectedly. + exc: Optional[Exception] = e + else: + exc = None + finally: + future = None - if exc_info is not None: + if exc is not None: try: - yielded = self.gen.throw(*exc_info) # type: ignore + yielded = self.gen.throw(exc) finally: - # Break up a reference to itself - # for faster GC on CPython. - exc_info = None + # Break up a circular reference for faster GC on + # CPython. + del exc else: yielded = self.gen.send(value) @@ -829,13 +839,17 @@ def handle_exception( return False -# Convert Awaitables into Futures. -try: - _wrap_awaitable = asyncio.ensure_future -except AttributeError: - # asyncio.ensure_future was introduced in Python 3.4.4, but - # Debian jessie still ships with 3.4.2 so try the old name. - _wrap_awaitable = getattr(asyncio, "async") +def _wrap_awaitable(awaitable: Awaitable) -> Future: + # Convert Awaitables into Futures. + # Note that we use ensure_future, which handles both awaitables + # and coroutines, rather than create_task, which only accepts + # coroutines. (ensure_future calls create_task if given a coroutine) + fut = asyncio.ensure_future(awaitable) + # See comments on IOLoop._pending_tasks. + loop = IOLoop.current() + loop._register_task(fut) + fut.add_done_callback(lambda f: loop._unregister_task(f)) + return fut def convert_yielded(yielded: _Yieldable) -> Future: diff --git a/tornado/http1connection.py b/tornado/http1connection.py index 835027b479..1a23f5c7fd 100644 --- a/tornado/http1connection.py +++ b/tornado/http1connection.py @@ -38,6 +38,8 @@ from typing import cast, Optional, Type, Awaitable, Callable, Union, Tuple +CR_OR_LF_RE = re.compile(b"\r|\n") + class _QuietException(Exception): def __init__(self) -> None: @@ -69,8 +71,7 @@ def __exit__( class HTTP1ConnectionParameters(object): - """Parameters for `.HTTP1Connection` and `.HTTP1ServerConnection`. - """ + """Parameters for `.HTTP1Connection` and `.HTTP1ServerConnection`.""" def __init__( self, @@ -132,7 +133,11 @@ def __init__( self.no_keep_alive = params.no_keep_alive # The body limits can be altered by the delegate, so save them # here instead of just referencing self.params later. - self._max_body_size = self.params.max_body_size or self.stream.max_buffer_size + self._max_body_size = ( + self.params.max_body_size + if self.params.max_body_size is not None + else self.stream.max_buffer_size + ) self._body_timeout = self.params.body_timeout # _write_finished is set to True when finish() has been called, # i.e. there will be no more data sent. Data may still be in the @@ -386,14 +391,11 @@ def write_headers( self._request_start_line = start_line lines.append(utf8("%s %s HTTP/1.1" % (start_line[0], start_line[1]))) # Client requests with a non-empty body must have either a - # Content-Length or a Transfer-Encoding. + # Content-Length or a Transfer-Encoding. If Content-Length is not + # present we'll add our Transfer-Encoding below. self._chunking_output = ( start_line.method in ("POST", "PUT", "PATCH") and "Content-Length" not in headers - and ( - "Transfer-Encoding" not in headers - or headers["Transfer-Encoding"] == "chunked" - ) ) else: assert isinstance(start_line, httputil.ResponseStartLine) @@ -415,9 +417,6 @@ def write_headers( and (start_line.code < 100 or start_line.code >= 200) # No need to chunk the output if a Content-Length is specified. and "Content-Length" not in headers - # Applications are discouraged from touching Transfer-Encoding, - # but if they do, leave it alone. - and "Transfer-Encoding" not in headers ) # If connection to a 1.1 client will be closed, inform client if ( @@ -439,7 +438,7 @@ def write_headers( ): self._expected_content_remaining = 0 elif "Content-Length" in headers: - self._expected_content_remaining = int(headers["Content-Length"]) + self._expected_content_remaining = parse_int(headers["Content-Length"]) else: self._expected_content_remaining = None # TODO: headers are supposed to be of type str, but we still have some @@ -450,8 +449,8 @@ def write_headers( ) lines.extend(line.encode("latin1") for line in header_lines) for line in lines: - if b"\n" in line: - raise ValueError("Newline in header: " + repr(line)) + if CR_OR_LF_RE.search(line): + raise ValueError("Illegal characters (CR or LF) in header: %r" % line) future = None if self.stream.closed(): future = self._write_future = Future() @@ -557,7 +556,7 @@ def _can_keep_alive( return connection_header != "close" elif ( "Content-Length" in headers - or headers.get("Transfer-Encoding", "").lower() == "chunked" + or is_transfer_encoding_chunked(headers) or getattr(start_line, "method", None) in ("HEAD", "GET") ): # start_line may be a request or response start line; only @@ -595,13 +594,6 @@ def _read_body( delegate: httputil.HTTPMessageDelegate, ) -> Optional[Awaitable[None]]: if "Content-Length" in headers: - if "Transfer-Encoding" in headers: - # Response cannot contain both Content-Length and - # Transfer-Encoding headers. - # http://tools.ietf.org/html/rfc7230#section-3.3.3 - raise httputil.HTTPInputError( - "Response with both Transfer-Encoding and Content-Length" - ) if "," in headers["Content-Length"]: # Proxies sometimes cause Content-Length headers to get # duplicated. If all the values are identical then we can @@ -615,7 +607,7 @@ def _read_body( headers["Content-Length"] = pieces[0] try: - content_length = int(headers["Content-Length"]) # type: Optional[int] + content_length: Optional[int] = parse_int(headers["Content-Length"]) except ValueError: # Handles non-integer Content-Length value. raise httputil.HTTPInputError( @@ -628,20 +620,22 @@ def _read_body( else: content_length = None + is_chunked = is_transfer_encoding_chunked(headers) + if code == 204: # This response code is not allowed to have a non-empty body, # and has an implicit length of zero instead of read-until-close. # http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.3 - if "Transfer-Encoding" in headers or content_length not in (None, 0): + if is_chunked or content_length not in (None, 0): raise httputil.HTTPInputError( "Response with code %d should not have body" % code ) content_length = 0 + if is_chunked: + return self._read_chunked_body(delegate) if content_length is not None: return self._read_fixed_body(content_length, delegate) - if headers.get("Transfer-Encoding", "").lower() == "chunked": - return self._read_chunked_body(delegate) if self.is_client: return self._read_body_until_close(delegate) return None @@ -665,7 +659,10 @@ async def _read_chunked_body(self, delegate: httputil.HTTPMessageDelegate) -> No total_size = 0 while True: chunk_len_str = await self.stream.read_until(b"\r\n", max_bytes=64) - chunk_len = int(chunk_len_str.strip(), 16) + try: + chunk_len = parse_hex_int(native_str(chunk_len_str[:-2])) + except ValueError: + raise httputil.HTTPInputError("invalid chunk size") if chunk_len == 0: crlf = await self.stream.read_bytes(2) if crlf != b"\r\n": @@ -703,8 +700,7 @@ async def _read_body_until_close( class _GzipMessageDelegate(httputil.HTTPMessageDelegate): - """Wraps an `HTTPMessageDelegate` to decode ``Content-Encoding: gzip``. - """ + """Wraps an `HTTPMessageDelegate` to decode ``Content-Encoding: gzip``.""" def __init__(self, delegate: httputil.HTTPMessageDelegate, chunk_size: int) -> None: self._delegate = delegate @@ -716,7 +712,7 @@ def headers_received( start_line: Union[httputil.RequestStartLine, httputil.ResponseStartLine], headers: httputil.HTTPHeaders, ) -> Optional[Awaitable[None]]: - if headers.get("Content-Encoding") == "gzip": + if headers.get("Content-Encoding", "").lower() == "gzip": self._decompressor = GzipDecompressor() # Downstream delegates will only see uncompressed data, # so rename the content-encoding header. @@ -840,3 +836,51 @@ async def _server_request_loop( await asyncio.sleep(0) finally: delegate.on_close(self) + + +DIGITS = re.compile(r"[0-9]+") +HEXDIGITS = re.compile(r"[0-9a-fA-F]+") + + +def parse_int(s: str) -> int: + """Parse a non-negative integer from a string.""" + if DIGITS.fullmatch(s) is None: + raise ValueError("not an integer: %r" % s) + return int(s) + + +def parse_hex_int(s: str) -> int: + """Parse a non-negative hexadecimal integer from a string.""" + if HEXDIGITS.fullmatch(s) is None: + raise ValueError("not a hexadecimal integer: %r" % s) + return int(s, 16) + + +def is_transfer_encoding_chunked(headers: httputil.HTTPHeaders) -> bool: + """Returns true if the headers specify Transfer-Encoding: chunked. + + Raise httputil.HTTPInputError if any other transfer encoding is used. + """ + # Note that transfer-encoding is an area in which postel's law can lead + # us astray. If a proxy and a backend server are liberal in what they accept, + # but accept slightly different things, this can lead to mismatched framing + # and request smuggling issues. Therefore we are as strict as possible here + # (even technically going beyond the requirements of the RFCs: a value of + # ",chunked" is legal but doesn't appear in practice for legitimate traffic) + if "Transfer-Encoding" not in headers: + return False + if "Content-Length" in headers: + # Message cannot contain both Content-Length and + # Transfer-Encoding headers. + # http://tools.ietf.org/html/rfc7230#section-3.3.3 + raise httputil.HTTPInputError( + "Message with both Transfer-Encoding and Content-Length" + ) + if headers["Transfer-Encoding"].lower() == "chunked": + return True + # We do not support any transfer-encodings other than chunked, and we do not + # expect to add any support because the concept of transfer-encoding has + # been removed in HTTP/2. + raise httputil.HTTPInputError( + "Unsupported Transfer-Encoding %s" % headers["Transfer-Encoding"] + ) diff --git a/tornado/httpserver.py b/tornado/httpserver.py index cd4a468120..757f711b24 100644 --- a/tornado/httpserver.py +++ b/tornado/httpserver.py @@ -74,7 +74,7 @@ class HTTPServer(TCPServer, Configurable, httputil.HTTPServerConnectionDelegate) To make this server serve SSL traffic, send the ``ssl_options`` keyword argument with an `ssl.SSLContext` object. For compatibility with older versions of Python ``ssl_options`` may also be a dictionary of keyword - arguments for the `ssl.wrap_socket` method.:: + arguments for the `ssl.SSLContext.wrap_socket` method.:: ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) ssl_ctx.load_cert_chain(os.path.join(data_dir, "mydomain.crt"), @@ -84,41 +84,53 @@ class HTTPServer(TCPServer, Configurable, httputil.HTTPServerConnectionDelegate) `HTTPServer` initialization follows one of three patterns (the initialization methods are defined on `tornado.tcpserver.TCPServer`): - 1. `~tornado.tcpserver.TCPServer.listen`: simple single-process:: + 1. `~tornado.tcpserver.TCPServer.listen`: single-process:: - server = HTTPServer(app) - server.listen(8888) - IOLoop.current().start() + async def main(): + server = HTTPServer() + server.listen(8888) + await asyncio.Event.wait() + + asyncio.run(main()) In many cases, `tornado.web.Application.listen` can be used to avoid the need to explicitly create the `HTTPServer`. - 2. `~tornado.tcpserver.TCPServer.bind`/`~tornado.tcpserver.TCPServer.start`: - simple multi-process:: + While this example does not create multiple processes on its own, when + the ``reuse_port=True`` argument is passed to ``listen()`` you can run + the program multiple times to create a multi-process service. - server = HTTPServer(app) - server.bind(8888) - server.start(0) # Forks multiple sub-processes - IOLoop.current().start() + 2. `~tornado.tcpserver.TCPServer.add_sockets`: multi-process:: - When using this interface, an `.IOLoop` must *not* be passed - to the `HTTPServer` constructor. `~.TCPServer.start` will always start - the server on the default singleton `.IOLoop`. + sockets = bind_sockets(8888) + tornado.process.fork_processes(0) + async def post_fork_main(): + server = HTTPServer() + server.add_sockets(sockets) + await asyncio.Event().wait() + asyncio.run(post_fork_main()) - 3. `~tornado.tcpserver.TCPServer.add_sockets`: advanced multi-process:: + The ``add_sockets`` interface is more complicated, but it can be used with + `tornado.process.fork_processes` to run a multi-process service with all + worker processes forked from a single parent. ``add_sockets`` can also be + used in single-process servers if you want to create your listening + sockets in some way other than `~tornado.netutil.bind_sockets`. - sockets = tornado.netutil.bind_sockets(8888) - tornado.process.fork_processes(0) - server = HTTPServer(app) - server.add_sockets(sockets) + Note that when using this pattern, nothing that touches the event loop + can be run before ``fork_processes``. + + 3. `~tornado.tcpserver.TCPServer.bind`/`~tornado.tcpserver.TCPServer.start`: + simple **deprecated** multi-process:: + + server = HTTPServer() + server.bind(8888) + server.start(0) # Forks multiple sub-processes IOLoop.current().start() - The `~.TCPServer.add_sockets` interface is more complicated, - but it can be used with `tornado.process.fork_processes` to - give you more flexibility in when the fork happens. - `~.TCPServer.add_sockets` can also be used in single-process - servers if you want to create your listening sockets in some - way other than `tornado.netutil.bind_sockets`. + This pattern is deprecated because it requires interfaces in the + `asyncio` module that have been deprecated since Python 3.10. Support for + creating multiple processes in the ``start`` method will be removed in a + future version of Tornado. .. versionchanged:: 4.0 Added ``decompress_request``, ``chunk_size``, ``max_header_size``, diff --git a/tornado/httputil.py b/tornado/httputil.py index bd32cd0c49..ebdc8059c1 100644 --- a/tornado/httputil.py +++ b/tornado/httputil.py @@ -20,7 +20,7 @@ """ import calendar -import collections +import collections.abc import copy import datetime import email.utils @@ -62,6 +62,9 @@ from asyncio import Future # noqa: F401 import unittest # noqa: F401 +# To be used with str.strip() and related methods. +HTTP_WHITESPACE = " \t" + @lru_cache(1000) def _normalize_header(name: str) -> str: @@ -171,7 +174,7 @@ def parse_line(self, line: str) -> None: # continuation of a multi-line header if self._last_key is None: raise HTTPInputError("first header line cannot start with whitespace") - new_part = " " + line.lstrip() + new_part = " " + line.lstrip(HTTP_WHITESPACE) self._as_list[self._last_key][-1] += new_part self._dict[self._last_key] += new_part else: @@ -179,7 +182,7 @@ def parse_line(self, line: str) -> None: name, value = line.split(":", 1) except ValueError: raise HTTPInputError("no colon in header line") - self.add(name, value.strip()) + self.add(name, value.strip(HTTP_WHITESPACE)) @classmethod def parse(cls, headers: str) -> "HTTPHeaders": @@ -408,7 +411,7 @@ def cookies(self) -> Dict[str, http.cookies.Morsel]: def full_url(self) -> str: """Reconstructs the full URL for this request.""" - return self.protocol + "://" + self.host + self.uri + return self.protocol + "://" + self.host + self.uri # type: ignore[operator] def request_time(self) -> float: """Returns the amount of time it took for this request to execute.""" @@ -602,8 +605,7 @@ def write(self, chunk: bytes) -> "Future[None]": raise NotImplementedError() def finish(self) -> None: - """Indicates that the last body data has been written. - """ + """Indicates that the last body data has been written.""" raise NotImplementedError() @@ -665,7 +667,9 @@ class HTTPFile(ObjectDict): * ``content_type`` """ - pass + filename: str + body: bytes + content_type: str def _parse_request_range( @@ -855,7 +859,8 @@ def format_timestamp( The argument may be a numeric timestamp as returned by `time.time`, a time tuple as returned by `time.gmtime`, or a `datetime.datetime` - object. + object. Naive `datetime.datetime` objects are assumed to represent + UTC; aware objects are converted to UTC before formatting. >>> format_timestamp(1359312200) 'Sun, 27 Jan 2013 18:43:20 GMT' @@ -1052,15 +1057,20 @@ def qs_to_qsl(qs: Dict[str, List[AnyStr]]) -> Iterable[Tuple[str, AnyStr]]: yield (k, v) -_OctalPatt = re.compile(r"\\[0-3][0-7][0-7]") -_QuotePatt = re.compile(r"[\\].") -_nulljoin = "".join +_unquote_sub = re.compile(r"\\(?:([0-3][0-7][0-7])|(.))").sub + + +def _unquote_replace(m: re.Match) -> str: + if m[1]: + return chr(int(m[1], 8)) + else: + return m[2] def _unquote_cookie(s: str) -> str: """Handle double quotes and escaping in cookie values. - This method is copied verbatim from the Python 3.5 standard + This method is copied verbatim from the Python 3.13 standard library (http.cookies._unquote) so we don't have to depend on non-public interfaces. """ @@ -1081,30 +1091,7 @@ def _unquote_cookie(s: str) -> str: # \012 --> \n # \" --> " # - i = 0 - n = len(s) - res = [] - while 0 <= i < n: - o_match = _OctalPatt.search(s, i) - q_match = _QuotePatt.search(s, i) - if not o_match and not q_match: # Neither matched - res.append(s[i:]) - break - # else: - j = k = -1 - if o_match: - j = o_match.start(0) - if q_match: - k = q_match.start(0) - if q_match and (not o_match or k < j): # QuotePatt matched - res.append(s[i:k]) - res.append(s[k + 1]) - i = k + 2 - else: # OctalPatt matched - res.append(s[i:j]) - res.append(chr(int(s[j + 1 : j + 4], 8))) - i = j + 4 - return _nulljoin(res) + return _unquote_sub(_unquote_replace, s) def parse_cookie(cookie: str) -> Dict[str, str]: diff --git a/tornado/ioloop.py b/tornado/ioloop.py index 2cf884450c..3fb1359aae 100644 --- a/tornado/ioloop.py +++ b/tornado/ioloop.py @@ -15,18 +15,11 @@ """An I/O event loop for non-blocking sockets. -In Tornado 6.0, `.IOLoop` is a wrapper around the `asyncio` event -loop, with a slightly different interface for historical reasons. -Applications can use either the `.IOLoop` interface or the underlying -`asyncio` event loop directly (unless compatibility with older -versions of Tornado is desired, in which case `.IOLoop` must be used). - -Typical applications will use a single `IOLoop` object, accessed via -`IOLoop.current` class method. The `IOLoop.start` method (or -equivalently, `asyncio.AbstractEventLoop.run_forever`) should usually -be called at the end of the ``main()`` function. Atypical applications -may use more than one `IOLoop`, such as one `IOLoop` per thread, or -per `unittest` case. +In Tornado 6.0, `.IOLoop` is a wrapper around the `asyncio` event loop, with a +slightly different interface. The `.IOLoop` interface is now provided primarily +for backwards compatibility; new code should generally use the `asyncio` event +loop interface directly. The `IOLoop.current` class method provides the +`IOLoop` instance corresponding to the running `asyncio` event loop. """ @@ -34,13 +27,14 @@ import concurrent.futures import datetime import functools -import logging import numbers import os import sys import time import math import random +import warnings +from inspect import isawaitable from tornado.concurrent import ( Future, @@ -56,7 +50,7 @@ from typing import Union, Any, Type, Optional, Callable, TypeVar, Tuple, Awaitable if typing.TYPE_CHECKING: - from typing import Dict, List # noqa: F401 + from typing import Dict, List, Set # noqa: F401 from typing_extensions import Protocol else: @@ -78,18 +72,18 @@ def close(self) -> None: class IOLoop(Configurable): """An I/O event loop. - As of Tornado 6.0, `IOLoop` is a wrapper around the `asyncio` event - loop. + As of Tornado 6.0, `IOLoop` is a wrapper around the `asyncio` event loop. Example usage for a simple TCP server: .. testcode:: + import asyncio import errno import functools import socket - import tornado.ioloop + import tornado from tornado.iostream import IOStream async def handle_connection(connection, address): @@ -107,7 +101,7 @@ def connection_ready(sock, fd, events): io_loop = tornado.ioloop.IOLoop.current() io_loop.spawn_callback(handle_connection, connection, address) - if __name__ == '__main__': + async def main(): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.setblocking(0) @@ -117,28 +111,28 @@ def connection_ready(sock, fd, events): io_loop = tornado.ioloop.IOLoop.current() callback = functools.partial(connection_ready, sock) io_loop.add_handler(sock.fileno(), callback, io_loop.READ) - io_loop.start() + await asyncio.Event().wait() + + if __name__ == "__main__": + asyncio.run(main()) .. testoutput:: :hide: - By default, a newly-constructed `IOLoop` becomes the thread's current - `IOLoop`, unless there already is a current `IOLoop`. This behavior - can be controlled with the ``make_current`` argument to the `IOLoop` - constructor: if ``make_current=True``, the new `IOLoop` will always - try to become current and it raises an error if there is already a - current instance. If ``make_current=False``, the new `IOLoop` will - not try to become current. - - In general, an `IOLoop` cannot survive a fork or be shared across - processes in any way. When multiple processes are being used, each - process should create its own `IOLoop`, which also implies that - any objects which depend on the `IOLoop` (such as - `.AsyncHTTPClient`) must also be created in the child processes. - As a guideline, anything that starts processes (including the - `tornado.process` and `multiprocessing` modules) should do so as - early as possible, ideally the first thing the application does - after loading its configuration in ``main()``. + Most applications should not attempt to construct an `IOLoop` directly, + and instead initialize the `asyncio` event loop and use `IOLoop.current()`. + In some cases, such as in test frameworks when initializing an `IOLoop` + to be run in a secondary thread, it may be appropriate to construct + an `IOLoop` with ``IOLoop(make_current=False)``. + + In general, an `IOLoop` cannot survive a fork or be shared across processes + in any way. When multiple processes are being used, each process should + create its own `IOLoop`, which also implies that any objects which depend on + the `IOLoop` (such as `.AsyncHTTPClient`) must also be created in the child + processes. As a guideline, anything that starts processes (including the + `tornado.process` and `multiprocessing` modules) should do so as early as + possible, ideally the first thing the application does after loading its + configuration, and *before* any calls to `.IOLoop.start` or `asyncio.run`. .. versionchanged:: 4.2 Added the ``make_current`` keyword argument to the `IOLoop` @@ -146,10 +140,14 @@ def connection_ready(sock, fd, events): .. versionchanged:: 5.0 - Uses the `asyncio` event loop by default. The - ``IOLoop.configure`` method cannot be used on Python 3 except - to redundantly specify the `asyncio` event loop. + Uses the `asyncio` event loop by default. The ``IOLoop.configure`` method + cannot be used on Python 3 except to redundantly specify the `asyncio` + event loop. + .. versionchanged:: 6.3 + ``make_current=True`` is now the default when creating an IOLoop - + previously the default was to make the event loop current if there wasn't + already a current one. """ # These constants were originally based on constants from the epoll module. @@ -161,19 +159,28 @@ def connection_ready(sock, fd, events): # In Python 3, _ioloop_for_asyncio maps from asyncio loops to IOLoops. _ioloop_for_asyncio = dict() # type: Dict[asyncio.AbstractEventLoop, IOLoop] + # Maintain a set of all pending tasks to follow the warning in the docs + # of asyncio.create_tasks: + # https://docs.python.org/3.11/library/asyncio-task.html#asyncio.create_task + # This ensures that all pending tasks have a strong reference so they + # will not be garbage collected before they are finished. + # (Thus avoiding "task was destroyed but it is pending" warnings) + # An analogous change has been proposed in cpython for 3.13: + # https://github.com/python/cpython/issues/91887 + # If that change is accepted, this can eventually be removed. + # If it is not, we will consider the rationale and may remove this. + _pending_tasks = set() # type: Set[Future] + @classmethod def configure( cls, impl: "Union[None, str, Type[Configurable]]", **kwargs: Any ) -> None: - if asyncio is not None: - from tornado.platform.asyncio import BaseAsyncIOLoop - - if isinstance(impl, str): - impl = import_object(impl) - if isinstance(impl, type) and not issubclass(impl, BaseAsyncIOLoop): - raise RuntimeError( - "only AsyncIOLoop is allowed when asyncio is available" - ) + from tornado.platform.asyncio import BaseAsyncIOLoop + + if isinstance(impl, str): + impl = import_object(impl) + if isinstance(impl, type) and not issubclass(impl, BaseAsyncIOLoop): + raise RuntimeError("only AsyncIOLoop is allowed when asyncio is available") super(IOLoop, cls).configure(impl, **kwargs) @staticmethod @@ -258,20 +265,27 @@ def current(instance: bool = True) -> Optional["IOLoop"]: # noqa: F811 an alias for this method). ``instance=False`` is deprecated, since even if we do not create an `IOLoop`, this method may initialize the asyncio loop. + + .. deprecated:: 6.2 + It is deprecated to call ``IOLoop.current()`` when no `asyncio` + event loop is running. """ try: loop = asyncio.get_event_loop() - except (RuntimeError, AssertionError): + except RuntimeError: if not instance: return None - raise + # Create a new asyncio event loop for this thread. + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: return IOLoop._ioloop_for_asyncio[loop] except KeyError: if instance: from tornado.platform.asyncio import AsyncIOMainLoop - current = AsyncIOMainLoop(make_current=True) # type: Optional[IOLoop] + current = AsyncIOMainLoop() # type: Optional[IOLoop] else: current = None return current @@ -291,7 +305,19 @@ def make_current(self) -> None: .. versionchanged:: 5.0 This method also sets the current `asyncio` event loop. + + .. deprecated:: 6.2 + Setting and clearing the current event loop through Tornado is + deprecated. Use ``asyncio.set_event_loop`` instead if you need this. """ + warnings.warn( + "make_current is deprecated; start the event loop first", + DeprecationWarning, + stacklevel=2, + ) + self._make_current() + + def _make_current(self) -> None: # The asyncio event loops override this method. raise NotImplementedError() @@ -303,12 +329,20 @@ def clear_current() -> None: .. versionchanged:: 5.0 This method also clears the current `asyncio` event loop. + .. deprecated:: 6.2 """ + warnings.warn( + "clear_current is deprecated", + DeprecationWarning, + stacklevel=2, + ) + IOLoop._clear_current() + + @staticmethod + def _clear_current() -> None: old = IOLoop.current(instance=False) if old is not None: old._clear_current_hook() - if asyncio is None: - IOLoop._current.instance = None def _clear_current_hook(self) -> None: """Instance method called when an IOLoop ceases to be current. @@ -327,16 +361,9 @@ def configurable_default(cls) -> Type[Configurable]: return AsyncIOLoop - def initialize(self, make_current: Optional[bool] = None) -> None: - if make_current is None: - if IOLoop.current(instance=False) is None: - self.make_current() - elif make_current: - current = IOLoop.current(instance=False) - # AsyncIO loops can already be current by this point. - if current is not None and current is not self: - raise RuntimeError("current IOLoop already exists") - self.make_current() + def initialize(self, make_current: bool = True) -> None: + if make_current: + self._make_current() def close(self, all_fds: bool = False) -> None: """Closes the `IOLoop`, freeing any resources used. @@ -422,26 +449,6 @@ def start(self) -> None: """ raise NotImplementedError() - def _setup_logging(self) -> None: - """The IOLoop catches and logs exceptions, so it's - important that log output be visible. However, python's - default behavior for non-root loggers (prior to python - 3.2) is to print an unhelpful "no handlers could be - found" message rather than the actual log entry, so we - must explicitly configure logging if we've made it this - far without anything. - - This method should be called from start() in subclasses. - """ - if not any( - [ - logging.getLogger().handlers, - logging.getLogger("tornado").handlers, - logging.getLogger("tornado.application").handlers, - ] - ): - logging.basicConfig() - def stop(self) -> None: """Stop the I/O loop. @@ -467,7 +474,7 @@ def run_sync(self, func: Callable, timeout: Optional[float] = None) -> Any: The keyword-only argument ``timeout`` may be used to set a maximum duration for the function. If the timeout expires, - a `tornado.util.TimeoutError` is raised. + a `asyncio.TimeoutError` is raised. This method is useful to allow asynchronous calls in a ``main()`` function:: @@ -484,6 +491,8 @@ async def main(): .. versionchanged:: 5.0 If a timeout occurs, the ``func`` coroutine will be cancelled. + .. versionchanged:: 6.2 + ``tornado.util.TimeoutError`` is now an alias to ``asyncio.TimeoutError``. """ future_cell = [None] # type: List[Optional[Future]] @@ -546,7 +555,7 @@ def time(self) -> float: def add_timeout( self, deadline: Union[float, datetime.timedelta], - callback: Callable[..., None], + callback: Callable, *args: Any, **kwargs: Any ) -> object: @@ -585,7 +594,7 @@ def add_timeout( raise TypeError("Unsupported deadline %r" % deadline) def call_later( - self, delay: float, callback: Callable[..., None], *args: Any, **kwargs: Any + self, delay: float, callback: Callable, *args: Any, **kwargs: Any ) -> object: """Runs the ``callback`` after ``delay`` seconds have passed. @@ -600,7 +609,7 @@ def call_later( return self.call_at(self.time() + delay, callback, *args, **kwargs) def call_at( - self, when: float, callback: Callable[..., None], *args: Any, **kwargs: Any + self, when: float, callback: Callable, *args: Any, **kwargs: Any ) -> object: """Runs the ``callback`` at the absolute time designated by ``when``. @@ -635,9 +644,6 @@ def add_callback(self, callback: Callable, *args: Any, **kwargs: Any) -> None: other interaction with the `IOLoop` must be done from that `IOLoop`'s thread. `add_callback()` may be used to transfer control from other threads to the `IOLoop`'s thread. - - To add a callback from a signal handler, see - `add_callback_from_signal`. """ raise NotImplementedError() @@ -646,8 +652,13 @@ def add_callback_from_signal( ) -> None: """Calls the given callback on the next I/O loop iteration. - Safe for use from a Python signal handler; should not be used - otherwise. + Intended to be afe for use from a Python signal handler; should not be + used otherwise. + + .. deprecated:: 6.4 + Use ``asyncio.AbstractEventLoop.add_signal_handler`` instead. + This method is suspected to have been broken since Tornado 5.0 and + will be removed in version 7.0. """ raise NotImplementedError() @@ -685,22 +696,20 @@ def add_future( # the error logging (i.e. it goes to tornado.log.app_log # instead of asyncio's log). future.add_done_callback( - lambda f: self._run_callback(functools.partial(callback, future)) + lambda f: self._run_callback(functools.partial(callback, f)) ) else: assert is_future(future) # For concurrent futures, we use self.add_callback, so # it's fine if future_add_done_callback inlines that call. - future_add_done_callback( - future, lambda f: self.add_callback(callback, future) - ) + future_add_done_callback(future, lambda f: self.add_callback(callback, f)) def run_in_executor( self, executor: Optional[concurrent.futures.Executor], func: Callable[..., _T], *args: Any - ) -> Awaitable[_T]: + ) -> "Future[_T]": """Runs a function in a ``concurrent.futures.Executor``. If ``executor`` is ``None``, the IO loop's default executor will be used. @@ -806,6 +815,12 @@ def close_fd(self, fd: Union[int, _Selectable]) -> None: except OSError: pass + def _register_task(self, f: Future) -> None: + self._pending_tasks.add(f) + + def _unregister_task(self, f: Future) -> None: + self._pending_tasks.discard(f) + class _Timeout(object): """An IOLoop timeout, a UNIX timestamp and a callback""" @@ -839,9 +854,11 @@ def __le__(self, other: "_Timeout") -> bool: class PeriodicCallback(object): """Schedules the given callback to be called periodically. - The callback is called every ``callback_time`` milliseconds. - Note that the timeout is given in milliseconds, while most other - time-related functions in Tornado use seconds. + The callback is called every ``callback_time`` milliseconds when + ``callback_time`` is a float. Note that the timeout is given in + milliseconds, while most other time-related functions in Tornado use + seconds. ``callback_time`` may alternatively be given as a + `datetime.timedelta` object. If ``jitter`` is specified, each callback time will be randomly selected within a window of ``jitter * callback_time`` milliseconds. @@ -861,15 +878,30 @@ class PeriodicCallback(object): .. versionchanged:: 5.1 The ``jitter`` argument is added. + + .. versionchanged:: 6.2 + If the ``callback`` argument is a coroutine, and a callback runs for + longer than ``callback_time``, subsequent invocations will be skipped. + Previously this was only true for regular functions, not coroutines, + which were "fire-and-forget" for `PeriodicCallback`. + + The ``callback_time`` argument now accepts `datetime.timedelta` objects, + in addition to the previous numeric milliseconds. """ def __init__( - self, callback: Callable[[], None], callback_time: float, jitter: float = 0 + self, + callback: Callable[[], Optional[Awaitable]], + callback_time: Union[datetime.timedelta, float], + jitter: float = 0, ) -> None: self.callback = callback - if callback_time <= 0: - raise ValueError("Periodic callback must have a positive callback_time") - self.callback_time = callback_time + if isinstance(callback_time, datetime.timedelta): + self.callback_time = callback_time / datetime.timedelta(milliseconds=1) + else: + if callback_time <= 0: + raise ValueError("Periodic callback must have a positive callback_time") + self.callback_time = callback_time self.jitter = jitter self._running = False self._timeout = None # type: object @@ -898,11 +930,13 @@ def is_running(self) -> bool: """ return self._running - def _run(self) -> None: + async def _run(self) -> None: if not self._running: return try: - return self.callback() + val = self.callback() + if val is not None and isawaitable(val): + await val except Exception: app_log.error("Exception in callback %r", self.callback, exc_info=True) finally: diff --git a/tornado/iostream.py b/tornado/iostream.py index 19c548553a..ee5775932e 100644 --- a/tornado/iostream.py +++ b/tornado/iostream.py @@ -111,8 +111,7 @@ class UnsatisfiableReadError(Exception): class StreamBufferFullError(Exception): - """Exception raised by `IOStream` methods when the buffer is full. - """ + """Exception raised by `IOStream` methods when the buffer is full.""" class _StreamBuffer(object): @@ -196,11 +195,9 @@ def advance(self, size: int) -> None: pos += size size = 0 else: - # Amortized O(1) shrink for Python 2 pos += size - if len(b) <= 2 * pos: - del typing.cast(bytearray, b)[:pos] - pos = 0 + del typing.cast(bytearray, b)[:pos] + pos = 0 size = 0 assert size == 0 @@ -255,7 +252,6 @@ def __init__( self.max_write_buffer_size = max_write_buffer_size self.error = None # type: Optional[BaseException] self._read_buffer = bytearray() - self._read_buffer_pos = 0 self._read_buffer_size = 0 self._user_read_buffer = False self._after_user_read_buffer = None # type: Optional[bytearray] @@ -452,21 +448,17 @@ def read_into(self, buf: bytearray, partial: bool = False) -> Awaitable[int]: available_bytes = self._read_buffer_size n = len(buf) if available_bytes >= n: - end = self._read_buffer_pos + n - buf[:] = memoryview(self._read_buffer)[self._read_buffer_pos : end] - del self._read_buffer[:end] + buf[:] = memoryview(self._read_buffer)[:n] + del self._read_buffer[:n] self._after_user_read_buffer = self._read_buffer elif available_bytes > 0: - buf[:available_bytes] = memoryview(self._read_buffer)[ - self._read_buffer_pos : - ] + buf[:available_bytes] = memoryview(self._read_buffer)[:] # Set up the supplied buffer as our temporary read buffer. # The original (if it had any data remaining) has been # saved for later. self._user_read_buffer = True self._read_buffer = buf - self._read_buffer_pos = 0 self._read_buffer_size = available_bytes self._read_bytes = n self._read_partial = partial @@ -498,7 +490,7 @@ def read_until_close(self) -> Awaitable[bytes]: """ future = self._start_read() if self.closed(): - self._finish_read(self._read_buffer_size, False) + self._finish_read(self._read_buffer_size) return future self._read_until_close = True try: @@ -530,6 +522,9 @@ def write(self, data: Union[bytes, memoryview]) -> "Future[None]": """ self._check_closed() if data: + if isinstance(data, memoryview): + # Make sure that ``len(data) == data.nbytes`` + data = memoryview(data).cast("B") if ( self.max_write_buffer_size is not None and len(self._write_buffer) + len(data) > self.max_write_buffer_size @@ -593,7 +588,7 @@ def close( self.error = exc_info[1] if self._read_until_close: self._read_until_close = False - self._finish_read(self._read_buffer_size, False) + self._finish_read(self._read_buffer_size) elif self._read_future is not None: # resolve reads that are pending and ready to complete try: @@ -810,11 +805,10 @@ def _start_read(self) -> Future: self._read_future = Future() return self._read_future - def _finish_read(self, size: int, streaming: bool) -> None: + def _finish_read(self, size: int) -> None: if self._user_read_buffer: self._read_buffer = self._after_user_read_buffer or bytearray() self._after_user_read_buffer = None - self._read_buffer_pos = 0 self._read_buffer_size = len(self._read_buffer) self._user_read_buffer = False result = size # type: Union[int, bytes] @@ -902,7 +896,7 @@ def _read_from_buffer(self, pos: int) -> None: """ self._read_bytes = self._read_delimiter = self._read_regex = None self._read_partial = False - self._finish_read(pos, False) + self._finish_read(pos) def _find_read_pos(self) -> Optional[int]: """Attempts to find a position in the read buffer that satisfies @@ -927,20 +921,17 @@ def _find_read_pos(self) -> Optional[int]: # since large merges are relatively expensive and get undone in # _consume(). if self._read_buffer: - loc = self._read_buffer.find( - self._read_delimiter, self._read_buffer_pos - ) + loc = self._read_buffer.find(self._read_delimiter) if loc != -1: - loc -= self._read_buffer_pos delimiter_len = len(self._read_delimiter) self._check_max_bytes(self._read_delimiter, loc + delimiter_len) return loc + delimiter_len self._check_max_bytes(self._read_delimiter, self._read_buffer_size) elif self._read_regex is not None: if self._read_buffer: - m = self._read_regex.search(self._read_buffer, self._read_buffer_pos) + m = self._read_regex.search(self._read_buffer) if m is not None: - loc = m.end() - self._read_buffer_pos + loc = m.end() self._check_max_bytes(self._read_regex, loc) return loc self._check_max_bytes(self._read_regex, self._read_buffer_size) @@ -997,19 +988,9 @@ def _consume(self, loc: int) -> bytes: return b"" assert loc <= self._read_buffer_size # Slice the bytearray buffer into bytes, without intermediate copying - b = ( - memoryview(self._read_buffer)[ - self._read_buffer_pos : self._read_buffer_pos + loc - ] - ).tobytes() - self._read_buffer_pos += loc + b = (memoryview(self._read_buffer)[:loc]).tobytes() self._read_buffer_size -= loc - # Amortized O(1) shrink - # (this heuristic is implemented natively in Python 3.4+ - # but is replicated here for Python 2) - if self._read_buffer_pos > self._read_buffer_size: - del self._read_buffer[: self._read_buffer_pos] - self._read_buffer_pos = 0 + del self._read_buffer[:loc] return b def _check_closed(self) -> None: @@ -1088,9 +1069,8 @@ class IOStream(BaseIOStream): .. testcode:: - import tornado.ioloop - import tornado.iostream import socket + import tornado async def main(): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0) @@ -1108,11 +1088,7 @@ async def main(): stream.close() if __name__ == '__main__': - tornado.ioloop.IOLoop.current().run_sync(main) - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0) - stream = tornado.iostream.IOStream(s) - stream.connect(("friendfeed.com", 80), send_request) - tornado.ioloop.IOLoop.current().start() + asyncio.run(main()) .. testoutput:: :hide: @@ -1241,7 +1217,7 @@ def start_tls( The ``ssl_options`` argument may be either an `ssl.SSLContext` object or a dictionary of keyword arguments for the - `ssl.wrap_socket` function. The ``server_hostname`` argument + `ssl.SSLContext.wrap_socket` function. The ``server_hostname`` argument will be used for certificate validation unless disabled in the ``ssl_options``. @@ -1346,7 +1322,7 @@ class SSLIOStream(IOStream): If the socket passed to the constructor is already connected, it should be wrapped with:: - ssl.wrap_socket(sock, do_handshake_on_connect=False, **kwargs) + ssl.SSLContext(...).wrap_socket(sock, do_handshake_on_connect=False, **kwargs) before constructing the `SSLIOStream`. Unconnected sockets will be wrapped when `IOStream.connect` is finished. @@ -1357,7 +1333,7 @@ class SSLIOStream(IOStream): def __init__(self, *args: Any, **kwargs: Any) -> None: """The ``ssl_options`` keyword argument may either be an `ssl.SSLContext` object or a dictionary of keywords arguments - for `ssl.wrap_socket` + for `ssl.SSLContext.wrap_socket` """ self._ssl_options = kwargs.pop("ssl_options", _client_ssl_defaults) super().__init__(*args, **kwargs) @@ -1398,7 +1374,7 @@ def _do_ssl_handshake(self) -> None: return elif err.args[0] in (ssl.SSL_ERROR_EOF, ssl.SSL_ERROR_ZERO_RETURN): return self.close(exc_info=err) - elif err.args[0] == ssl.SSL_ERROR_SSL: + elif err.args[0] in (ssl.SSL_ERROR_SSL, ssl.SSL_ERROR_SYSCALL): try: peer = self.socket.getpeername() except Exception: @@ -1435,9 +1411,9 @@ def _do_ssl_handshake(self) -> None: return self.close(exc_info=err) else: self._ssl_accepting = False - if not self._verify_cert(self.socket.getpeercert()): - self.close() - return + # Prior to the introduction of SNI, this is where we would check + # the server's claimed hostname. + assert ssl.HAS_SNI self._finish_ssl_connect() def _finish_ssl_connect(self) -> None: @@ -1446,33 +1422,6 @@ def _finish_ssl_connect(self) -> None: self._ssl_connect_future = None future_set_result_unless_cancelled(future, self) - def _verify_cert(self, peercert: Any) -> bool: - """Returns ``True`` if peercert is valid according to the configured - validation mode and hostname. - - The ssl handshake already tested the certificate for a valid - CA signature; the only thing that remains is to check - the hostname. - """ - if isinstance(self._ssl_options, dict): - verify_mode = self._ssl_options.get("cert_reqs", ssl.CERT_NONE) - elif isinstance(self._ssl_options, ssl.SSLContext): - verify_mode = self._ssl_options.verify_mode - assert verify_mode in (ssl.CERT_NONE, ssl.CERT_REQUIRED, ssl.CERT_OPTIONAL) - if verify_mode == ssl.CERT_NONE or self._server_hostname is None: - return True - cert = self.socket.getpeercert() - if cert is None and verify_mode == ssl.CERT_REQUIRED: - gen_log.warning("No SSL certificate given") - return False - try: - ssl.match_hostname(peercert, self._server_hostname) - except ssl.CertificateError as e: - gen_log.warning("Invalid SSL certificate: %s" % e) - return False - else: - return True - def _handle_read(self) -> None: if self._ssl_accepting: self._do_ssl_handshake() @@ -1528,6 +1477,7 @@ def _handle_connect(self) -> None: self._ssl_options, server_hostname=self._server_hostname, do_handshake_on_connect=False, + server_side=False, ) self._add_io_state(old_state) @@ -1564,6 +1514,11 @@ def wait_for_handshake(self) -> "Future[SSLIOStream]": return future def write_to_fd(self, data: memoryview) -> int: + # clip buffer size at 1GB since SSL sockets only support upto 2GB + # this change in behaviour is transparent, since the function is + # already expected to (possibly) write less than the provided buffer + if len(data) >> 30: + data = memoryview(data)[: 1 << 30] try: return self.socket.send(data) # type: ignore except ssl.SSLError as e: @@ -1588,6 +1543,11 @@ def read_from_fd(self, buf: Union[bytearray, memoryview]) -> Optional[int]: # to read (attempting to read may or may not raise an exception # depending on the SSL version) return None + # clip buffer size at 1GB since SSL sockets only support upto 2GB + # this change in behaviour is transparent, since the function is + # already expected to (possibly) read less than the provided buffer + if len(buf) >> 30: + buf = memoryview(buf)[: 1 << 30] try: return self.socket.recv_into(buf, len(buf)) except ssl.SSLError as e: @@ -1622,6 +1582,13 @@ class PipeIOStream(BaseIOStream): def __init__(self, fd: int, *args: Any, **kwargs: Any) -> None: self.fd = fd self._fio = io.FileIO(self.fd, "r+") + if sys.platform == "win32": + # The form and placement of this assertion is important to mypy. + # A plain assert statement isn't recognized here. If the assertion + # were earlier it would worry that the attributes of self aren't + # set on windows. If it were missing it would complain about + # the absence of the set_blocking function. + raise AssertionError("PipeIOStream is not supported on Windows") os.set_blocking(fd, False) super().__init__(*args, **kwargs) diff --git a/tornado/locale.py b/tornado/locale.py index adb1f77420..c5526703b1 100644 --- a/tornado/locale.py +++ b/tornado/locale.py @@ -41,6 +41,7 @@ import csv import datetime import gettext +import glob import os import re @@ -198,13 +199,12 @@ def load_gettext_translations(directory: str, domain: str) -> None: global _supported_locales global _use_gettext _translations = {} - for lang in os.listdir(directory): - if lang.startswith("."): - continue # skip .svn, etc - if os.path.isfile(os.path.join(directory, lang)): - continue + + for filename in glob.glob( + os.path.join(directory, "*", "LC_MESSAGES", domain + ".mo") + ): + lang = os.path.basename(os.path.dirname(os.path.dirname(filename))) try: - os.stat(os.path.join(directory, lang, "LC_MESSAGES", domain + ".mo")) _translations[lang] = gettext.translation( domain, directory, languages=[lang] ) @@ -268,7 +268,7 @@ def get(cls, code: str) -> "Locale": def __init__(self, code: str) -> None: self.code = code - self.name = LOCALE_NAMES.get(code, {}).get("name", u"Unknown") + self.name = LOCALE_NAMES.get(code, {}).get("name", "Unknown") self.rtl = False for prefix in ["fa", "ar", "he"]: if self.code.startswith(prefix): @@ -333,7 +333,7 @@ def format_date( shorter: bool = False, full_format: bool = False, ) -> str: - """Formats the given date (which should be GMT). + """Formats the given date. By default, we return a relative time (e.g., "2 minutes ago"). You can return an absolute date string with ``relative=False``. @@ -343,10 +343,16 @@ def format_date( This method is primarily intended for dates in the past. For dates in the future, we fall back to full format. + + .. versionchanged:: 6.4 + Aware `datetime.datetime` objects are now supported (naive + datetimes are still assumed to be UTC). """ if isinstance(date, (int, float)): - date = datetime.datetime.utcfromtimestamp(date) - now = datetime.datetime.utcnow() + date = datetime.datetime.fromtimestamp(date, datetime.timezone.utc) + if date.tzinfo is None: + date = date.replace(tzinfo=datetime.timezone.utc) + now = datetime.datetime.now(datetime.timezone.utc) if date > now: if relative and (date - now).seconds < 60: # Due to click skew, things are some things slightly @@ -406,7 +412,7 @@ def format_date( str_time = "%d:%02d" % (local_date.hour, local_date.minute) elif self.code == "zh_CN": str_time = "%s%d:%02d" % ( - (u"\u4e0a\u5348", u"\u4e0b\u5348")[local_date.hour >= 12], + ("\u4e0a\u5348", "\u4e0b\u5348")[local_date.hour >= 12], local_date.hour % 12 or 12, local_date.minute, ) @@ -458,7 +464,7 @@ def list(self, parts: Any) -> str: return "" if len(parts) == 1: return parts[0] - comma = u" \u0648 " if self.code.startswith("fa") else u", " + comma = " \u0648 " if self.code.startswith("fa") else ", " return _("%(commas)s and %(last)s") % { "commas": comma.join(parts[:-1]), "last": parts[len(parts) - 1], diff --git a/tornado/locks.py b/tornado/locks.py index 0898ebaa8b..1bcec1b3af 100644 --- a/tornado/locks.py +++ b/tornado/locks.py @@ -60,8 +60,8 @@ class Condition(_TimeoutGarbageCollector): .. testcode:: + import asyncio from tornado import gen - from tornado.ioloop import IOLoop from tornado.locks import Condition condition = Condition() @@ -80,7 +80,7 @@ async def runner(): # Wait for waiter() and notifier() in parallel await gen.multi([waiter(), notifier()]) - IOLoop.current().run_sync(runner) + asyncio.run(runner()) .. testoutput:: @@ -110,10 +110,6 @@ async def runner(): next iteration of the `.IOLoop`. """ - def __init__(self) -> None: - super().__init__() - self.io_loop = ioloop.IOLoop.current() - def __repr__(self) -> str: result = "<%s" % (self.__class__.__name__,) if self._waiters: @@ -169,8 +165,8 @@ class Event(object): .. testcode:: + import asyncio from tornado import gen - from tornado.ioloop import IOLoop from tornado.locks import Event event = Event() @@ -189,7 +185,7 @@ async def setter(): async def runner(): await gen.multi([waiter(), setter()]) - IOLoop.current().run_sync(runner) + asyncio.run(runner()) .. testoutput:: @@ -262,10 +258,10 @@ def wait( class _ReleasingContextManager(object): """Releases a Lock or Semaphore at the end of a "with" statement. - with (yield semaphore.acquire()): - pass + with (yield semaphore.acquire()): + pass - # Now semaphore.release() has been called. + # Now semaphore.release() has been called. """ def __init__(self, obj: Any) -> None: @@ -302,8 +298,7 @@ class Semaphore(_TimeoutGarbageCollector): from tornado.ioloop import IOLoop from tornado.concurrent import Future - # Ensure reliable doctest output: resolve Futures one at a time. - futures_q = deque([Future() for _ in range(3)]) + inited = False async def simulator(futures): for f in futures: @@ -312,15 +307,21 @@ async def simulator(futures): await gen.sleep(0) f.set_result(None) - IOLoop.current().add_callback(simulator, list(futures_q)) - def use_some_resource(): + global inited + global futures_q + if not inited: + inited = True + # Ensure reliable doctest output: resolve Futures one at a time. + futures_q = deque([Future() for _ in range(3)]) + IOLoop.current().add_callback(simulator, list(futures_q)) + return futures_q.popleft() .. testcode:: semaphore + import asyncio from tornado import gen - from tornado.ioloop import IOLoop from tornado.locks import Semaphore sem = Semaphore(2) @@ -338,7 +339,7 @@ async def runner(): # Join all workers. await gen.multi([worker(i) for i in range(3)]) - IOLoop.current().run_sync(runner) + asyncio.run(runner()) .. testoutput:: semaphore diff --git a/tornado/log.py b/tornado/log.py index 810a0373b5..8699896139 100644 --- a/tornado/log.py +++ b/tornado/log.py @@ -151,7 +151,11 @@ def __init__( self._colors[levelno] = unicode_type( curses.tparm(fg_color, code), "ascii" ) - self._normal = unicode_type(curses.tigetstr("sgr0"), "ascii") + normal = curses.tigetstr("sgr0") + if normal is not None: + self._normal = unicode_type(normal, "ascii") + else: + self._normal = "" else: # If curses is not present (currently we'll only get here for # colorama on windows), assume hard-coded ANSI color codes. diff --git a/tornado/netutil.py b/tornado/netutil.py index 868d3e9e69..18c91e6743 100644 --- a/tornado/netutil.py +++ b/tornado/netutil.py @@ -15,6 +15,7 @@ """Miscellaneous network utility code.""" +import asyncio import concurrent.futures import errno import os @@ -30,7 +31,7 @@ from typing import List, Callable, Any, Type, Dict, Union, Tuple, Awaitable, Optional # Note that the naming of ssl.Purpose is confusing; the purpose -# of a context is to authentiate the opposite side of the connection. +# of a context is to authenticate the opposite side of the connection. _client_ssl_defaults = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) _server_ssl_defaults = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) if hasattr(ssl, "OP_NO_COMPRESSION"): @@ -43,10 +44,10 @@ # module-import time, the import lock is already held by the main thread, # leading to deadlock. Avoid it by caching the idna encoder on the main # thread now. -u"foo".encode("idna") +"foo".encode("idna") # For undiagnosed reasons, 'latin1' codec may also need to be preloaded. -u"foo".encode("latin1") +"foo".encode("latin1") # Default backlog used when calling sock.listen() _DEFAULT_BACKLOG = 128 @@ -114,7 +115,7 @@ def bind_sockets( sys.platform == "darwin" and address == "localhost" and af == socket.AF_INET6 - and sockaddr[3] != 0 + and sockaddr[3] != 0 # type: ignore ): # Mac OS X includes a link-local address fe80::1%lo0 in the # getaddrinfo results for 'localhost'. However, the firewall @@ -301,6 +302,12 @@ def is_valid_ip(ip: str) -> bool: if e.args[0] == socket.EAI_NONAME: return False raise + except UnicodeError: + # `socket.getaddrinfo` will raise a UnicodeError from the + # `idna` decoder if the input is longer than 63 characters, + # even for socket.AI_NUMERICHOST. See + # https://bugs.python.org/issue32958 for discussion + return False return True @@ -316,16 +323,21 @@ class method:: The implementations of this interface included with Tornado are - * `tornado.netutil.DefaultExecutorResolver` + * `tornado.netutil.DefaultLoopResolver` + * `tornado.netutil.DefaultExecutorResolver` (deprecated) * `tornado.netutil.BlockingResolver` (deprecated) * `tornado.netutil.ThreadedResolver` (deprecated) * `tornado.netutil.OverrideResolver` - * `tornado.platform.twisted.TwistedResolver` - * `tornado.platform.caresresolver.CaresResolver` + * `tornado.platform.twisted.TwistedResolver` (deprecated) + * `tornado.platform.caresresolver.CaresResolver` (deprecated) .. versionchanged:: 5.0 The default implementation has changed from `BlockingResolver` to `DefaultExecutorResolver`. + + .. versionchanged:: 6.2 + The default implementation has changed from `DefaultExecutorResolver` to + `DefaultLoopResolver`. """ @classmethod @@ -334,7 +346,7 @@ def configurable_base(cls) -> Type["Resolver"]: @classmethod def configurable_default(cls) -> Type["Resolver"]: - return DefaultExecutorResolver + return DefaultLoopResolver def resolve( self, host: str, port: int, family: socket.AddressFamily = socket.AF_UNSPEC @@ -390,6 +402,10 @@ class DefaultExecutorResolver(Resolver): """Resolver implementation using `.IOLoop.run_in_executor`. .. versionadded:: 5.0 + + .. deprecated:: 6.2 + + Use `DefaultLoopResolver` instead. """ async def resolve( @@ -401,6 +417,25 @@ async def resolve( return result +class DefaultLoopResolver(Resolver): + """Resolver implementation using `asyncio.loop.getaddrinfo`.""" + + async def resolve( + self, host: str, port: int, family: socket.AddressFamily = socket.AF_UNSPEC + ) -> List[Tuple[int, Any]]: + # On Solaris, getaddrinfo fails if the given port is not found + # in /etc/services and no socket type is given, so we must pass + # one here. The socket type used here doesn't seem to actually + # matter (we discard the one we get back in the results), + # so the addresses we return should still be usable with SOCK_DGRAM. + return [ + (fam, address) + for fam, _, _, _, address in await asyncio.get_running_loop().getaddrinfo( + host, port, family=family, type=socket.SOCK_STREAM + ) + ] + + class ExecutorResolver(Resolver): """Resolver implementation using a `concurrent.futures.Executor`. @@ -415,8 +450,8 @@ class ExecutorResolver(Resolver): The ``io_loop`` argument (deprecated since version 4.1) has been removed. .. deprecated:: 5.0 - The default `Resolver` now uses `.IOLoop.run_in_executor`; use that instead - of this class. + The default `Resolver` now uses `asyncio.loop.getaddrinfo`; + use that instead of this class. """ def initialize( @@ -424,7 +459,6 @@ def initialize( executor: Optional[concurrent.futures.Executor] = None, close_executor: bool = True, ) -> None: - self.io_loop = IOLoop.current() if executor is not None: self.executor = executor self.close_executor = close_executor @@ -553,30 +587,46 @@ def resolve( def ssl_options_to_context( - ssl_options: Union[Dict[str, Any], ssl.SSLContext] + ssl_options: Union[Dict[str, Any], ssl.SSLContext], + server_side: Optional[bool] = None, ) -> ssl.SSLContext: """Try to convert an ``ssl_options`` dictionary to an `~ssl.SSLContext` object. The ``ssl_options`` dictionary contains keywords to be passed to - `ssl.wrap_socket`. In Python 2.7.9+, `ssl.SSLContext` objects can + ``ssl.SSLContext.wrap_socket``. In Python 2.7.9+, `ssl.SSLContext` objects can be used instead. This function converts the dict form to its `~ssl.SSLContext` equivalent, and may be used when a component which accepts both forms needs to upgrade to the `~ssl.SSLContext` version to use features like SNI or NPN. + + .. versionchanged:: 6.2 + + Added server_side argument. Omitting this argument will + result in a DeprecationWarning on Python 3.10. + """ if isinstance(ssl_options, ssl.SSLContext): return ssl_options assert isinstance(ssl_options, dict) assert all(k in _SSL_CONTEXT_KEYWORDS for k in ssl_options), ssl_options - # Can't use create_default_context since this interface doesn't - # tell us client vs server. - context = ssl.SSLContext(ssl_options.get("ssl_version", ssl.PROTOCOL_SSLv23)) + # TODO: Now that we have the server_side argument, can we switch to + # create_default_context or would that change behavior? + default_version = ssl.PROTOCOL_TLS + if server_side: + default_version = ssl.PROTOCOL_TLS_SERVER + elif server_side is not None: + default_version = ssl.PROTOCOL_TLS_CLIENT + context = ssl.SSLContext(ssl_options.get("ssl_version", default_version)) if "certfile" in ssl_options: context.load_cert_chain( ssl_options["certfile"], ssl_options.get("keyfile", None) ) if "cert_reqs" in ssl_options: + if ssl_options["cert_reqs"] == ssl.CERT_NONE: + # This may have been set automatically by PROTOCOL_TLS_CLIENT but is + # incompatible with CERT_NONE so we must manually clear it. + context.check_hostname = False context.verify_mode = ssl_options["cert_reqs"] if "ca_certs" in ssl_options: context.load_verify_locations(ssl_options["ca_certs"]) @@ -595,23 +645,27 @@ def ssl_wrap_socket( socket: socket.socket, ssl_options: Union[Dict[str, Any], ssl.SSLContext], server_hostname: Optional[str] = None, + server_side: Optional[bool] = None, **kwargs: Any ) -> ssl.SSLSocket: """Returns an ``ssl.SSLSocket`` wrapping the given socket. ``ssl_options`` may be either an `ssl.SSLContext` object or a dictionary (as accepted by `ssl_options_to_context`). Additional - keyword arguments are passed to ``wrap_socket`` (either the - `~ssl.SSLContext` method or the `ssl` module function as - appropriate). + keyword arguments are passed to `ssl.SSLContext.wrap_socket`. + + .. versionchanged:: 6.2 + + Added server_side argument. Omitting this argument will + result in a DeprecationWarning on Python 3.10. """ - context = ssl_options_to_context(ssl_options) - if ssl.HAS_SNI: - # In python 3.4, wrap_socket only accepts the server_hostname - # argument if HAS_SNI is true. - # TODO: add a unittest (python added server-side SNI support in 3.4) - # In the meantime it can be manually tested with - # python3 -m tornado.httpclient https://sni.velox.ch - return context.wrap_socket(socket, server_hostname=server_hostname, **kwargs) - else: - return context.wrap_socket(socket, **kwargs) + context = ssl_options_to_context(ssl_options, server_side=server_side) + if server_side is None: + server_side = False + assert ssl.HAS_SNI + # TODO: add a unittest for hostname validation (python added server-side SNI support in 3.4) + # In the meantime it can be manually tested with + # python3 -m tornado.httpclient https://sni.velox.ch + return context.wrap_socket( + socket, server_hostname=server_hostname, server_side=server_side, **kwargs + ) diff --git a/tornado/options.py b/tornado/options.py index f0b89a933a..b82966910b 100644 --- a/tornado/options.py +++ b/tornado/options.py @@ -56,7 +56,7 @@ def start_server(): either `parse_command_line` or `parse_config_file`:: import myapp.db, myapp.server - import tornado.options + import tornado if __name__ == '__main__': tornado.options.parse_command_line() @@ -86,6 +86,12 @@ def start_server(): options.logging = None parse_command_line() +.. note:: + + `parse_command_line` or `parse_config_file` function should called after + logging configuration and user-defined command line flags using the + ``callback`` option definition, or these configurations will not take effect. + .. versionchanged:: 4.3 Dashes and underscores are fully interchangeable in option names; options can be defined, set, and read with any mix of the two. @@ -266,17 +272,22 @@ def define( % (normalized, self._options[normalized].file_name) ) frame = sys._getframe(0) - options_file = frame.f_code.co_filename - - # Can be called directly, or through top level define() fn, in which - # case, step up above that frame to look for real caller. - if ( - frame.f_back.f_code.co_filename == options_file - and frame.f_back.f_code.co_name == "define" - ): - frame = frame.f_back - - file_name = frame.f_back.f_code.co_filename + if frame is not None: + options_file = frame.f_code.co_filename + + # Can be called directly, or through top level define() fn, in which + # case, step up above that frame to look for real caller. + if ( + frame.f_back is not None + and frame.f_back.f_code.co_filename == options_file + and frame.f_back.f_code.co_name == "define" + ): + frame = frame.f_back + + assert frame.f_back is not None + file_name = frame.f_back.f_code.co_filename + else: + file_name = "" if file_name == options_file: file_name = "" if type is None: @@ -416,7 +427,9 @@ def parse_config_file(self, path: str, final: bool = True) -> None: % (option.name, option.type.__name__) ) - if type(config[name]) == str and option.type != str: + if type(config[name]) == str and ( + option.type != str or option.multiple + ): option.parse(config[name]) else: option.set(config[name]) @@ -651,7 +664,9 @@ def _parse_timedelta(self, value: str) -> datetime.timedelta: num = float(m.group(1)) units = m.group(2) or "seconds" units = self._TIMEDELTA_ABBREV_DICT.get(units, units) - sum += datetime.timedelta(**{units: num}) + # This line confuses mypy when setup.py sets python_version=3.6 + # https://github.com/python/mypy/issues/9676 + sum += datetime.timedelta(**{units: num}) # type: ignore start = m.end() return sum except Exception: diff --git a/tornado/platform/asyncio.py b/tornado/platform/asyncio.py index 012948b3aa..79e60848b4 100644 --- a/tornado/platform/asyncio.py +++ b/tornado/platform/asyncio.py @@ -32,26 +32,36 @@ import sys import threading import typing +import warnings from tornado.gen import convert_yielded from tornado.ioloop import IOLoop, _Selectable -from typing import Any, TypeVar, Awaitable, Callable, Union, Optional, List, Tuple, Dict - -if typing.TYPE_CHECKING: - from typing import Set # noqa: F401 - from typing_extensions import Protocol +from typing import ( + Any, + Callable, + Dict, + List, + Optional, + Protocol, + Set, + Tuple, + TypeVar, + Union, +) + + +class _HasFileno(Protocol): + def fileno(self) -> int: + pass - class _HasFileno(Protocol): - def fileno(self) -> int: - pass - _FileDescriptorLike = Union[int, _HasFileno] +_FileDescriptorLike = Union[int, _HasFileno] _T = TypeVar("_T") # Collection of selector thread event loops to shut down on exit. -_selector_loops = set() # type: Set[AddThreadSelectorEventLoop] +_selector_loops: Set["SelectorThread"] = set() def _atexit_callback() -> None: @@ -63,11 +73,12 @@ def _atexit_callback() -> None: loop._waker_w.send(b"a") except BlockingIOError: pass - # If we don't join our (daemon) thread here, we may get a deadlock - # during interpreter shutdown. I don't really understand why. This - # deadlock happens every time in CI (both travis and appveyor) but - # I've never been able to reproduce locally. - loop._thread.join() + if loop._thread is not None: + # If we don't join our (daemon) thread here, we may get a deadlock + # during interpreter shutdown. I don't really understand why. This + # deadlock happens every time in CI (both travis and appveyor) but + # I've never been able to reproduce locally. + loop._thread.join() _selector_loops.clear() @@ -86,16 +97,16 @@ def initialize( # type: ignore # as windows where the default event loop does not implement these methods. self.selector_loop = asyncio_loop if hasattr(asyncio, "ProactorEventLoop") and isinstance( - asyncio_loop, asyncio.ProactorEventLoop # type: ignore + asyncio_loop, asyncio.ProactorEventLoop ): # Ignore this line for mypy because the abstract method checker # doesn't understand dynamic proxies. self.selector_loop = AddThreadSelectorEventLoop(asyncio_loop) # type: ignore # Maps fd to (fileobj, handler function) pair (as in IOLoop.add_handler) - self.handlers = {} # type: Dict[int, Tuple[Union[int, _Selectable], Callable]] + self.handlers: Dict[int, Tuple[Union[int, _Selectable], Callable]] = {} # Set of fds listening for reads/writes - self.readers = set() # type: Set[int] - self.writers = set() # type: Set[int] + self.readers: Set[int] = set() + self.writers: Set[int] = set() self.closing = False # If an asyncio loop was closed through an asyncio interface # instead of IOLoop.close(), we'd never hear about it and may @@ -108,20 +119,22 @@ def initialize( # type: ignore # TODO(bdarnell): consider making self.asyncio_loop a weakref # for AsyncIOMainLoop and make _ioloop_for_asyncio a # WeakKeyDictionary. - for loop in list(IOLoop._ioloop_for_asyncio): + for loop in IOLoop._ioloop_for_asyncio.copy(): if loop.is_closed(): - del IOLoop._ioloop_for_asyncio[loop] - IOLoop._ioloop_for_asyncio[asyncio_loop] = self - - self._thread_identity = 0 + try: + del IOLoop._ioloop_for_asyncio[loop] + except KeyError: + pass + + # Make sure we don't already have an IOLoop for this asyncio loop + existing_loop = IOLoop._ioloop_for_asyncio.setdefault(asyncio_loop, self) + if existing_loop is not self: + raise RuntimeError( + f"IOLoop {existing_loop} already associated with asyncio loop {asyncio_loop}" + ) super().initialize(**kwargs) - def assign_thread_identity() -> None: - self._thread_identity = threading.get_ident() - - self.add_callback(assign_thread_identity) - def close(self, all_fds: bool = False) -> None: self.closing = True for fd in list(self.handlers): @@ -189,22 +202,13 @@ def _handle_events(self, fd: int, events: int) -> None: handler_func(fileobj, events) def start(self) -> None: - try: - old_loop = asyncio.get_event_loop() - except (RuntimeError, AssertionError): - old_loop = None # type: ignore - try: - self._setup_logging() - asyncio.set_event_loop(self.asyncio_loop) - self.asyncio_loop.run_forever() - finally: - asyncio.set_event_loop(old_loop) + self.asyncio_loop.run_forever() def stop(self) -> None: self.asyncio_loop.stop() def call_at( - self, when: float, callback: Callable[..., None], *args: Any, **kwargs: Any + self, when: float, callback: Callable, *args: Any, **kwargs: Any ) -> object: # asyncio.call_at supports *args but not **kwargs, so bind them here. # We do not synchronize self.time and asyncio_loop.time, so @@ -219,10 +223,14 @@ def remove_timeout(self, timeout: object) -> None: timeout.cancel() # type: ignore def add_callback(self, callback: Callable, *args: Any, **kwargs: Any) -> None: - if threading.get_ident() == self._thread_identity: - call_soon = self.asyncio_loop.call_soon - else: + try: + if asyncio.get_running_loop() is self.asyncio_loop: + call_soon = self.asyncio_loop.call_soon + else: + call_soon = self.asyncio_loop.call_soon_threadsafe + except RuntimeError: call_soon = self.asyncio_loop.call_soon_threadsafe + try: call_soon(self._run_callback, functools.partial(callback, *args, **kwargs)) except RuntimeError: @@ -241,6 +249,7 @@ def add_callback(self, callback: Callable, *args: Any, **kwargs: Any) -> None: def add_callback_from_signal( self, callback: Callable, *args: Any, **kwargs: Any ) -> None: + warnings.warn("add_callback_from_signal is deprecated", DeprecationWarning) try: self.asyncio_loop.call_soon_threadsafe( self._run_callback, functools.partial(callback, *args, **kwargs) @@ -252,8 +261,8 @@ def run_in_executor( self, executor: Optional[concurrent.futures.Executor], func: Callable[..., _T], - *args: Any - ) -> Awaitable[_T]: + *args: Any, + ) -> "asyncio.Future[_T]": return self.asyncio_loop.run_in_executor(executor, func, *args) def set_default_executor(self, executor: concurrent.futures.Executor) -> None: @@ -278,7 +287,7 @@ class AsyncIOMainLoop(BaseAsyncIOLoop): def initialize(self, **kwargs: Any) -> None: # type: ignore super().initialize(asyncio.get_event_loop(), **kwargs) - def make_current(self) -> None: + def _make_current(self) -> None: # AsyncIOMainLoop already refers to the current asyncio loop so # nothing to do here. pass @@ -293,6 +302,12 @@ class AsyncIOLoop(BaseAsyncIOLoop): Each ``AsyncIOLoop`` creates a new ``asyncio.EventLoop``; this object can be accessed with the ``asyncio_loop`` attribute. + .. versionchanged:: 6.2 + + Support explicit ``asyncio_loop`` argument + for specifying the asyncio loop to attach to, + rather than always creating a new one with the default policy. + .. versionchanged:: 5.0 When an ``AsyncIOLoop`` becomes the current `.IOLoop`, it also sets @@ -306,21 +321,24 @@ class AsyncIOLoop(BaseAsyncIOLoop): def initialize(self, **kwargs: Any) -> None: # type: ignore self.is_current = False - loop = asyncio.new_event_loop() + loop = None + if "asyncio_loop" not in kwargs: + kwargs["asyncio_loop"] = loop = asyncio.new_event_loop() try: - super().initialize(loop, **kwargs) + super().initialize(**kwargs) except Exception: # If initialize() does not succeed (taking ownership of the loop), # we have to close it. - loop.close() + if loop is not None: + loop.close() raise def close(self, all_fds: bool = False) -> None: if self.is_current: - self.clear_current() + self._clear_current() super().close(all_fds=all_fds) - def make_current(self) -> None: + def _make_current(self) -> None: if not self.is_current: try: self.old_asyncio = asyncio.get_event_loop() @@ -387,90 +405,74 @@ class AnyThreadEventLoopPolicy(_BasePolicy): # type: ignore .. versionadded:: 5.0 + .. deprecated:: 6.2 + + ``AnyThreadEventLoopPolicy`` affects the implicit creation + of an event loop, which is deprecated in Python 3.10 and + will be removed in a future version of Python. At that time + ``AnyThreadEventLoopPolicy`` will no longer be useful. + If you are relying on it, use `asyncio.new_event_loop` + or `asyncio.run` explicitly in any non-main threads that + need event loops. """ + def __init__(self) -> None: + super().__init__() + warnings.warn( + "AnyThreadEventLoopPolicy is deprecated, use asyncio.run " + "or asyncio.new_event_loop instead", + DeprecationWarning, + stacklevel=2, + ) + def get_event_loop(self) -> asyncio.AbstractEventLoop: try: return super().get_event_loop() - except (RuntimeError, AssertionError): - # This was an AssertionError in Python 3.4.2 (which ships with Debian Jessie) - # and changed to a RuntimeError in 3.4.3. + except RuntimeError: # "There is no current event loop in thread %r" loop = self.new_event_loop() self.set_event_loop(loop) return loop -class AddThreadSelectorEventLoop(asyncio.AbstractEventLoop): - """Wrap an event loop to add implementations of the ``add_reader`` method family. +class SelectorThread: + """Define ``add_reader`` methods to be called in a background select thread. Instances of this class start a second thread to run a selector. - This thread is completely hidden from the user; all callbacks are - run on the wrapped event loop's thread. - - This class is used automatically by Tornado; applications should not need - to refer to it directly. - - It is safe to wrap any event loop with this class, although it only makes sense - for event loops that do not implement the ``add_reader`` family of methods - themselves (i.e. ``WindowsProactorEventLoop``) - - Closing the ``AddThreadSelectorEventLoop`` also closes the wrapped event loop. + This thread is completely hidden from the user; + all callbacks are run on the wrapped event loop's thread. + Typically used via ``AddThreadSelectorEventLoop``, + but can be attached to a running asyncio loop. """ - # This class is a __getattribute__-based proxy. All attributes other than those - # in this set are proxied through to the underlying loop. - MY_ATTRIBUTES = { - "_consume_waker", - "_select_cond", - "_select_args", - "_closing_selector", - "_thread", - "_handle_event", - "_readers", - "_real_loop", - "_start_select", - "_run_select", - "_handle_select", - "_wake_selector", - "_waker_r", - "_waker_w", - "_writers", - "add_reader", - "add_writer", - "close", - "remove_reader", - "remove_writer", - } - - def __getattribute__(self, name: str) -> Any: - if name in AddThreadSelectorEventLoop.MY_ATTRIBUTES: - return super().__getattribute__(name) - return getattr(self._real_loop, name) + _closed = False def __init__(self, real_loop: asyncio.AbstractEventLoop) -> None: self._real_loop = real_loop - # Create a thread to run the select system call. We manage this thread - # manually so we can trigger a clean shutdown from an atexit hook. Note - # that due to the order of operations at shutdown, only daemon threads - # can be shut down in this way (non-daemon threads would require the - # introduction of a new hook: https://bugs.python.org/issue41962) self._select_cond = threading.Condition() - self._select_args = ( - None - ) # type: Optional[Tuple[List[_FileDescriptorLike], List[_FileDescriptorLike]]] + self._select_args: Optional[ + Tuple[List[_FileDescriptorLike], List[_FileDescriptorLike]] + ] = None self._closing_selector = False - self._thread = threading.Thread( - name="Tornado selector", daemon=True, target=self._run_select, + self._thread: Optional[threading.Thread] = None + self._thread_manager_handle = self._thread_manager() + + async def thread_manager_anext() -> None: + # the anext builtin wasn't added until 3.10. We just need to iterate + # this generator one step. + await self._thread_manager_handle.__anext__() + + # When the loop starts, start the thread. Not too soon because we can't + # clean up if we get to this point but the event loop is closed without + # starting. + self._real_loop.call_soon( + lambda: self._real_loop.create_task(thread_manager_anext()) ) - self._thread.start() - # Start the select loop once the loop is started. - self._real_loop.call_soon(self._start_select) - self._readers = {} # type: Dict[_FileDescriptorLike, Callable] - self._writers = {} # type: Dict[_FileDescriptorLike, Callable] + self._readers: Dict[_FileDescriptorLike, Callable] = {} + self._writers: Dict[_FileDescriptorLike, Callable] = {} # Writing to _waker_w will wake up the selector thread, which # watches for _waker_r to be readable. @@ -480,28 +482,49 @@ def __init__(self, real_loop: asyncio.AbstractEventLoop) -> None: _selector_loops.add(self) self.add_reader(self._waker_r, self._consume_waker) - def __del__(self) -> None: - # If the top-level application code uses asyncio interfaces to - # start and stop the event loop, no objects created in Tornado - # can get a clean shutdown notification. If we're just left to - # be GC'd, we must explicitly close our sockets to avoid - # logging warnings. - _selector_loops.discard(self) - self._waker_r.close() - self._waker_w.close() - def close(self) -> None: + if self._closed: + return with self._select_cond: self._closing_selector = True self._select_cond.notify() self._wake_selector() - self._thread.join() + if self._thread is not None: + self._thread.join() _selector_loops.discard(self) + self.remove_reader(self._waker_r) self._waker_r.close() self._waker_w.close() - self._real_loop.close() + self._closed = True + + async def _thread_manager(self) -> typing.AsyncGenerator[None, None]: + # Create a thread to run the select system call. We manage this thread + # manually so we can trigger a clean shutdown from an atexit hook. Note + # that due to the order of operations at shutdown, only daemon threads + # can be shut down in this way (non-daemon threads would require the + # introduction of a new hook: https://bugs.python.org/issue41962) + self._thread = threading.Thread( + name="Tornado selector", + daemon=True, + target=self._run_select, + ) + self._thread.start() + self._start_select() + try: + # The presense of this yield statement means that this coroutine + # is actually an asynchronous generator, which has a special + # shutdown protocol. We wait at this yield point until the + # event loop's shutdown_asyncgens method is called, at which point + # we will get a GeneratorExit exception and can shut down the + # selector thread. + yield + except GeneratorExit: + self.close() + raise def _wake_selector(self) -> None: + if self._closed: + return try: self._waker_w.send(b"a") except BlockingIOError: @@ -570,10 +593,24 @@ def _run_select(self) -> None: raise else: raise - self._real_loop.call_soon_threadsafe(self._handle_select, rs, ws) + + try: + self._real_loop.call_soon_threadsafe(self._handle_select, rs, ws) + except RuntimeError: + # "Event loop is closed". Swallow the exception for + # consistency with PollIOLoop (and logical consistency + # with the fact that we can't guarantee that an + # add_callback that completes without error will + # eventually execute). + pass + except AttributeError: + # ProactorEventLoop may raise this instead of RuntimeError + # if call_soon_threadsafe races with a call to close(). + # Swallow it too for consistency. + pass def _handle_select( - self, rs: List["_FileDescriptorLike"], ws: List["_FileDescriptorLike"] + self, rs: List[_FileDescriptorLike], ws: List[_FileDescriptorLike] ) -> None: for r in rs: self._handle_event(r, self._readers) @@ -582,7 +619,9 @@ def _handle_select( self._start_select() def _handle_event( - self, fd: "_FileDescriptorLike", cb_map: Dict["_FileDescriptorLike", Callable], + self, + fd: _FileDescriptorLike, + cb_map: Dict[_FileDescriptorLike, Callable], ) -> None: try: callback = cb_map[fd] @@ -591,21 +630,89 @@ def _handle_event( callback() def add_reader( - self, fd: "_FileDescriptorLike", callback: Callable[..., None], *args: Any + self, fd: _FileDescriptorLike, callback: Callable[..., None], *args: Any ) -> None: self._readers[fd] = functools.partial(callback, *args) self._wake_selector() def add_writer( - self, fd: "_FileDescriptorLike", callback: Callable[..., None], *args: Any + self, fd: _FileDescriptorLike, callback: Callable[..., None], *args: Any ) -> None: self._writers[fd] = functools.partial(callback, *args) self._wake_selector() - def remove_reader(self, fd: "_FileDescriptorLike") -> None: - del self._readers[fd] + def remove_reader(self, fd: _FileDescriptorLike) -> bool: + try: + del self._readers[fd] + except KeyError: + return False self._wake_selector() + return True - def remove_writer(self, fd: "_FileDescriptorLike") -> None: - del self._writers[fd] + def remove_writer(self, fd: _FileDescriptorLike) -> bool: + try: + del self._writers[fd] + except KeyError: + return False self._wake_selector() + return True + + +class AddThreadSelectorEventLoop(asyncio.AbstractEventLoop): + """Wrap an event loop to add implementations of the ``add_reader`` method family. + + Instances of this class start a second thread to run a selector. + This thread is completely hidden from the user; all callbacks are + run on the wrapped event loop's thread. + + This class is used automatically by Tornado; applications should not need + to refer to it directly. + + It is safe to wrap any event loop with this class, although it only makes sense + for event loops that do not implement the ``add_reader`` family of methods + themselves (i.e. ``WindowsProactorEventLoop``) + + Closing the ``AddThreadSelectorEventLoop`` also closes the wrapped event loop. + + """ + + # This class is a __getattribute__-based proxy. All attributes other than those + # in this set are proxied through to the underlying loop. + MY_ATTRIBUTES = { + "_real_loop", + "_selector", + "add_reader", + "add_writer", + "close", + "remove_reader", + "remove_writer", + } + + def __getattribute__(self, name: str) -> Any: + if name in AddThreadSelectorEventLoop.MY_ATTRIBUTES: + return super().__getattribute__(name) + return getattr(self._real_loop, name) + + def __init__(self, real_loop: asyncio.AbstractEventLoop) -> None: + self._real_loop = real_loop + self._selector = SelectorThread(real_loop) + + def close(self) -> None: + self._selector.close() + self._real_loop.close() + + def add_reader( + self, fd: "_FileDescriptorLike", callback: Callable[..., None], *args: Any + ) -> None: + return self._selector.add_reader(fd, callback, *args) + + def add_writer( + self, fd: "_FileDescriptorLike", callback: Callable[..., None], *args: Any + ) -> None: + return self._selector.add_writer(fd, callback, *args) + + def remove_reader(self, fd: "_FileDescriptorLike") -> bool: + return self._selector.remove_reader(fd) + + def remove_writer(self, fd: "_FileDescriptorLike") -> bool: + return self._selector.remove_writer(fd) diff --git a/tornado/platform/caresresolver.py b/tornado/platform/caresresolver.py index e2c5009ac0..1ba45c9ac4 100644 --- a/tornado/platform/caresresolver.py +++ b/tornado/platform/caresresolver.py @@ -15,17 +15,22 @@ class CaresResolver(Resolver): """Name resolver based on the c-ares library. - This is a non-blocking and non-threaded resolver. It may not produce - the same results as the system resolver, but can be used for non-blocking + This is a non-blocking and non-threaded resolver. It may not produce the + same results as the system resolver, but can be used for non-blocking resolution when threads cannot be used. - c-ares fails to resolve some names when ``family`` is ``AF_UNSPEC``, - so it is only recommended for use in ``AF_INET`` (i.e. IPv4). This is - the default for ``tornado.simple_httpclient``, but other libraries - may default to ``AF_UNSPEC``. + ``pycares`` will not return a mix of ``AF_INET`` and ``AF_INET6`` when + ``family`` is ``AF_UNSPEC``, so it is only recommended for use in + ``AF_INET`` (i.e. IPv4). This is the default for + ``tornado.simple_httpclient``, but other libraries may default to + ``AF_UNSPEC``. .. versionchanged:: 5.0 The ``io_loop`` argument (deprecated since version 4.1) has been removed. + + .. deprecated:: 6.2 + This class is deprecated and will be removed in Tornado 7.0. Use the default + thread-based resolver instead. """ def initialize(self) -> None: diff --git a/tornado/platform/twisted.py b/tornado/platform/twisted.py index 0987a84ab7..153fe436eb 100644 --- a/tornado/platform/twisted.py +++ b/tornado/platform/twisted.py @@ -52,6 +52,10 @@ class TwistedResolver(Resolver): .. versionchanged:: 5.0 The ``io_loop`` argument (deprecated since version 4.1) has been removed. + + .. deprecated:: 6.2 + This class is deprecated and will be removed in Tornado 7.0. Use the default + thread-based resolver instead. """ def initialize(self) -> None: diff --git a/tornado/process.py b/tornado/process.py index 26428feb77..12e3eb648d 100644 --- a/tornado/process.py +++ b/tornado/process.py @@ -17,6 +17,7 @@ the server into multiple processes and managing subprocesses. """ +import asyncio import os import multiprocessing import signal @@ -210,7 +211,6 @@ class Subprocess(object): _initialized = False _waiting = {} # type: ignore - _old_sigchld = None def __init__(self, *args: Any, **kwargs: Any) -> None: self.io_loop = ioloop.IOLoop.current() @@ -322,11 +322,8 @@ def initialize(cls) -> None: """ if cls._initialized: return - io_loop = ioloop.IOLoop.current() - cls._old_sigchld = signal.signal( - signal.SIGCHLD, - lambda sig, frame: io_loop.add_callback_from_signal(cls._cleanup), - ) + loop = asyncio.get_event_loop() + loop.add_signal_handler(signal.SIGCHLD, cls._cleanup) cls._initialized = True @classmethod @@ -334,7 +331,8 @@ def uninitialize(cls) -> None: """Removes the ``SIGCHLD`` handler.""" if not cls._initialized: return - signal.signal(signal.SIGCHLD, cls._old_sigchld) + loop = asyncio.get_event_loop() + loop.remove_signal_handler(signal.SIGCHLD) cls._initialized = False @classmethod @@ -352,7 +350,7 @@ def _try_cleanup_process(cls, pid: int) -> None: return assert ret_pid == pid subproc = cls._waiting.pop(pid) - subproc.io_loop.add_callback_from_signal(subproc._set_returncode, status) + subproc.io_loop.add_callback(subproc._set_returncode, status) def _set_returncode(self, status: int) -> None: if sys.platform == "win32": diff --git a/tornado/queues.py b/tornado/queues.py index 1e87f62e09..1358d0ecf1 100644 --- a/tornado/queues.py +++ b/tornado/queues.py @@ -85,7 +85,7 @@ class Queue(Generic[_T]): .. testcode:: - from tornado import gen + import asyncio from tornado.ioloop import IOLoop from tornado.queues import Queue @@ -95,7 +95,7 @@ async def consumer(): async for item in q: try: print('Doing work on %s' % item) - await gen.sleep(0.01) + await asyncio.sleep(0.01) finally: q.task_done() @@ -111,7 +111,7 @@ async def main(): await q.join() # Wait for consumer to finish all tasks. print('Done') - IOLoop.current().run_sync(main) + asyncio.run(main()) .. testoutput:: @@ -353,16 +353,20 @@ class PriorityQueue(Queue): .. testcode:: + import asyncio from tornado.queues import PriorityQueue - q = PriorityQueue() - q.put((1, 'medium-priority item')) - q.put((0, 'high-priority item')) - q.put((10, 'low-priority item')) + async def main(): + q = PriorityQueue() + q.put((1, 'medium-priority item')) + q.put((0, 'high-priority item')) + q.put((10, 'low-priority item')) + + print(await q.get()) + print(await q.get()) + print(await q.get()) - print(q.get_nowait()) - print(q.get_nowait()) - print(q.get_nowait()) + asyncio.run(main()) .. testoutput:: @@ -377,7 +381,7 @@ def _init(self) -> None: def _put(self, item: _T) -> None: heapq.heappush(self._queue, item) - def _get(self) -> _T: + def _get(self) -> _T: # type: ignore[type-var] return heapq.heappop(self._queue) @@ -386,16 +390,20 @@ class LifoQueue(Queue): .. testcode:: + import asyncio from tornado.queues import LifoQueue - q = LifoQueue() - q.put(3) - q.put(2) - q.put(1) + async def main(): + q = LifoQueue() + q.put(3) + q.put(2) + q.put(1) + + print(await q.get()) + print(await q.get()) + print(await q.get()) - print(q.get_nowait()) - print(q.get_nowait()) - print(q.get_nowait()) + asyncio.run(main()) .. testoutput:: @@ -410,5 +418,5 @@ def _init(self) -> None: def _put(self, item: _T) -> None: self._queue.append(item) - def _get(self) -> _T: + def _get(self) -> _T: # type: ignore[type-var] return self._queue.pop() diff --git a/tornado/simple_httpclient.py b/tornado/simple_httpclient.py index f99f391fdc..5b2d4dcd98 100644 --- a/tornado/simple_httpclient.py +++ b/tornado/simple_httpclient.py @@ -84,6 +84,35 @@ class SimpleAsyncHTTPClient(AsyncHTTPClient): supported. In particular, proxies are not supported, connections are not reused, and callers cannot select the network interface to be used. + + This implementation supports the following arguments, which can be passed + to ``configure()`` to control the global singleton, or to the constructor + when ``force_instance=True``. + + ``max_clients`` is the number of concurrent requests that can be + in progress; when this limit is reached additional requests will be + queued. Note that time spent waiting in this queue still counts + against the ``request_timeout``. + + ``defaults`` is a dict of parameters that will be used as defaults on all + `.HTTPRequest` objects submitted to this client. + + ``hostname_mapping`` is a dictionary mapping hostnames to IP addresses. + It can be used to make local DNS changes when modifying system-wide + settings like ``/etc/hosts`` is not possible or desirable (e.g. in + unittests). ``resolver`` is similar, but using the `.Resolver` interface + instead of a simple mapping. + + ``max_buffer_size`` (default 100MB) is the number of bytes + that can be read into memory at once. ``max_body_size`` + (defaults to ``max_buffer_size``) is the largest response body + that the client will accept. Without a + ``streaming_callback``, the smaller of these two limits + applies; with a ``streaming_callback`` only ``max_body_size`` + does. + + .. versionchanged:: 4.2 + Added the ``max_body_size`` argument. """ def initialize( # type: ignore @@ -96,38 +125,6 @@ def initialize( # type: ignore max_header_size: Optional[int] = None, max_body_size: Optional[int] = None, ) -> None: - """Creates a AsyncHTTPClient. - - Only a single AsyncHTTPClient instance exists per IOLoop - in order to provide limitations on the number of pending connections. - ``force_instance=True`` may be used to suppress this behavior. - - Note that because of this implicit reuse, unless ``force_instance`` - is used, only the first call to the constructor actually uses - its arguments. It is recommended to use the ``configure`` method - instead of the constructor to ensure that arguments take effect. - - ``max_clients`` is the number of concurrent requests that can be - in progress; when this limit is reached additional requests will be - queued. Note that time spent waiting in this queue still counts - against the ``request_timeout``. - - ``hostname_mapping`` is a dictionary mapping hostnames to IP addresses. - It can be used to make local DNS changes when modifying system-wide - settings like ``/etc/hosts`` is not possible or desirable (e.g. in - unittests). - - ``max_buffer_size`` (default 100MB) is the number of bytes - that can be read into memory at once. ``max_body_size`` - (defaults to ``max_buffer_size``) is the largest response body - that the client will accept. Without a - ``streaming_callback``, the smaller of these two limits - applies; with a ``streaming_callback`` only ``max_body_size`` - does. - - .. versionchanged:: 4.2 - Added the ``max_body_size`` argument. - """ super().initialize(defaults=defaults) self.max_clients = max_clients self.queue = ( @@ -325,11 +322,16 @@ async def run(self) -> None: % (self.request.network_interface,) ) - timeout = ( - min(self.request.connect_timeout, self.request.request_timeout) - or self.request.connect_timeout - or self.request.request_timeout - ) # min but skip zero + if self.request.connect_timeout and self.request.request_timeout: + timeout = min( + self.request.connect_timeout, self.request.request_timeout + ) + elif self.request.connect_timeout: + timeout = self.request.connect_timeout + elif self.request.request_timeout: + timeout = self.request.request_timeout + else: + timeout = 0 if timeout: self._timeout = self.io_loop.add_timeout( self.start_time + timeout, @@ -427,9 +429,9 @@ async def run(self) -> None: self.request.method == "POST" and "Content-Type" not in self.request.headers ): - self.request.headers[ - "Content-Type" - ] = "application/x-www-form-urlencoded" + self.request.headers["Content-Type"] = ( + "application/x-www-form-urlencoded" + ) if self.request.decompress_response: self.request.headers["Accept-Encoding"] = "gzip" req_path = (self.parsed.path or "/") + ( @@ -545,7 +547,7 @@ def _handle_exception( value: Optional[BaseException], tb: Optional[TracebackType], ) -> bool: - if self.final_callback: + if self.final_callback is not None: self._remove_timeout() if isinstance(value, StreamClosedError): if value.real_error is None: @@ -626,10 +628,12 @@ def finish(self) -> None: original_request = getattr(self.request, "original_request", self.request) if self._should_follow_redirect(): assert isinstance(self.request, _RequestProxy) + assert self.headers is not None new_request = copy.copy(self.request.request) new_request.url = urllib.parse.urljoin( self.request.url, self.headers["Location"] ) + assert self.request.max_redirects is not None new_request.max_redirects = self.request.max_redirects - 1 del new_request.headers["Host"] # https://tools.ietf.org/html/rfc7231#section-6.4 @@ -645,7 +649,7 @@ def finish(self) -> None: self.code in (301, 302) and self.request.method == "POST" ): new_request.method = "GET" - new_request.body = None + new_request.body = None # type: ignore for h in [ "Content-Length", "Content-Type", @@ -656,10 +660,11 @@ def finish(self) -> None: del self.request.headers[h] except KeyError: pass - new_request.original_request = original_request + new_request.original_request = original_request # type: ignore final_callback = self.final_callback - self.final_callback = None + self.final_callback = None # type: ignore self._release() + assert self.client is not None fut = self.client.fetch(new_request, raise_error=False) fut.add_done_callback(lambda f: final_callback(f.result())) self._on_end_request() diff --git a/tornado/tcpclient.py b/tornado/tcpclient.py index e2d682ea64..0a829062e7 100644 --- a/tornado/tcpclient.py +++ b/tornado/tcpclient.py @@ -21,6 +21,7 @@ import numbers import datetime import ssl +import typing from tornado.concurrent import Future, future_add_done_callback from tornado.ioloop import IOLoop @@ -29,7 +30,10 @@ from tornado.netutil import Resolver from tornado.gen import TimeoutError -from typing import Any, Union, Dict, Tuple, List, Callable, Iterator, Optional, Set +from typing import Any, Union, Dict, Tuple, List, Callable, Iterator, Optional + +if typing.TYPE_CHECKING: + from typing import Set # noqa(F401) _INITIAL_CONNECT_TIMEOUT = 0.3 diff --git a/tornado/tcpserver.py b/tornado/tcpserver.py index 476ffc936f..02c0ca0cca 100644 --- a/tornado/tcpserver.py +++ b/tornado/tcpserver.py @@ -24,7 +24,12 @@ from tornado.log import app_log from tornado.ioloop import IOLoop from tornado.iostream import IOStream, SSLIOStream -from tornado.netutil import bind_sockets, add_accept_handler, ssl_wrap_socket +from tornado.netutil import ( + bind_sockets, + add_accept_handler, + ssl_wrap_socket, + _DEFAULT_BACKLOG, +) from tornado import process from tornado.util import errno_from_exception @@ -43,21 +48,20 @@ class TCPServer(object): from tornado.tcpserver import TCPServer from tornado.iostream import StreamClosedError - from tornado import gen class EchoServer(TCPServer): async def handle_stream(self, stream, address): while True: try: - data = await stream.read_until(b"\n") - await stream.write(data) + data = await stream.read_until(b"\n") await + stream.write(data) except StreamClosedError: break To make this server serve SSL traffic, send the ``ssl_options`` keyword argument with an `ssl.SSLContext` object. For compatibility with older versions of Python ``ssl_options`` may also be a dictionary of keyword - arguments for the `ssl.wrap_socket` method.:: + arguments for the `ssl.SSLContext.wrap_socket` method.:: ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) ssl_ctx.load_cert_chain(os.path.join(data_dir, "mydomain.crt"), @@ -66,37 +70,49 @@ async def handle_stream(self, stream, address): `TCPServer` initialization follows one of three patterns: - 1. `listen`: simple single-process:: + 1. `listen`: single-process:: - server = TCPServer() - server.listen(8888) - IOLoop.current().start() + async def main(): + server = TCPServer() + server.listen(8888) + await asyncio.Event.wait() - 2. `bind`/`start`: simple multi-process:: - - server = TCPServer() - server.bind(8888) - server.start(0) # Forks multiple sub-processes - IOLoop.current().start() + asyncio.run(main()) - When using this interface, an `.IOLoop` must *not* be passed - to the `TCPServer` constructor. `start` will always start - the server on the default singleton `.IOLoop`. + While this example does not create multiple processes on its own, when + the ``reuse_port=True`` argument is passed to ``listen()`` you can run + the program multiple times to create a multi-process service. - 3. `add_sockets`: advanced multi-process:: + 2. `add_sockets`: multi-process:: sockets = bind_sockets(8888) tornado.process.fork_processes(0) + async def post_fork_main(): + server = TCPServer() + server.add_sockets(sockets) + await asyncio.Event().wait() + asyncio.run(post_fork_main()) + + The `add_sockets` interface is more complicated, but it can be used with + `tornado.process.fork_processes` to run a multi-process service with all + worker processes forked from a single parent. `add_sockets` can also be + used in single-process servers if you want to create your listening + sockets in some way other than `~tornado.netutil.bind_sockets`. + + Note that when using this pattern, nothing that touches the event loop + can be run before ``fork_processes``. + + 3. `bind`/`start`: simple **deprecated** multi-process:: + server = TCPServer() - server.add_sockets(sockets) + server.bind(8888) + server.start(0) # Forks multiple sub-processes IOLoop.current().start() - The `add_sockets` interface is more complicated, but it can be - used with `tornado.process.fork_processes` to give you more - flexibility in when the fork happens. `add_sockets` can - also be used in single-process servers if you want to create - your listening sockets in some way other than - `~tornado.netutil.bind_sockets`. + This pattern is deprecated because it requires interfaces in the + `asyncio` module that have been deprecated since Python 3.10. Support for + creating multiple processes in the ``start`` method will be removed in a + future version of Tornado. .. versionadded:: 3.1 The ``max_buffer_size`` argument. @@ -140,15 +156,38 @@ def __init__( 'keyfile "%s" does not exist' % self.ssl_options["keyfile"] ) - def listen(self, port: int, address: str = "") -> None: + def listen( + self, + port: int, + address: Optional[str] = None, + family: socket.AddressFamily = socket.AF_UNSPEC, + backlog: int = _DEFAULT_BACKLOG, + flags: Optional[int] = None, + reuse_port: bool = False, + ) -> None: """Starts accepting connections on the given port. This method may be called more than once to listen on multiple ports. `listen` takes effect immediately; it is not necessary to call - `TCPServer.start` afterwards. It is, however, necessary to start - the `.IOLoop`. + `TCPServer.start` afterwards. It is, however, necessary to start the + event loop if it is not already running. + + All arguments have the same meaning as in + `tornado.netutil.bind_sockets`. + + .. versionchanged:: 6.2 + + Added ``family``, ``backlog``, ``flags``, and ``reuse_port`` + arguments to match `tornado.netutil.bind_sockets`. """ - sockets = bind_sockets(port, address=address) + sockets = bind_sockets( + port, + address=address, + family=family, + backlog=backlog, + flags=flags, + reuse_port=reuse_port, + ) self.add_sockets(sockets) def add_sockets(self, sockets: Iterable[socket.socket]) -> None: @@ -175,34 +214,47 @@ def bind( port: int, address: Optional[str] = None, family: socket.AddressFamily = socket.AF_UNSPEC, - backlog: int = 128, + backlog: int = _DEFAULT_BACKLOG, + flags: Optional[int] = None, reuse_port: bool = False, ) -> None: """Binds this server to the given port on the given address. - To start the server, call `start`. If you want to run this server - in a single process, you can call `listen` as a shortcut to the - sequence of `bind` and `start` calls. + To start the server, call `start`. If you want to run this server in a + single process, you can call `listen` as a shortcut to the sequence of + `bind` and `start` calls. Address may be either an IP address or hostname. If it's a hostname, - the server will listen on all IP addresses associated with the - name. Address may be an empty string or None to listen on all - available interfaces. Family may be set to either `socket.AF_INET` - or `socket.AF_INET6` to restrict to IPv4 or IPv6 addresses, otherwise - both will be used if available. + the server will listen on all IP addresses associated with the name. + Address may be an empty string or None to listen on all available + interfaces. Family may be set to either `socket.AF_INET` or + `socket.AF_INET6` to restrict to IPv4 or IPv6 addresses, otherwise both + will be used if available. - The ``backlog`` argument has the same meaning as for - `socket.listen `. The ``reuse_port`` argument - has the same meaning as for `.bind_sockets`. + The ``backlog`` argument has the same meaning as for `socket.listen + `. The ``reuse_port`` argument has the same + meaning as for `.bind_sockets`. - This method may be called multiple times prior to `start` to listen - on multiple ports or interfaces. + This method may be called multiple times prior to `start` to listen on + multiple ports or interfaces. .. versionchanged:: 4.4 Added the ``reuse_port`` argument. + + .. versionchanged:: 6.2 + Added the ``flags`` argument to match `.bind_sockets`. + + .. deprecated:: 6.2 + Use either ``listen()`` or ``add_sockets()`` instead of ``bind()`` + and ``start()``. """ sockets = bind_sockets( - port, address=address, family=family, backlog=backlog, reuse_port=reuse_port + port, + address=address, + family=family, + backlog=backlog, + flags=flags, + reuse_port=reuse_port, ) if self._started: self.add_sockets(sockets) @@ -238,6 +290,10 @@ def start( .. versionchanged:: 6.0 Added ``max_restarts`` argument. + + .. deprecated:: 6.2 + Use either ``listen()`` or ``add_sockets()`` instead of ``bind()`` + and ``start()``. """ assert not self._started self._started = True diff --git a/tornado/template.py b/tornado/template.py index 2e6e0a2fc5..d53e977c5e 100644 --- a/tornado/template.py +++ b/tornado/template.py @@ -451,8 +451,7 @@ def _create_template(self, name: str) -> Template: class Loader(BaseLoader): - """A template loader that loads from a single root directory. - """ + """A template loader that loads from a single root directory.""" def __init__(self, root_directory: str, **kwargs: Any) -> None: super().__init__(**kwargs) diff --git a/tornado/test/__init__.py b/tornado/test/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tornado/test/__main__.py b/tornado/test/__main__.py index 430c895fa2..890bd505b4 100644 --- a/tornado/test/__main__.py +++ b/tornado/test/__main__.py @@ -2,6 +2,7 @@ This only works in python 2.7+. """ + from tornado.test.runtests import all, main # tornado.testing.main autodiscovery relies on 'all' being present in diff --git a/tornado/test/asyncio_test.py b/tornado/test/asyncio_test.py index 3f9f3389a2..bb6416a549 100644 --- a/tornado/test/asyncio_test.py +++ b/tornado/test/asyncio_test.py @@ -11,7 +11,10 @@ # under the License. import asyncio +import threading +import time import unittest +import warnings from concurrent.futures import ThreadPoolExecutor from tornado import gen @@ -20,32 +23,29 @@ AsyncIOLoop, to_asyncio_future, AnyThreadEventLoopPolicy, + AddThreadSelectorEventLoop, ) from tornado.testing import AsyncTestCase, gen_test class AsyncIOLoopTest(AsyncTestCase): - def get_new_ioloop(self): - io_loop = AsyncIOLoop() - return io_loop + @property + def asyncio_loop(self): + return self.io_loop.asyncio_loop # type: ignore def test_asyncio_callback(self): # Basic test that the asyncio loop is set up correctly. - asyncio.get_event_loop().call_soon(self.stop) + async def add_callback(): + asyncio.get_event_loop().call_soon(self.stop) + + self.asyncio_loop.run_until_complete(add_callback()) self.wait() @gen_test def test_asyncio_future(self): # Test that we can yield an asyncio future from a tornado coroutine. - # Without 'yield from', we must wrap coroutines in ensure_future, - # which was introduced during Python 3.4, deprecating the prior "async". - if hasattr(asyncio, "ensure_future"): - ensure_future = asyncio.ensure_future - else: - # async is a reserved word in Python 3.7 - ensure_future = getattr(asyncio, "async") - - x = yield ensure_future( + # Without 'yield from', we must wrap coroutines in ensure_future. + x = yield asyncio.ensure_future( asyncio.get_event_loop().run_in_executor(None, lambda: 42) ) self.assertEqual(x, 42) @@ -89,44 +89,51 @@ async def native_coroutine_with_adapter2(): # Asyncio only supports coroutines that yield asyncio-compatible # Futures (which our Future is since 5.0). self.assertEqual( - asyncio.get_event_loop().run_until_complete( - native_coroutine_without_adapter() - ), + self.asyncio_loop.run_until_complete(native_coroutine_without_adapter()), 42, ) self.assertEqual( - asyncio.get_event_loop().run_until_complete( - native_coroutine_with_adapter() - ), + self.asyncio_loop.run_until_complete(native_coroutine_with_adapter()), 42, ) self.assertEqual( - asyncio.get_event_loop().run_until_complete( - native_coroutine_with_adapter2() - ), + self.asyncio_loop.run_until_complete(native_coroutine_with_adapter2()), 42, ) + def test_add_thread_close_idempotent(self): + loop = AddThreadSelectorEventLoop(asyncio.get_event_loop()) # type: ignore + loop.close() + loop.close() + class LeakTest(unittest.TestCase): def setUp(self): # Trigger a cleanup of the mapping so we start with a clean slate. - AsyncIOLoop().close() + AsyncIOLoop(make_current=False).close() # If we don't clean up after ourselves other tests may fail on # py34. self.orig_policy = asyncio.get_event_loop_policy() asyncio.set_event_loop_policy(asyncio.DefaultEventLoopPolicy()) def tearDown(self): - asyncio.get_event_loop().close() + try: + loop = asyncio.get_event_loop_policy().get_event_loop() + except Exception: + # We may not have a current event loop at this point. + pass + else: + loop.close() asyncio.set_event_loop_policy(self.orig_policy) def test_ioloop_close_leak(self): orig_count = len(IOLoop._ioloop_for_asyncio) for i in range(10): # Create and close an AsyncIOLoop using Tornado interfaces. - loop = AsyncIOLoop() - loop.close() + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + loop = AsyncIOLoop() + loop.close() new_count = len(IOLoop._ioloop_for_asyncio) - orig_count self.assertEqual(new_count, 0) @@ -145,6 +152,57 @@ def test_asyncio_close_leak(self): self.assertEqual(new_count, 1) +class SelectorThreadLeakTest(unittest.TestCase): + # These tests are only relevant on windows, but they should pass anywhere. + def setUp(self): + # As a precaution, ensure that we've run an event loop at least once + # so if it spins up any singleton threads they're already there. + asyncio.run(self.dummy_tornado_coroutine()) + self.orig_thread_count = threading.active_count() + + def assert_no_thread_leak(self): + # For some reason we see transient failures here, but I haven't been able + # to catch it to identify which thread is causing it. Whatever thread it + # is, it appears to quickly clean up on its own, so just retry a few times. + # At least some of the time the errant thread was running at the time we + # captured self.orig_thread_count, so use inequalities. + deadline = time.time() + 1 + while time.time() < deadline: + threads = list(threading.enumerate()) + if len(threads) <= self.orig_thread_count: + break + time.sleep(0.1) + self.assertLessEqual(len(threads), self.orig_thread_count, threads) + + async def dummy_tornado_coroutine(self): + # Just access the IOLoop to initialize the selector thread. + IOLoop.current() + + def test_asyncio_run(self): + for i in range(10): + # asyncio.run calls shutdown_asyncgens for us. + asyncio.run(self.dummy_tornado_coroutine()) + self.assert_no_thread_leak() + + def test_asyncio_manual(self): + for i in range(10): + loop = asyncio.new_event_loop() + loop.run_until_complete(self.dummy_tornado_coroutine()) + # Without this step, we'd leak the thread. + loop.run_until_complete(loop.shutdown_asyncgens()) + loop.close() + self.assert_no_thread_leak() + + def test_tornado(self): + for i in range(10): + # The IOLoop interfaces are aware of the selector thread and + # (synchronously) shut it down. + loop = IOLoop(make_current=False) + loop.run_sync(self.dummy_tornado_coroutine) + loop.close() + self.assert_no_thread_leak() + + class AnyThreadEventLoopPolicyTest(unittest.TestCase): def setUp(self): self.orig_policy = asyncio.get_event_loop_policy() @@ -170,21 +228,34 @@ def get_and_close_event_loop(): future = self.executor.submit(get_and_close_event_loop) return future.result() - def run_policy_test(self, accessor, expected_type): - # With the default policy, non-main threads don't get an event - # loop. - self.assertRaises( - (RuntimeError, AssertionError), self.executor.submit(accessor).result - ) - # Set the policy and we can get a loop. - asyncio.set_event_loop_policy(AnyThreadEventLoopPolicy()) - self.assertIsInstance(self.executor.submit(accessor).result(), expected_type) - # Clean up to silence leak warnings. Always use asyncio since - # IOLoop doesn't (currently) close the underlying loop. - self.executor.submit(lambda: asyncio.get_event_loop().close()).result() # type: ignore - def test_asyncio_accessor(self): - self.run_policy_test(asyncio.get_event_loop, asyncio.AbstractEventLoop) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + # With the default policy, non-main threads don't get an event + # loop. + self.assertRaises( + RuntimeError, self.executor.submit(asyncio.get_event_loop).result + ) + # Set the policy and we can get a loop. + asyncio.set_event_loop_policy(AnyThreadEventLoopPolicy()) + self.assertIsInstance( + self.executor.submit(asyncio.get_event_loop).result(), + asyncio.AbstractEventLoop, + ) + # Clean up to silence leak warnings. Always use asyncio since + # IOLoop doesn't (currently) close the underlying loop. + self.executor.submit(lambda: asyncio.get_event_loop().close()).result() # type: ignore def test_tornado_accessor(self): - self.run_policy_test(IOLoop.current, IOLoop) + # Tornado's IOLoop.current() API can create a loop for any thread, + # regardless of this event loop policy. + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + self.assertIsInstance(self.executor.submit(IOLoop.current).result(), IOLoop) + # Clean up to silence leak warnings. Always use asyncio since + # IOLoop doesn't (currently) close the underlying loop. + self.executor.submit(lambda: asyncio.get_event_loop().close()).result() # type: ignore + + asyncio.set_event_loop_policy(AnyThreadEventLoopPolicy()) + self.assertIsInstance(self.executor.submit(IOLoop.current).result(), IOLoop) + self.executor.submit(lambda: asyncio.get_event_loop().close()).result() # type: ignore diff --git a/tornado/test/auth_test.py b/tornado/test/auth_test.py index 8de863eb21..5eddb9803c 100644 --- a/tornado/test/auth_test.py +++ b/tornado/test/auth_test.py @@ -502,14 +502,14 @@ def test_twitter_get_user(self): self.assertEqual( parsed, { - u"access_token": { - u"key": u"hjkl", - u"screen_name": u"foo", - u"secret": u"vbnm", + "access_token": { + "key": "hjkl", + "screen_name": "foo", + "secret": "vbnm", }, - u"name": u"Foo", - u"screen_name": u"foo", - u"username": u"foo", + "name": "Foo", + "screen_name": "foo", + "username": "foo", }, ) @@ -550,7 +550,6 @@ def get(self): self.authorize_redirect( redirect_uri=self._OAUTH_REDIRECT_URI, client_id=self.settings["google_oauth"]["key"], - client_secret=self.settings["google_oauth"]["secret"], scope=["profile", "email"], response_type="code", extra_params={"prompt": "select_account"}, @@ -601,9 +600,9 @@ def test_google_login(self): response = self.fetch("/client/login") self.assertDictEqual( { - u"name": u"Foo", - u"email": u"foo@example.com", - u"access_token": u"fake-access-token", + "name": "Foo", + "email": "foo@example.com", + "access_token": "fake-access-token", }, json_decode(response.body), ) diff --git a/tornado/test/autoreload_test.py b/tornado/test/autoreload_test.py index be481e106f..5675faa2a5 100644 --- a/tornado/test/autoreload_test.py +++ b/tornado/test/autoreload_test.py @@ -4,14 +4,44 @@ from subprocess import Popen import sys from tempfile import mkdtemp +import textwrap import time import unittest class AutoreloadTest(unittest.TestCase): def setUp(self): + # When these tests fail the output sometimes exceeds the default maxDiff. + self.maxDiff = 1024 + self.path = mkdtemp() + # Most test apps run themselves twice via autoreload. The first time it manually triggers + # a reload (could also do this by touching a file but this is faster since filesystem + # timestamps are not necessarily high resolution). The second time it exits directly + # so that the autoreload wrapper (if it is used) doesn't catch it. + # + # The last line of each such test's "main" program should be + # exec(open("run_twice_magic.py").read()) + self.write_files( + { + "run_twice_magic.py": """ + import os + import sys + + import tornado.autoreload + + sys.stdout.flush() + + if "TESTAPP_STARTED" not in os.environ: + os.environ["TESTAPP_STARTED"] = "1" + tornado.autoreload._reload() + else: + os._exit(0) + """ + } + ) + def tearDown(self): try: shutil.rmtree(self.path) @@ -24,29 +54,23 @@ def tearDown(self): time.sleep(1) shutil.rmtree(self.path) - def test_reload_module(self): - main = """\ -import os -import sys + def write_files(self, tree, base_path=None): + """Write a directory tree to self.path. -from tornado import autoreload - -# This import will fail if path is not set up correctly -import testapp - -print('Starting') -if 'TESTAPP_STARTED' not in os.environ: - os.environ['TESTAPP_STARTED'] = '1' - sys.stdout.flush() - autoreload._reload() -""" - - # Create temporary test application - os.mkdir(os.path.join(self.path, "testapp")) - open(os.path.join(self.path, "testapp/__init__.py"), "w").close() - with open(os.path.join(self.path, "testapp/__main__.py"), "w") as f: - f.write(main) + tree is a dictionary mapping file names to contents, or + sub-dictionaries representing subdirectories. + """ + if base_path is None: + base_path = self.path + for name, contents in tree.items(): + if isinstance(contents, dict): + os.mkdir(os.path.join(base_path, name)) + self.write_files(contents, os.path.join(base_path, name)) + else: + with open(os.path.join(base_path, name), "w", encoding="utf-8") as f: + f.write(textwrap.dedent(contents)) + def run_subprocess(self, args): # Make sure the tornado module under test is available to the test # application pythonpath = os.getcwd() @@ -54,21 +78,114 @@ def test_reload_module(self): pythonpath += os.pathsep + os.environ["PYTHONPATH"] p = Popen( - [sys.executable, "-m", "testapp"], + args, stdout=subprocess.PIPE, - cwd=self.path, env=dict(os.environ, PYTHONPATH=pythonpath), + cwd=self.path, universal_newlines=True, + encoding="utf-8", ) + + # This timeout needs to be fairly generous for pypy due to jit + # warmup costs. + for i in range(40): + if p.poll() is not None: + break + time.sleep(0.1) + else: + p.kill() + raise Exception("subprocess failed to terminate") + out = p.communicate()[0] - self.assertEqual(out, "Starting\nStarting\n") + self.assertEqual(p.returncode, 0) + return out + + def test_reload(self): + main = """\ +import sys + +# In module mode, the path is set to the parent directory and we can import testapp. +try: + import testapp +except ImportError: + print("import testapp failed") +else: + print("import testapp succeeded") + +spec = getattr(sys.modules[__name__], '__spec__', None) +print(f"Starting {__name__=}, __spec__.name={getattr(spec, 'name', None)}") +exec(open("run_twice_magic.py").read()) +""" + + # Create temporary test application + self.write_files( + { + "testapp": { + "__init__.py": "", + "__main__.py": main, + }, + } + ) + + # The autoreload wrapper should support all the same modes as the python interpreter. + # The wrapper itself should have no effect on this test so we try all modes with and + # without it. + for wrapper in [False, True]: + with self.subTest(wrapper=wrapper): + with self.subTest(mode="module"): + if wrapper: + base_args = [sys.executable, "-m", "tornado.autoreload"] + else: + base_args = [sys.executable] + # In module mode, the path is set to the parent directory and we can import + # testapp. Also, the __spec__.name is set to the fully qualified module name. + out = self.run_subprocess(base_args + ["-m", "testapp"]) + self.assertEqual( + out, + ( + "import testapp succeeded\n" + + "Starting __name__='__main__', __spec__.name=testapp.__main__\n" + ) + * 2, + ) + + with self.subTest(mode="file"): + out = self.run_subprocess(base_args + ["testapp/__main__.py"]) + # In file mode, we do not expect the path to be set so we can import testapp, + # but when the wrapper is used the -m argument to the python interpreter + # does this for us. + expect_import = ( + "import testapp succeeded" + if wrapper + else "import testapp failed" + ) + # In file mode there is no qualified module spec. + self.assertEqual( + out, + f"{expect_import}\nStarting __name__='__main__', __spec__.name=None\n" + * 2, + ) + + with self.subTest(mode="directory"): + # Running as a directory finds __main__.py like a module. It does not manipulate + # sys.path but it does set a spec with a name of exactly __main__. + out = self.run_subprocess(base_args + ["testapp"]) + expect_import = ( + "import testapp succeeded" + if wrapper + else "import testapp failed" + ) + self.assertEqual( + out, + f"{expect_import}\nStarting __name__='__main__', __spec__.name=__main__\n" + * 2, + ) def test_reload_wrapper_preservation(self): # This test verifies that when `python -m tornado.autoreload` # is used on an application that also has an internal # autoreload, the reload wrapper is preserved on restart. main = """\ -import os import sys # This import will fail if path is not set up correctly @@ -77,51 +194,71 @@ def test_reload_wrapper_preservation(self): if 'tornado.autoreload' not in sys.modules: raise Exception('started without autoreload wrapper') -import tornado.autoreload - print('Starting') -sys.stdout.flush() -if 'TESTAPP_STARTED' not in os.environ: - os.environ['TESTAPP_STARTED'] = '1' - # Simulate an internal autoreload (one not caused - # by the wrapper). - tornado.autoreload._reload() -else: - # Exit directly so autoreload doesn't catch it. - os._exit(0) +exec(open("run_twice_magic.py").read()) """ + self.write_files( + { + "testapp": { + "__init__.py": "", + "__main__.py": main, + }, + } + ) + + out = self.run_subprocess( + [sys.executable, "-m", "tornado.autoreload", "-m", "testapp"] + ) + self.assertEqual(out, "Starting\n" * 2) + + def test_reload_wrapper_args(self): + main = """\ +import os +import sys + +print(os.path.basename(sys.argv[0])) +print(f'argv={sys.argv[1:]}') +exec(open("run_twice_magic.py").read()) +""" # Create temporary test application - os.mkdir(os.path.join(self.path, "testapp")) - init_file = os.path.join(self.path, "testapp", "__init__.py") - open(init_file, "w").close() - main_file = os.path.join(self.path, "testapp", "__main__.py") - with open(main_file, "w") as f: - f.write(main) + self.write_files({"main.py": main}) # Make sure the tornado module under test is available to the test # application - pythonpath = os.getcwd() - if "PYTHONPATH" in os.environ: - pythonpath += os.pathsep + os.environ["PYTHONPATH"] - - autoreload_proc = Popen( - [sys.executable, "-m", "tornado.autoreload", "-m", "testapp"], - stdout=subprocess.PIPE, - cwd=self.path, - env=dict(os.environ, PYTHONPATH=pythonpath), - universal_newlines=True, + out = self.run_subprocess( + [ + sys.executable, + "-m", + "tornado.autoreload", + "main.py", + "arg1", + "--arg2", + "-m", + "arg3", + ], ) - # This timeout needs to be fairly generous for pypy due to jit - # warmup costs. - for i in range(40): - if autoreload_proc.poll() is not None: - break - time.sleep(0.1) - else: - autoreload_proc.kill() - raise Exception("subprocess failed to terminate") + self.assertEqual(out, "main.py\nargv=['arg1', '--arg2', '-m', 'arg3']\n" * 2) - out = autoreload_proc.communicate()[0] - self.assertEqual(out, "Starting\n" * 2) + def test_reload_wrapper_until_success(self): + main = """\ +import os +import sys + +if "TESTAPP_STARTED" in os.environ: + print("exiting cleanly") + sys.exit(0) +else: + print("reloading") + exec(open("run_twice_magic.py").read()) +""" + + # Create temporary test application + self.write_files({"main.py": main}) + + out = self.run_subprocess( + [sys.executable, "-m", "tornado.autoreload", "--until-success", "main.py"] + ) + + self.assertEqual(out, "reloading\nexiting cleanly\n") diff --git a/tornado/test/circlerefs_test.py b/tornado/test/circlerefs_test.py new file mode 100644 index 0000000000..5c25adffd8 --- /dev/null +++ b/tornado/test/circlerefs_test.py @@ -0,0 +1,217 @@ +"""Test script to find circular references. + +Circular references are not leaks per se, because they will eventually +be GC'd. However, on CPython, they prevent the reference-counting fast +path from being used and instead rely on the slower full GC. This +increases memory footprint and CPU overhead, so we try to eliminate +circular references created by normal operation. +""" + +import asyncio +import contextlib +import gc +import io +import sys +import traceback +import types +import typing +import unittest + +import tornado +from tornado import web, gen, httpclient +from tornado.test.util import skipNotCPython + + +def find_circular_references(garbage): + """Find circular references in a list of objects. + + The garbage list contains objects that participate in a cycle, + but also the larger set of objects kept alive by that cycle. + This function finds subsets of those objects that make up + the cycle(s). + """ + + def inner(level): + for item in level: + item_id = id(item) + if item_id not in garbage_ids: + continue + if item_id in visited_ids: + continue + if item_id in stack_ids: + candidate = stack[stack.index(item) :] + candidate.append(item) + found.append(candidate) + continue + + stack.append(item) + stack_ids.add(item_id) + inner(gc.get_referents(item)) + stack.pop() + stack_ids.remove(item_id) + visited_ids.add(item_id) + + found: typing.List[object] = [] + stack = [] + stack_ids = set() + garbage_ids = set(map(id, garbage)) + visited_ids = set() + + inner(garbage) + return found + + +@contextlib.contextmanager +def assert_no_cycle_garbage(): + """Raise AssertionError if the wrapped code creates garbage with cycles.""" + gc.disable() + gc.collect() + gc.set_debug(gc.DEBUG_STATS | gc.DEBUG_SAVEALL) + yield + try: + # We have DEBUG_STATS on which causes gc.collect to write to stderr. + # Capture the output instead of spamming the logs on passing runs. + f = io.StringIO() + old_stderr = sys.stderr + sys.stderr = f + try: + gc.collect() + finally: + sys.stderr = old_stderr + garbage = gc.garbage[:] + # Must clear gc.garbage (the same object, not just replacing it with a + # new list) to avoid warnings at shutdown. + gc.garbage[:] = [] + if len(garbage) == 0: + return + for circular in find_circular_references(garbage): + f.write("\n==========\n Circular \n==========") + for item in circular: + f.write(f"\n {repr(item)}") + for item in circular: + if isinstance(item, types.FrameType): + f.write(f"\nLocals: {item.f_locals}") + f.write(f"\nTraceback: {repr(item)}") + traceback.print_stack(item) + del garbage + raise AssertionError(f.getvalue()) + finally: + gc.set_debug(0) + gc.enable() + + +# GC behavior is cpython-specific +@skipNotCPython +class CircleRefsTest(unittest.TestCase): + def test_known_leak(self): + # Construct a known leak scenario to make sure the test harness works. + class C(object): + def __init__(self, name): + self.name = name + self.a: typing.Optional[C] = None + self.b: typing.Optional[C] = None + self.c: typing.Optional[C] = None + + def __repr__(self): + return f"name={self.name}" + + with self.assertRaises(AssertionError) as cm: + with assert_no_cycle_garbage(): + # a and b form a reference cycle. c is not part of the cycle, + # but it cannot be GC'd while a and b are alive. + a = C("a") + b = C("b") + c = C("c") + a.b = b + a.c = c + b.a = a + b.c = c + del a, b + self.assertIn("Circular", str(cm.exception)) + # Leading spaces ensure we only catch these at the beginning of a line, meaning they are a + # cycle participant and not simply the contents of a locals dict or similar container. (This + # depends on the formatting above which isn't ideal but this test evolved from a + # command-line script) Note that the behavior here changed in python 3.11; in newer pythons + # locals are handled a bit differently and the test passes without the spaces. + self.assertIn(" name=a", str(cm.exception)) + self.assertIn(" name=b", str(cm.exception)) + self.assertNotIn(" name=c", str(cm.exception)) + + async def run_handler(self, handler_class): + app = web.Application( + [ + (r"/", handler_class), + ] + ) + socket, port = tornado.testing.bind_unused_port() + server = tornado.httpserver.HTTPServer(app) + server.add_socket(socket) + + client = httpclient.AsyncHTTPClient() + with assert_no_cycle_garbage(): + # Only the fetch (and the corresponding server-side handler) + # are being tested for cycles. In particular, the Application + # object has internal cycles (as of this writing) which we don't + # care to fix since in real world usage the Application object + # is effectively a global singleton. + await client.fetch(f"http://127.0.0.1:{port}/") + client.close() + server.stop() + socket.close() + + def test_sync_handler(self): + class Handler(web.RequestHandler): + def get(self): + self.write("ok\n") + + asyncio.run(self.run_handler(Handler)) + + def test_finish_exception_handler(self): + class Handler(web.RequestHandler): + def get(self): + raise web.Finish("ok\n") + + asyncio.run(self.run_handler(Handler)) + + def test_coro_handler(self): + class Handler(web.RequestHandler): + @gen.coroutine + def get(self): + yield asyncio.sleep(0.01) + self.write("ok\n") + + asyncio.run(self.run_handler(Handler)) + + def test_async_handler(self): + class Handler(web.RequestHandler): + async def get(self): + await asyncio.sleep(0.01) + self.write("ok\n") + + asyncio.run(self.run_handler(Handler)) + + def test_run_on_executor(self): + # From https://github.com/tornadoweb/tornado/issues/2620 + # + # When this test was introduced it found cycles in IOLoop.add_future + # and tornado.concurrent.chain_future. + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor(1) as thread_pool: + + class Factory(object): + executor = thread_pool + + @tornado.concurrent.run_on_executor + def run(self): + return None + + factory = Factory() + + async def main(): + # The cycle is not reported on the first call. It's not clear why. + for i in range(2): + await factory.run() + + with assert_no_cycle_garbage(): + asyncio.run(main()) diff --git a/tornado/test/concurrent_test.py b/tornado/test/concurrent_test.py index b121c6971a..33fcb6505e 100644 --- a/tornado/test/concurrent_test.py +++ b/tornado/test/concurrent_test.py @@ -122,7 +122,7 @@ def test_future_error(self: typing.Any): future = self.client.capitalize("HELLO") self.io_loop.add_future(future, self.stop) self.wait() - self.assertRaisesRegexp(CapError, "already capitalized", future.result) # type: ignore + self.assertRaisesRegex(CapError, "already capitalized", future.result) # type: ignore def test_generator(self: typing.Any): @gen.coroutine @@ -135,7 +135,7 @@ def f(): def test_generator_error(self: typing.Any): @gen.coroutine def f(): - with self.assertRaisesRegexp(CapError, "already capitalized"): + with self.assertRaisesRegex(CapError, "already capitalized"): yield self.client.capitalize("HELLO") self.io_loop.run_sync(f) diff --git a/tornado/test/escape_test.py b/tornado/test/escape_test.py index d8f95e426e..3115a19409 100644 --- a/tornado/test/escape_test.py +++ b/tornado/test/escape_test.py @@ -1,6 +1,6 @@ import unittest -import tornado.escape +import tornado from tornado.escape import ( utf8, xhtml_escape, @@ -22,189 +22,191 @@ ( "hello http://world.com/!", {}, - u'hello http://world.com/!', + 'hello http://world.com/!', ), ( "hello http://world.com/with?param=true&stuff=yes", {}, - u'hello http://world.com/with?param=true&stuff=yes', # noqa: E501 + 'hello http://world.com/with?param=true&stuff=yes', # noqa: E501 ), # an opened paren followed by many chars killed Gruber's regex ( "http://url.com/w(aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", {}, - u'http://url.com/w(aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', # noqa: E501 + 'http://url.com/w(aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', # noqa: E501 ), # as did too many dots at the end ( "http://url.com/withmany.......................................", {}, - u'http://url.com/withmany.......................................', # noqa: E501 + 'http://url.com/withmany.......................................', # noqa: E501 ), ( "http://url.com/withmany((((((((((((((((((((((((((((((((((a)", {}, - u'http://url.com/withmany((((((((((((((((((((((((((((((((((a)', # noqa: E501 + 'http://url.com/withmany((((((((((((((((((((((((((((((((((a)', # noqa: E501 ), # some examples from http://daringfireball.net/2009/11/liberal_regex_for_matching_urls # plus a fex extras (such as multiple parentheses). ( "http://foo.com/blah_blah", {}, - u'http://foo.com/blah_blah', + 'http://foo.com/blah_blah', ), ( "http://foo.com/blah_blah/", {}, - u'http://foo.com/blah_blah/', + 'http://foo.com/blah_blah/', ), ( "(Something like http://foo.com/blah_blah)", {}, - u'(Something like http://foo.com/blah_blah)', + '(Something like http://foo.com/blah_blah)', ), ( "http://foo.com/blah_blah_(wikipedia)", {}, - u'http://foo.com/blah_blah_(wikipedia)', + 'http://foo.com/blah_blah_(wikipedia)', ), ( "http://foo.com/blah_(blah)_(wikipedia)_blah", {}, - u'http://foo.com/blah_(blah)_(wikipedia)_blah', # noqa: E501 + 'http://foo.com/blah_(blah)_(wikipedia)_blah', # noqa: E501 ), ( "(Something like http://foo.com/blah_blah_(wikipedia))", {}, - u'(Something like http://foo.com/blah_blah_(wikipedia))', # noqa: E501 + '(Something like http://foo.com/blah_blah_(wikipedia))', # noqa: E501 ), ( "http://foo.com/blah_blah.", {}, - u'http://foo.com/blah_blah.', + 'http://foo.com/blah_blah.', ), ( "http://foo.com/blah_blah/.", {}, - u'http://foo.com/blah_blah/.', + 'http://foo.com/blah_blah/.', ), ( "", {}, - u'<http://foo.com/blah_blah>', + '<http://foo.com/blah_blah>', ), ( "", {}, - u'<http://foo.com/blah_blah/>', + '<http://foo.com/blah_blah/>', ), ( "http://foo.com/blah_blah,", {}, - u'http://foo.com/blah_blah,', + 'http://foo.com/blah_blah,', ), ( "http://www.example.com/wpstyle/?p=364.", {}, - u'http://www.example.com/wpstyle/?p=364.', # noqa: E501 + 'http://www.example.com/wpstyle/?p=364.', # noqa: E501 ), ( "rdar://1234", {"permitted_protocols": ["http", "rdar"]}, - u'rdar://1234', + 'rdar://1234', ), ( "rdar:/1234", {"permitted_protocols": ["rdar"]}, - u'rdar:/1234', + 'rdar:/1234', ), ( "http://userid:password@example.com:8080", {}, - u'http://userid:password@example.com:8080', # noqa: E501 + 'http://userid:password@example.com:8080', # noqa: E501 ), ( "http://userid@example.com", {}, - u'http://userid@example.com', + 'http://userid@example.com', ), ( "http://userid@example.com:8080", {}, - u'http://userid@example.com:8080', + 'http://userid@example.com:8080', ), ( "http://userid:password@example.com", {}, - u'http://userid:password@example.com', + 'http://userid:password@example.com', ), ( "message://%3c330e7f8409726r6a4ba78dkf1fd71420c1bf6ff@mail.gmail.com%3e", {"permitted_protocols": ["http", "message"]}, - u'' - u"message://%3c330e7f8409726r6a4ba78dkf1fd71420c1bf6ff@mail.gmail.com%3e", + '' + "message://%3c330e7f8409726r6a4ba78dkf1fd71420c1bf6ff@mail.gmail.com%3e", ), ( - u"http://\u27a1.ws/\u4a39", + "http://\u27a1.ws/\u4a39", {}, - u'http://\u27a1.ws/\u4a39', + 'http://\u27a1.ws/\u4a39', ), ( "http://example.com", {}, - u'<tag>http://example.com</tag>', + '<tag>http://example.com</tag>', ), ( "Just a www.example.com link.", {}, - u'Just a www.example.com link.', + 'Just a www.example.com link.', ), ( "Just a www.example.com link.", {"require_protocol": True}, - u"Just a www.example.com link.", + "Just a www.example.com link.", ), ( "A http://reallylong.com/link/that/exceedsthelenglimit.html", {"require_protocol": True, "shorten": True}, - u'A http://reallylong.com/link...', # noqa: E501 + 'A http://reallylong.com/link...', # noqa: E501 ), ( "A http://reallylongdomainnamethatwillbetoolong.com/hi!", {"shorten": True}, - u'A http://reallylongdomainnametha...!', # noqa: E501 + 'A http://reallylongdomainnametha...!', # noqa: E501 ), ( "A file:///passwords.txt and http://web.com link", {}, - u'A file:///passwords.txt and http://web.com link', + 'A file:///passwords.txt and http://web.com link', ), ( "A file:///passwords.txt and http://web.com link", {"permitted_protocols": ["file"]}, - u'A file:///passwords.txt and http://web.com link', + 'A file:///passwords.txt and http://web.com link', ), ( "www.external-link.com", {"extra_params": 'rel="nofollow" class="external"'}, - u'www.external-link.com', # noqa: E501 + 'www.external-link.com', # noqa: E501 ), ( "www.external-link.com and www.internal-link.com/blogs extra", { - "extra_params": lambda href: 'class="internal"' - if href.startswith("http://www.internal-link.com") - else 'rel="nofollow" class="external"' + "extra_params": lambda href: ( + 'class="internal"' + if href.startswith("http://www.internal-link.com") + else 'rel="nofollow" class="external"' + ) }, - u'www.external-link.com' # noqa: E501 - u' and www.internal-link.com/blogs extra', # noqa: E501 + 'www.external-link.com' # noqa: E501 + ' and www.internal-link.com/blogs extra', # noqa: E501 ), ( "www.external-link.com", {"extra_params": lambda href: ' rel="nofollow" class="external" '}, - u'www.external-link.com', # noqa: E501 + 'www.external-link.com', # noqa: E501 ), ] # type: List[Tuple[Union[str, bytes], Dict[str, Any], str]] @@ -218,11 +220,11 @@ def test_linkify(self): def test_xhtml_escape(self): tests = [ ("", "<foo>"), - (u"", u"<foo>"), + ("", "<foo>"), (b"", b"<foo>"), - ("<>&\"'", "<>&"'"), + ("<>&\"'", "<>&"'"), ("&", "&amp;"), - (u"<\u00e9>", u"<\u00e9>"), + ("<\u00e9>", "<\u00e9>"), (b"<\xc3\xa9>", b"<\xc3\xa9>"), ] # type: List[Tuple[Union[str, bytes], Union[str, bytes]]] for unescaped, escaped in tests: @@ -234,7 +236,7 @@ def test_xhtml_unescape_numeric(self): ("foo bar", "foo bar"), ("foo bar", "foo bar"), ("foo bar", "foo bar"), - ("foo઼bar", u"foo\u0abcbar"), + ("foo઼bar", "foo\u0abcbar"), ("foo&#xyz;bar", "foo&#xyz;bar"), # invalid encoding ("foo&#;bar", "foo&#;bar"), # invalid encoding ("foo&#x;bar", "foo&#x;bar"), # invalid encoding @@ -245,19 +247,19 @@ def test_xhtml_unescape_numeric(self): def test_url_escape_unicode(self): tests = [ # byte strings are passed through as-is - (u"\u00e9".encode("utf8"), "%C3%A9"), - (u"\u00e9".encode("latin1"), "%E9"), + ("\u00e9".encode("utf8"), "%C3%A9"), + ("\u00e9".encode("latin1"), "%E9"), # unicode strings become utf8 - (u"\u00e9", "%C3%A9"), + ("\u00e9", "%C3%A9"), ] # type: List[Tuple[Union[str, bytes], str]] for unescaped, escaped in tests: self.assertEqual(url_escape(unescaped), escaped) def test_url_unescape_unicode(self): tests = [ - ("%C3%A9", u"\u00e9", "utf8"), - ("%C3%A9", u"\u00c3\u00a9", "latin1"), - ("%C3%A9", utf8(u"\u00e9"), None), + ("%C3%A9", "\u00e9", "utf8"), + ("%C3%A9", "\u00c3\u00a9", "latin1"), + ("%C3%A9", utf8("\u00e9"), None), ] for escaped, unescaped, encoding in tests: # input strings to url_unescape should only contain ascii @@ -283,30 +285,30 @@ def test_escape_return_types(self): # On python2 the escape methods should generally return the same # type as their argument self.assertEqual(type(xhtml_escape("foo")), str) - self.assertEqual(type(xhtml_escape(u"foo")), unicode_type) + self.assertEqual(type(xhtml_escape("foo")), unicode_type) def test_json_decode(self): # json_decode accepts both bytes and unicode, but strings it returns # are always unicode. - self.assertEqual(json_decode(b'"foo"'), u"foo") - self.assertEqual(json_decode(u'"foo"'), u"foo") + self.assertEqual(json_decode(b'"foo"'), "foo") + self.assertEqual(json_decode('"foo"'), "foo") # Non-ascii bytes are interpreted as utf8 - self.assertEqual(json_decode(utf8(u'"\u00e9"')), u"\u00e9") + self.assertEqual(json_decode(utf8('"\u00e9"')), "\u00e9") def test_json_encode(self): # json deals with strings, not bytes. On python 2 byte strings will # convert automatically if they are utf8; on python 3 byte strings # are not allowed. - self.assertEqual(json_decode(json_encode(u"\u00e9")), u"\u00e9") + self.assertEqual(json_decode(json_encode("\u00e9")), "\u00e9") if bytes is str: - self.assertEqual(json_decode(json_encode(utf8(u"\u00e9"))), u"\u00e9") + self.assertEqual(json_decode(json_encode(utf8("\u00e9"))), "\u00e9") self.assertRaises(UnicodeDecodeError, json_encode, b"\xe9") def test_squeeze(self): self.assertEqual( - squeeze(u"sequences of whitespace chars"), - u"sequences of whitespace chars", + squeeze("sequences of whitespace chars"), + "sequences of whitespace chars", ) def test_recursive_unicode(self): @@ -316,7 +318,7 @@ def test_recursive_unicode(self): "tuple": (b"foo", b"bar"), "bytes": b"foo", } - self.assertEqual(recursive_unicode(tests["dict"]), {u"foo": u"bar"}) - self.assertEqual(recursive_unicode(tests["list"]), [u"foo", u"bar"]) - self.assertEqual(recursive_unicode(tests["tuple"]), (u"foo", u"bar")) - self.assertEqual(recursive_unicode(tests["bytes"]), u"foo") + self.assertEqual(recursive_unicode(tests["dict"]), {"foo": "bar"}) + self.assertEqual(recursive_unicode(tests["list"]), ["foo", "bar"]) + self.assertEqual(recursive_unicode(tests["tuple"]), ("foo", "bar")) + self.assertEqual(recursive_unicode(tests["bytes"]), "foo") diff --git a/tornado/test/gen_test.py b/tornado/test/gen_test.py index 73c3387803..c17bf65fd0 100644 --- a/tornado/test/gen_test.py +++ b/tornado/test/gen_test.py @@ -1114,6 +1114,16 @@ def test_reset(self): # so we must make sure that we maintain that property across yield. ctx_var.reset(token) + @gen_test + def test_propagate_to_first_yield_with_native_async_function(self): + x = 10 + + async def native_async_function(): + self.assertEqual(ctx_var.get(), x) + + ctx_var.set(x) + yield native_async_function() + if __name__ == "__main__": unittest.main() diff --git a/tornado/test/gettext_translations/extract_me.py b/tornado/test/gettext_translations/extract_me.py index 08b29bc53c..860e3d1b2a 100644 --- a/tornado/test/gettext_translations/extract_me.py +++ b/tornado/test/gettext_translations/extract_me.py @@ -8,8 +8,8 @@ # 3) msgfmt tornado_test.po -o tornado_test.mo # 4) Put the file in the proper location: $LANG/LC_MESSAGES -_("school") -pgettext("law", "right") -pgettext("good", "right") -pgettext("organization", "club", "clubs", 1) -pgettext("stick", "club", "clubs", 1) +_("school") # type: ignore[name-defined] +pgettext("law", "right") # type: ignore[name-defined] +pgettext("good", "right") # type: ignore[name-defined] +pgettext("organization", "club", "clubs", 1) # type: ignore[name-defined] +pgettext("stick", "club", "clubs", 1) # type: ignore[name-defined] diff --git a/tornado/test/http1connection_test.py b/tornado/test/http1connection_test.py index d21d506228..34de6d3830 100644 --- a/tornado/test/http1connection_test.py +++ b/tornado/test/http1connection_test.py @@ -1,5 +1,5 @@ import socket -import typing +import typing # noqa(F401) from tornado.http1connection import HTTP1Connection from tornado.httputil import HTTPMessageDelegate diff --git a/tornado/test/httpclient_test.py b/tornado/test/httpclient_test.py index fd9a978640..17291f8f2e 100644 --- a/tornado/test/httpclient_test.py +++ b/tornado/test/httpclient_test.py @@ -28,7 +28,7 @@ from tornado.log import gen_log, app_log from tornado import netutil from tornado.testing import AsyncHTTPTestCase, bind_unused_port, gen_test, ExpectLog -from tornado.test.util import skipOnTravis +from tornado.test.util import skipOnTravis, ignore_deprecation from tornado.web import Application, RequestHandler, url from tornado.httputil import format_timestamp, HTTPHeaders @@ -139,18 +139,23 @@ def get(self): class InvalidGzipHandler(RequestHandler): - def get(self): + def get(self) -> None: # set Content-Encoding manually to avoid automatic gzip encoding self.set_header("Content-Type", "text/plain") self.set_header("Content-Encoding", "gzip") # Triggering the potential bug seems to depend on input length. # This length is taken from the bad-response example reported in # https://github.com/tornadoweb/tornado/pull/2875 (uncompressed). - body = "".join("Hello World {}\n".format(i) for i in range(9000))[:149051] - body = gzip.compress(body.encode(), compresslevel=6) + b"\00" + text = "".join("Hello World {}\n".format(i) for i in range(9000))[:149051] + body = gzip.compress(text.encode(), compresslevel=6) + b"\00" self.write(body) +class HeaderEncodingHandler(RequestHandler): + def get(self): + self.finish(self.request.headers["Foo"].encode("ISO8859-1")) + + # These tests end up getting run redundantly: once here with the default # HTTPClient implementation, and then again in each implementation's own # test suite. @@ -175,6 +180,7 @@ def get_app(self): url("/https/github.com/patch", PatchHandler), url("/https/github.com/set_header", SetHeaderHandler), url("/https/github.com/invalid_gzip", InvalidGzipHandler), + url("/https/github.com/header-encoding", HeaderEncodingHandler), ], gzip=True, ) @@ -285,7 +291,7 @@ def test_basic_auth_unicode(self): # The standard mandates NFC. Give it a decomposed username # and ensure it is normalized to composed form. - username = unicodedata.normalize("NFD", u"josé") + username = unicodedata.normalize("NFD", "josé") self.assertEqual( self.fetch("/auth", auth_username=username, auth_password="səcrət").body, b"Basic am9zw6k6c8mZY3LJmXQ=", @@ -374,7 +380,7 @@ def test_credentials_in_url(self): self.assertEqual(b"Basic " + base64.b64encode(b"me:secret"), response.body) def test_body_encoding(self): - unicode_body = u"\xe9" + unicode_body = "\xe9" byte_body = binascii.a2b_hex(b"e9") # unicode string in body gets converted to utf8 @@ -404,7 +410,7 @@ def test_body_encoding(self): method="POST", body=byte_body, headers={"Content-Type": "application/blah"}, - user_agent=u"foo", + user_agent="foo", ) self.assertEqual(response.headers["Content-Length"], "1") self.assertEqual(response.body, byte_body) @@ -474,7 +480,7 @@ def streaming_callback(chunk): streaming_callback=streaming_callback, ) self.assertEqual(len(first_line), 1, first_line) - self.assertRegexpMatches(first_line[0], "HTTP/[0-9]\\.[0-9] 200.*\r\n") + self.assertRegex(first_line[0], "HTTP/[0-9]\\.[0-9] 200.*\r\n") self.assertEqual(chunks, [b"asdf", b"qwer"]) @gen_test @@ -493,7 +499,7 @@ def test_header_types(self): # in a plain dictionary or an HTTPHeaders object. # Keys must always be the native str type. # All combinations should have the same results on the wire. - for value in [u"MyUserAgent", b"MyUserAgent"]: + for value in ["MyUserAgent", b"MyUserAgent"]: for container in [dict, HTTPHeaders]: headers = container() headers["User-Agent"] = value @@ -537,6 +543,16 @@ def accept_callback(conn, address): finally: self.io_loop.remove_handler(sock.fileno()) + @gen_test + def test_header_encoding(self): + response = yield self.http_client.fetch( + self.get_url("/header-encoding"), + headers={ + "Foo": "b\xe4r", + }, + ) + self.assertEqual(response.body, "b\xe4r".encode("ISO8859-1")) + def test_304_with_content_length(self): # According to the spec 304 responses SHOULD NOT include # Content-Length or other entity headers, but some servers do it @@ -665,7 +681,7 @@ def test_non_ascii_header(self): # Non-ascii headers are sent as latin1. response = self.fetch("/set_header?k=foo&v=%E9") response.rethrow() - self.assertEqual(response.headers["Foo"], native_str(u"\u00e9")) + self.assertEqual(response.headers["Foo"], native_str("\u00e9")) def test_response_times(self): # A few simple sanity checks of the response time fields to @@ -674,6 +690,7 @@ def test_response_times(self): start_time = time.time() response = self.fetch("/hello") response.rethrow() + assert response.request_time is not None self.assertGreaterEqual(response.request_time, 0) self.assertLess(response.request_time, 1.0) # A very crude check to make sure that start_time is based on @@ -708,6 +725,22 @@ def test_error_after_cancel(self): if el.logged_stack: break + def test_header_crlf(self): + # Ensure that the client doesn't allow CRLF injection in headers. RFC 9112 section 2.2 + # prohibits a bare CR specifically and "a recipient MAY recognize a single LF as a line + # terminator" so we check each character separately as well as the (redundant) CRLF pair. + for header, name in [ + ("foo\rbar:", "cr"), + ("foo\nbar:", "lf"), + ("foo\r\nbar:", "crlf"), + ]: + with self.subTest(name=name, position="value"): + with self.assertRaises(ValueError): + self.fetch("/hello", headers={"foo": header}) + with self.subTest(name=name, position="key"): + with self.assertRaises(ValueError): + self.fetch("/hello", headers={header: "foo"}) + class RequestProxyTest(unittest.TestCase): def test_request_set(self): @@ -754,7 +787,7 @@ def test_str(self): class SyncHTTPClientTest(unittest.TestCase): def setUp(self): - self.server_ioloop = IOLoop() + self.server_ioloop = IOLoop(make_current=False) event = threading.Event() @gen.coroutine @@ -836,7 +869,7 @@ def test_destructor_log(self): stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=True, - timeout=5, + timeout=15, ) if proc.stdout: print("STDOUT:") @@ -870,7 +903,15 @@ def test_body_setter(self): self.assertEqual(request.body, utf8("foo")) def test_if_modified_since(self): - http_date = datetime.datetime.utcnow() + http_date = datetime.datetime.now(datetime.timezone.utc) + request = HTTPRequest("http://example.com", if_modified_since=http_date) + self.assertEqual( + request.headers, {"If-Modified-Since": format_timestamp(http_date)} + ) + + def test_if_modified_since_naive_deprecated(self): + with ignore_deprecation(): + http_date = datetime.datetime.utcnow() request = HTTPRequest("http://example.com", if_modified_since=http_date) self.assertEqual( request.headers, {"If-Modified-Since": format_timestamp(http_date)} diff --git a/tornado/test/httpserver_test.py b/tornado/test/httpserver_test.py index 614dec7b8f..0b29a39ccf 100644 --- a/tornado/test/httpserver_test.py +++ b/tornado/test/httpserver_test.py @@ -18,7 +18,7 @@ ) from tornado.iostream import IOStream from tornado.locks import Event -from tornado.log import gen_log +from tornado.log import gen_log, app_log from tornado.netutil import ssl_options_to_context from tornado.simple_httpclient import SimpleAsyncHTTPClient from tornado.testing import ( @@ -41,6 +41,7 @@ import ssl import sys import tempfile +import textwrap import unittest import urllib.parse from io import BytesIO @@ -118,7 +119,7 @@ class SSLTestMixin(object): def get_ssl_options(self): return dict( ssl_version=self.get_ssl_version(), - **AsyncHTTPSTestCase.default_ssl_options() + **AsyncHTTPSTestCase.default_ssl_options(), ) def get_ssl_version(self): @@ -181,7 +182,9 @@ def get_ssl_version(self): class SSLContextTest(BaseSSLTest, SSLTestMixin): def get_ssl_options(self): - context = ssl_options_to_context(AsyncHTTPSTestCase.get_ssl_options(self)) + context = ssl_options_to_context( + AsyncHTTPSTestCase.get_ssl_options(self), server_side=True + ) assert isinstance(context, ssl.SSLContext) return context @@ -279,23 +282,23 @@ def test_multipart_form(self): [ b"Content-Disposition: form-data; name=argument", b"", - u"\u00e1".encode("utf-8"), + "\u00e1".encode("utf-8"), b"--1234567890", - u'Content-Disposition: form-data; name="files"; filename="\u00f3"'.encode( + 'Content-Disposition: form-data; name="files"; filename="\u00f3"'.encode( "utf8" ), b"", - u"\u00fa".encode("utf-8"), + "\u00fa".encode("utf-8"), b"--1234567890--", b"", ] ), ) data = json_decode(response) - self.assertEqual(u"\u00e9", data["header"]) - self.assertEqual(u"\u00e1", data["argument"]) - self.assertEqual(u"\u00f3", data["filename"]) - self.assertEqual(u"\u00fa", data["filebody"]) + self.assertEqual("\u00e9", data["header"]) + self.assertEqual("\u00e1", data["argument"]) + self.assertEqual("\u00f3", data["filename"]) + self.assertEqual("\u00fa", data["filebody"]) def test_newlines(self): # We support both CRLF and bare LF as line separators. @@ -410,17 +413,17 @@ def get_app(self): def test_query_string_encoding(self): response = self.fetch("/echo?foo=%C3%A9") data = json_decode(response.body) - self.assertEqual(data, {u"foo": [u"\u00e9"]}) + self.assertEqual(data, {"foo": ["\u00e9"]}) def test_empty_query_string(self): response = self.fetch("/echo?foo=&foo=") data = json_decode(response.body) - self.assertEqual(data, {u"foo": [u"", u""]}) + self.assertEqual(data, {"foo": ["", ""]}) def test_empty_post_parameters(self): response = self.fetch("/echo", method="POST", body="foo=&bar=") data = json_decode(response.body) - self.assertEqual(data, {u"foo": [u""], u"bar": [u""]}) + self.assertEqual(data, {"foo": [""], "bar": [""]}) def test_types(self): headers = {"Cookie": "foo=bar"} @@ -530,7 +533,7 @@ def test_chunked_request_body(self): start_line, headers, response = self.io_loop.run_sync( lambda: read_stream_body(self.stream) ) - self.assertEqual(json_decode(response), {u"foo": [u"bar"]}) + self.assertEqual(json_decode(response), {"foo": ["bar"]}) def test_chunked_request_uppercase(self): # As per RFC 2616 section 3.6, "Transfer-Encoding" header's value is @@ -554,25 +557,132 @@ def test_chunked_request_uppercase(self): start_line, headers, response = self.io_loop.run_sync( lambda: read_stream_body(self.stream) ) - self.assertEqual(json_decode(response), {u"foo": [u"bar"]}) + self.assertEqual(json_decode(response), {"foo": ["bar"]}) - @gen_test - def test_invalid_content_length(self): + def test_chunked_request_body_invalid_size(self): + # Only hex digits are allowed in chunk sizes. Python's int() function + # also accepts underscores, so make sure we reject them here. + self.stream.write( + b"""\ +POST /echo HTTP/1.1 +Transfer-Encoding: chunked + +1_a +1234567890abcdef1234567890 +0 + +""".replace( + b"\n", b"\r\n" + ) + ) + with ExpectLog(gen_log, ".*invalid chunk size", level=logging.INFO): + start_line, headers, response = self.io_loop.run_sync( + lambda: read_stream_body(self.stream) + ) + self.assertEqual(400, start_line.code) + + def test_chunked_request_body_duplicate_header(self): + # Repeated Transfer-Encoding headers should be an error (and not confuse + # the chunked-encoding detection to mess up framing). + self.stream.write( + b"""\ +POST /echo HTTP/1.1 +Transfer-Encoding: chunked +Transfer-encoding: chunked + +2 +ok +0 + +""" + ) with ExpectLog( - gen_log, ".*Only integer Content-Length is allowed", level=logging.INFO + gen_log, + ".*Unsupported Transfer-Encoding chunked,chunked", + level=logging.INFO, ): - self.stream.write( - b"""\ + start_line, headers, response = self.io_loop.run_sync( + lambda: read_stream_body(self.stream) + ) + self.assertEqual(400, start_line.code) + + def test_chunked_request_body_unsupported_transfer_encoding(self): + # We don't support transfer-encodings other than chunked. + self.stream.write( + b"""\ POST /echo HTTP/1.1 -Content-Length: foo +Transfer-Encoding: gzip, chunked -bar +2 +ok +0 -""".replace( - b"\n", b"\r\n" - ) +""" + ) + with ExpectLog( + gen_log, ".*Unsupported Transfer-Encoding gzip, chunked", level=logging.INFO + ): + start_line, headers, response = self.io_loop.run_sync( + lambda: read_stream_body(self.stream) ) - yield self.stream.read_until_close() + self.assertEqual(400, start_line.code) + + def test_chunked_request_body_transfer_encoding_and_content_length(self): + # Transfer-encoding and content-length are mutually exclusive + self.stream.write( + b"""\ +POST /echo HTTP/1.1 +Transfer-Encoding: chunked +Content-Length: 2 + +2 +ok +0 + +""" + ) + with ExpectLog( + gen_log, + ".*Message with both Transfer-Encoding and Content-Length", + level=logging.INFO, + ): + start_line, headers, response = self.io_loop.run_sync( + lambda: read_stream_body(self.stream) + ) + self.assertEqual(400, start_line.code) + + @gen_test + def test_invalid_content_length(self): + # HTTP only allows decimal digits in content-length. Make sure we don't + # accept anything else, with special attention to things accepted by the + # python int() function (leading plus signs and internal underscores). + test_cases = [ + ("alphabetic", "foo"), + ("leading plus", "+10"), + ("internal underscore", "1_0"), + ] + for name, value in test_cases: + with self.subTest(name=name), closing(IOStream(socket.socket())) as stream: + with ExpectLog( + gen_log, + ".*Only integer Content-Length is allowed", + level=logging.INFO, + ): + yield stream.connect(("127.0.0.1", self.get_http_port())) + stream.write( + utf8( + textwrap.dedent( + f"""\ + POST /echo HTTP/1.1 + Content-Length: {value} + Connection: close + + 1234567890 + """ + ).replace("\n", "\r\n") + ) + ) + yield stream.read_until_close() class XHeaderTest(HandlerBaseTestCase): @@ -752,7 +862,7 @@ def test_unix_socket(self): def test_unix_socket_bad_request(self): # Unix sockets don't have remote addresses so they just return an # empty string. - with ExpectLog(gen_log, "Malformed HTTP message from"): + with ExpectLog(gen_log, "Malformed HTTP message from", level=logging.INFO): self.stream.write(b"garbage\r\n\r\n") response = yield self.stream.read_until_close() self.assertEqual(response, b"HTTP/1.1 400 Bad Request\r\n\r\n") @@ -989,7 +1099,7 @@ def post_gzip(self, body): def test_uncompressed(self): response = self.fetch("/", method="POST", body="foo=bar") - self.assertEqual(json_decode(response.body), {u"foo": [u"bar"]}) + self.assertEqual(json_decode(response.body), {"foo": ["bar"]}) class GzipTest(GzipBaseTest, AsyncHTTPTestCase): @@ -998,7 +1108,22 @@ def get_httpserver_options(self): def test_gzip(self): response = self.post_gzip("foo=bar") - self.assertEqual(json_decode(response.body), {u"foo": [u"bar"]}) + self.assertEqual(json_decode(response.body), {"foo": ["bar"]}) + + def test_gzip_case_insensitive(self): + # https://datatracker.ietf.org/doc/html/rfc7231#section-3.1.2.1 + bytesio = BytesIO() + gzip_file = gzip.GzipFile(mode="w", fileobj=bytesio) + gzip_file.write(utf8("foo=bar")) + gzip_file.close() + compressed_body = bytesio.getvalue() + response = self.fetch( + "/", + method="POST", + body=compressed_body, + headers={"Content-Encoding": "GZIP"}, + ) + self.assertEqual(json_decode(response.body), {"foo": ["bar"]}) class GzipUnsupportedTest(GzipBaseTest, AsyncHTTPTestCase): @@ -1106,6 +1231,46 @@ def body_producer(write): ) +class InvalidOutputContentLengthTest(AsyncHTTPTestCase): + class MessageDelegate(HTTPMessageDelegate): + def __init__(self, connection): + self.connection = connection + + def headers_received(self, start_line, headers): + content_lengths = { + "normal": "10", + "alphabetic": "foo", + "leading plus": "+10", + "underscore": "1_0", + } + self.connection.write_headers( + ResponseStartLine("HTTP/1.1", 200, "OK"), + HTTPHeaders({"Content-Length": content_lengths[headers["x-test"]]}), + ) + self.connection.write(b"1234567890") + self.connection.finish() + + def get_app(self): + class App(HTTPServerConnectionDelegate): + def start_request(self, server_conn, request_conn): + return InvalidOutputContentLengthTest.MessageDelegate(request_conn) + + return App() + + def test_invalid_output_content_length(self): + with self.subTest("normal"): + response = self.fetch("/", method="GET", headers={"x-test": "normal"}) + response.rethrow() + self.assertEqual(response.body, b"1234567890") + for test in ["alphabetic", "leading plus", "underscore"]: + with self.subTest(test): + # This log matching could be tighter but I think I'm already + # over-testing here. + with ExpectLog(app_log, "Uncaught exception"): + with self.assertRaises(HTTPError): + self.fetch("/", method="GET", headers={"x-test": test}) + + class MaxHeaderSizeTest(AsyncHTTPTestCase): def get_app(self): return Application([("/", HelloWorldRequestHandler)]) diff --git a/tornado/test/httputil_test.py b/tornado/test/httputil_test.py index 0fad403b9d..975900aa9c 100644 --- a/tornado/test/httputil_test.py +++ b/tornado/test/httputil_test.py @@ -13,6 +13,7 @@ from tornado.escape import utf8, native_str from tornado.log import gen_log from tornado.testing import ExpectLog +from tornado.test.util import ignore_deprecation import copy import datetime @@ -167,7 +168,7 @@ def test_non_ascii_filename(self): args, files = form_data_args() parse_multipart_form_data(b"1234", data, args, files) file = files["files"][0] - self.assertEqual(file["filename"], u"áb.txt") + self.assertEqual(file["filename"], "áb.txt") self.assertEqual(file["body"], b"Foo") def test_boundary_starts_and_ends_with_quotes(self): @@ -301,13 +302,13 @@ def test_unicode_newlines(self): # and cpython's unicodeobject.c (which defines the implementation # of unicode_type.splitlines(), and uses a different list than TR13). newlines = [ - u"\u001b", # VERTICAL TAB - u"\u001c", # FILE SEPARATOR - u"\u001d", # GROUP SEPARATOR - u"\u001e", # RECORD SEPARATOR - u"\u0085", # NEXT LINE - u"\u2028", # LINE SEPARATOR - u"\u2029", # PARAGRAPH SEPARATOR + "\u001b", # VERTICAL TAB + "\u001c", # FILE SEPARATOR + "\u001d", # GROUP SEPARATOR + "\u001e", # RECORD SEPARATOR + "\u0085", # NEXT LINE + "\u2028", # LINE SEPARATOR + "\u2029", # PARAGRAPH SEPARATOR ] for newline in newlines: # Try the utf8 and latin1 representations of each newline @@ -333,6 +334,25 @@ def test_unicode_newlines(self): gen_log.warning("failed while trying %r in %s", newline, encoding) raise + def test_unicode_whitespace(self): + # Only tabs and spaces are to be stripped according to the HTTP standard. + # Other unicode whitespace is to be left as-is. In the context of headers, + # this specifically means the whitespace characters falling within the + # latin1 charset. + whitespace = [ + (" ", True), # SPACE + ("\t", True), # TAB + ("\u00a0", False), # NON-BREAKING SPACE + ("\u0085", False), # NEXT LINE + ] + for c, stripped in whitespace: + headers = HTTPHeaders.parse("Transfer-Encoding: %schunked" % c) + if stripped: + expected = [("Transfer-Encoding", "chunked")] + else: + expected = [("Transfer-Encoding", "%schunked" % c)] + self.assertEqual(expected, list(headers.get_all())) + def test_optional_cr(self): # Both CRLF and LF should be accepted as separators. CR should not be # part of the data when followed by LF, but it is a normal char @@ -412,8 +432,29 @@ def test_time_tuple(self): self.assertEqual(9, len(tup)) self.check(tup) - def test_datetime(self): - self.check(datetime.datetime.utcfromtimestamp(self.TIMESTAMP)) + def test_utc_naive_datetime(self): + self.check( + datetime.datetime.fromtimestamp( + self.TIMESTAMP, datetime.timezone.utc + ).replace(tzinfo=None) + ) + + def test_utc_naive_datetime_deprecated(self): + with ignore_deprecation(): + self.check(datetime.datetime.utcfromtimestamp(self.TIMESTAMP)) + + def test_utc_aware_datetime(self): + self.check( + datetime.datetime.fromtimestamp(self.TIMESTAMP, datetime.timezone.utc) + ) + + def test_other_aware_datetime(self): + # Other timezones are ignored; the timezone is always printed as GMT + self.check( + datetime.datetime.fromtimestamp( + self.TIMESTAMP, datetime.timezone(datetime.timedelta(hours=-4)) + ) + ) # HTTPServerRequest is mainly tested incidentally to the server itself, @@ -519,3 +560,49 @@ def test_invalid_cookies(self): self.assertEqual( parse_cookie(" = b ; ; = ; c = ; "), {"": "b", "c": ""} ) + + def test_unquote(self): + # Copied from + # https://github.com/python/cpython/blob/dc7a2b6522ec7af41282bc34f405bee9b306d611/Lib/test/test_http_cookies.py#L62 + cases = [ + (r'a="b=\""', 'b="'), + (r'a="b=\\"', "b=\\"), + (r'a="b=\="', "b=="), + (r'a="b=\n"', "b=n"), + (r'a="b=\042"', 'b="'), + (r'a="b=\134"', "b=\\"), + (r'a="b=\377"', "b=\xff"), + (r'a="b=\400"', "b=400"), + (r'a="b=\42"', "b=42"), + (r'a="b=\\042"', "b=\\042"), + (r'a="b=\\134"', "b=\\134"), + (r'a="b=\\\""', 'b=\\"'), + (r'a="b=\\\042"', 'b=\\"'), + (r'a="b=\134\""', 'b=\\"'), + (r'a="b=\134\042"', 'b=\\"'), + ] + for encoded, decoded in cases: + with self.subTest(encoded): + c = parse_cookie(encoded) + self.assertEqual(c["a"], decoded) + + def test_unquote_large(self): + # Adapted from + # https://github.com/python/cpython/blob/dc7a2b6522ec7af41282bc34f405bee9b306d611/Lib/test/test_http_cookies.py#L87 + # Modified from that test because we handle semicolons differently from the stdlib. + # + # This is a performance regression test: prior to improvements in Tornado 6.4.2, this test + # would take over a minute with n= 100k. Now it runs in tens of milliseconds. + n = 100000 + for encoded in r"\\", r"\134": + with self.subTest(encoded): + start = time.time() + data = 'a="b=' + encoded * n + '"' + value = parse_cookie(data)["a"] + end = time.time() + self.assertEqual(value[:3], "b=\\") + self.assertEqual(value[-3:], "\\\\\\") + self.assertEqual(len(value), n + 2) + + # Very loose performance check to avoid false positives + self.assertLess(end - start, 1, "Test took too long") diff --git a/tornado/test/import_test.py b/tornado/test/import_test.py index 23450fbc6a..1ff52206a0 100644 --- a/tornado/test/import_test.py +++ b/tornado/test/import_test.py @@ -11,38 +11,31 @@ import asyncio asyncio.set_event_loop(None) -import tornado.auth -import tornado.autoreload -import tornado.concurrent -import tornado.escape -import tornado.gen -import tornado.http1connection -import tornado.httpclient -import tornado.httpserver -import tornado.httputil -import tornado.ioloop -import tornado.iostream -import tornado.locale -import tornado.log -import tornado.netutil -import tornado.options -import tornado.process -import tornado.simple_httpclient -import tornado.tcpserver -import tornado.tcpclient -import tornado.template -import tornado.testing -import tornado.util -import tornado.web -import tornado.websocket -import tornado.wsgi +import importlib +import tornado -try: - import pycurl -except ImportError: - pass -else: - import tornado.curl_httpclient +for mod in tornado.__all__: + if mod == "curl_httpclient": + # This module has extra dependencies; skip it if they're not installed. + try: + import pycurl + except ImportError: + continue + importlib.import_module(f"tornado.{mod}") +""" + +_import_lazy = b""" +import sys +import tornado + +if "tornado.web" in sys.modules: + raise Exception("unexpected eager import") + +# Trigger a lazy import by referring to something in a submodule. +tornado.web.RequestHandler + +if "tornado.web" not in sys.modules: + raise Exception("lazy import did not update sys.modules") """ @@ -56,11 +49,17 @@ def test_import_everything(self): proc.communicate(_import_everything) self.assertEqual(proc.returncode, 0) + def test_lazy_import(self): + # Test that submodules can be referenced lazily after "import tornado" + proc = subprocess.Popen([sys.executable], stdin=subprocess.PIPE) + proc.communicate(_import_lazy) + self.assertEqual(proc.returncode, 0) + def test_import_aliases(self): # Ensure we don't delete formerly-documented aliases accidentally. - import tornado.ioloop - import tornado.gen - import tornado.util + import tornado + import asyncio self.assertIs(tornado.ioloop.TimeoutError, tornado.util.TimeoutError) self.assertIs(tornado.gen.TimeoutError, tornado.util.TimeoutError) + self.assertIs(tornado.util.TimeoutError, asyncio.TimeoutError) diff --git a/tornado/test/ioloop_test.py b/tornado/test/ioloop_test.py index 16848b38d2..d07438aa63 100644 --- a/tornado/test/ioloop_test.py +++ b/tornado/test/ioloop_test.py @@ -1,5 +1,7 @@ +import asyncio from concurrent.futures import ThreadPoolExecutor from concurrent import futures +from collections.abc import Generator import contextlib import datetime import functools @@ -16,8 +18,19 @@ from tornado import gen from tornado.ioloop import IOLoop, TimeoutError, PeriodicCallback from tornado.log import app_log -from tornado.testing import AsyncTestCase, bind_unused_port, ExpectLog, gen_test -from tornado.test.util import skipIfNonUnix, skipOnTravis +from tornado.testing import ( + AsyncTestCase, + bind_unused_port, + ExpectLog, + gen_test, + setup_with_context_manager, +) +from tornado.test.util import ( + ignore_deprecation, + skipIfNonUnix, + skipOnTravis, +) +from tornado.concurrent import Future import typing @@ -114,7 +127,8 @@ def test_remove_without_add(self): def test_add_callback_from_signal(self): # cheat a little bit and just run this normally, since we can't # easily simulate the races that happen with real signal handlers - self.io_loop.add_callback_from_signal(self.stop) + with ignore_deprecation(): + self.io_loop.add_callback_from_signal(self.stop) self.wait() def test_add_callback_from_signal_other_thread(self): @@ -124,7 +138,8 @@ def test_add_callback_from_signal_other_thread(self): other_ioloop = IOLoop() thread = threading.Thread(target=other_ioloop.start) thread.start() - other_ioloop.add_callback_from_signal(other_ioloop.stop) + with ignore_deprecation(): + other_ioloop.add_callback_from_signal(other_ioloop.stop) thread.join() other_ioloop.close() @@ -161,7 +176,7 @@ def handler(fd, events): self.io_loop.add_handler(client.fileno(), handler, IOLoop.READ) self.io_loop.add_timeout( - self.io_loop.time() + 0.01, functools.partial(server.send, b"asdf") # type: ignore + self.io_loop.time() + 0.01, functools.partial(server.send, b"asdf") ) self.wait() self.io_loop.remove_handler(client.fileno()) @@ -246,6 +261,7 @@ def test_close_file_object(self): the object should be closed (by IOLoop.close(all_fds=True), not just the fd. """ + # Use a socket since they are supported by IOLoop on all platforms. # Unfortunately, sockets don't support the .closed attribute for # inspecting their close status, so we must use a wrapper. @@ -410,16 +426,26 @@ def test_init_close_race(self): # threads. def f(): for i in range(10): - loop = IOLoop() + loop = IOLoop(make_current=False) loop.close() yield gen.multi([self.io_loop.run_in_executor(None, f) for i in range(2)]) + def test_explicit_asyncio_loop(self): + asyncio_loop = asyncio.new_event_loop() + loop = IOLoop(asyncio_loop=asyncio_loop, make_current=False) + assert loop.asyncio_loop is asyncio_loop # type: ignore + with self.assertRaises(RuntimeError): + # Can't register two IOLoops with the same asyncio_loop + IOLoop(asyncio_loop=asyncio_loop, make_current=False) + loop.close() + # Deliberately not a subclass of AsyncTestCase so the IOLoop isn't # automatically set as current. class TestIOLoopCurrent(unittest.TestCase): def setUp(self): + setup_with_context_manager(self, ignore_deprecation()) self.io_loop = None # type: typing.Optional[IOLoop] IOLoop.clear_current() @@ -427,15 +453,6 @@ def tearDown(self): if self.io_loop is not None: self.io_loop.close() - def test_default_current(self): - self.io_loop = IOLoop() - # The first IOLoop with default arguments is made current. - self.assertIs(self.io_loop, IOLoop.current()) - # A second IOLoop can be created but is not made current. - io_loop2 = IOLoop() - self.assertIs(self.io_loop, IOLoop.current()) - io_loop2.close() - def test_non_current(self): self.io_loop = IOLoop(make_current=False) # The new IOLoop is not initially made current. @@ -458,14 +475,13 @@ def f(): def test_force_current(self): self.io_loop = IOLoop(make_current=True) self.assertIs(self.io_loop, IOLoop.current()) - with self.assertRaises(RuntimeError): - # A second make_current=True construction cannot succeed. - IOLoop(make_current=True) - # current() was not affected by the failed construction. - self.assertIs(self.io_loop, IOLoop.current()) class TestIOLoopCurrentAsync(AsyncTestCase): + def setUp(self): + super().setUp() + setup_with_context_manager(self, ignore_deprecation()) + @gen_test def test_clear_without_current(self): # If there is no current IOLoop, clear_current is a no-op (but @@ -557,7 +573,7 @@ def sync_func(): class TestIOLoopRunSync(unittest.TestCase): def setUp(self): - self.io_loop = IOLoop() + self.io_loop = IOLoop(make_current=False) def tearDown(self): self.io_loop.close() @@ -688,6 +704,63 @@ def mock_random(): with mock.patch("random.random", mock_random): self.assertEqual(self.simulate_calls(pc, call_durations), expected) + def test_timedelta(self): + pc = PeriodicCallback(lambda: None, datetime.timedelta(minutes=1, seconds=23)) + expected_callback_time = 83000 + self.assertEqual(pc.callback_time, expected_callback_time) + + +class TestPeriodicCallbackAsync(AsyncTestCase): + def test_periodic_plain(self): + count = 0 + + def callback() -> None: + nonlocal count + count += 1 + if count == 3: + self.stop() + + pc = PeriodicCallback(callback, 10) + pc.start() + self.wait() + pc.stop() + self.assertEqual(count, 3) + + def test_periodic_coro(self) -> None: + counts = [0, 0] + + @gen.coroutine + def callback() -> "Generator[Future[None], object, None]": + counts[0] += 1 + yield gen.sleep(0.025) + counts[1] += 1 + if counts[1] == 3: + pc.stop() + self.io_loop.add_callback(self.stop) + + pc = PeriodicCallback(callback, 10) + pc.start() + self.wait() + self.assertEqual(counts[0], 3) + self.assertEqual(counts[1], 3) + + def test_periodic_async(self) -> None: + counts = [0, 0] + + async def callback() -> None: + counts[0] += 1 + await gen.sleep(0.025) + counts[1] += 1 + if counts[1] == 3: + pc.stop() + self.io_loop.add_callback(self.stop) + + pc = PeriodicCallback(callback, 10) + pc.start() + self.wait() + self.assertEqual(counts[0], 3) + self.assertEqual(counts[1], 3) + class TestIOLoopConfiguration(unittest.TestCase): def run_python(self, *statements): diff --git a/tornado/test/iostream_test.py b/tornado/test/iostream_test.py index a43aa64cad..02fcd3e13f 100644 --- a/tornado/test/iostream_test.py +++ b/tornado/test/iostream_test.py @@ -12,7 +12,7 @@ from tornado.httputil import HTTPHeaders from tornado.locks import Condition, Event from tornado.log import gen_log -from tornado.netutil import ssl_wrap_socket +from tornado.netutil import ssl_options_to_context, ssl_wrap_socket from tornado.platform.asyncio import AddThreadSelectorEventLoop from tornado.tcpserver import TCPServer from tornado.testing import ( @@ -23,7 +23,12 @@ ExpectLog, gen_test, ) -from tornado.test.util import skipIfNonUnix, refusing_port, skipPypy3V58 +from tornado.test.util import ( + skipIfNonUnix, + refusing_port, + skipPypy3V58, + ignore_deprecation, +) from tornado.web import RequestHandler, Application import asyncio import errno @@ -784,7 +789,7 @@ def test_read_until_close_with_error(self: typing.Any): "tornado.iostream.BaseIOStream._try_inline_read", side_effect=IOError("boom"), ): - with self.assertRaisesRegexp(IOError, "boom"): + with self.assertRaisesRegex(IOError, "boom"): client.read_until_close() finally: server.close() @@ -811,7 +816,10 @@ def test_inline_read_error(self: typing.Any): # windows, making this check redundant with skipIfNonUnix, but # we sometimes enable it on other platforms for testing. io_loop = IOLoop.current() - if isinstance(io_loop.selector_loop, AddThreadSelectorEventLoop): + if isinstance( + io_loop.selector_loop, # type: ignore[attr-defined] + AddThreadSelectorEventLoop, + ): self.skipTest("AddThreadSelectorEventLoop not supported") server, client = yield self.make_iostream_pair() try: @@ -900,11 +908,11 @@ def _make_client_iostream(self, connection, **kwargs): class TestIOStreamSSL(TestIOStreamMixin, AsyncTestCase): def _make_server_iostream(self, connection, **kwargs): - connection = ssl.wrap_socket( + ssl_ctx = ssl_options_to_context(_server_ssl_options(), server_side=True) + connection = ssl_ctx.wrap_socket( connection, server_side=True, do_handshake_on_connect=False, - **_server_ssl_options() ) return SSLIOStream(connection, **kwargs) @@ -919,7 +927,7 @@ def _make_client_iostream(self, connection, **kwargs): # instead of an ssl_options dict to the SSLIOStream constructor. class TestIOStreamSSLContext(TestIOStreamMixin, AsyncTestCase): def _make_server_iostream(self, connection, **kwargs): - context = ssl.SSLContext(ssl.PROTOCOL_SSLv23) + context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) context.load_cert_chain( os.path.join(os.path.dirname(__file__), "test.crt"), os.path.join(os.path.dirname(__file__), "test.key"), @@ -930,7 +938,9 @@ def _make_server_iostream(self, connection, **kwargs): return SSLIOStream(connection, **kwargs) def _make_client_iostream(self, connection, **kwargs): - context = ssl.SSLContext(ssl.PROTOCOL_SSLv23) + context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE return SSLIOStream(connection, ssl_options=context, **kwargs) @@ -1047,6 +1057,17 @@ def test_check_hostname(self): # The server fails to connect, but the exact error is unspecified. yield server_future + @gen_test + def test_typed_memoryview(self): + # Test support of memoryviews with an item size greater than 1 byte. + buf = memoryview(bytes(80)).cast("L") + assert self.server_stream is not None + yield self.server_stream.write(buf) + assert self.client_stream is not None + # This will timeout if the calculation of the buffer size is incorrect + recv = yield self.client_stream.read_bytes(buf.nbytes) + self.assertEqual(bytes(recv), bytes(buf)) + class WaitForHandshakeTest(AsyncTestCase): @gen.coroutine @@ -1065,8 +1086,11 @@ def connect_to_server(self, server_cls): # to openssl 1.1.c. Other platforms might be affected with # newer openssl too). Disable it until we figure out # what's up. - ssl_ctx.options |= getattr(ssl, "OP_NO_TLSv1_3", 0) - client = SSLIOStream(socket.socket(), ssl_options=ssl_ctx) + # Update 2021-12-28: Still happening with Python 3.10 on + # Windows. OP_NO_TLSv1_3 now raises a DeprecationWarning. + with ignore_deprecation(): + ssl_ctx.options |= getattr(ssl, "OP_NO_TLSv1_3", 0) + client = SSLIOStream(socket.socket(), ssl_options=ssl_ctx) yield client.connect(("127.0.0.1", port)) self.assertIsNotNone(client.socket.cipher()) finally: @@ -1125,6 +1149,88 @@ def handle_stream(self, stream, address): yield handshake_future +class TestIOStreamCheckHostname(AsyncTestCase): + # This test ensures that hostname checks are working correctly after + # #3337 revealed that we have no test coverage in this area, and we + # removed a manual hostname check that was needed only for very old + # versions of python. + def setUp(self): + super().setUp() + self.listener, self.port = bind_unused_port() + + def accept_callback(connection, address): + ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + ssl_ctx.load_cert_chain( + os.path.join(os.path.dirname(__file__), "test.crt"), + os.path.join(os.path.dirname(__file__), "test.key"), + ) + connection = ssl_ctx.wrap_socket( + connection, + server_side=True, + do_handshake_on_connect=False, + ) + SSLIOStream(connection) + + netutil.add_accept_handler(self.listener, accept_callback) + + # Our self-signed cert is its own CA. We have to pass the CA check before + # the hostname check will be performed. + self.client_ssl_ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) + self.client_ssl_ctx.load_verify_locations( + os.path.join(os.path.dirname(__file__), "test.crt") + ) + + def tearDown(self): + self.io_loop.remove_handler(self.listener.fileno()) + self.listener.close() + super().tearDown() + + @gen_test + async def test_match(self): + stream = SSLIOStream(socket.socket(), ssl_options=self.client_ssl_ctx) + await stream.connect( + ("127.0.0.1", self.port), + server_hostname="foo.example.com", + ) + stream.close() + + @gen_test + async def test_no_match(self): + stream = SSLIOStream(socket.socket(), ssl_options=self.client_ssl_ctx) + with ExpectLog( + gen_log, + ".*alert bad certificate", + level=logging.WARNING, + required=platform.system() != "Windows", + ): + with self.assertRaises(ssl.SSLCertVerificationError): + with ExpectLog( + gen_log, + ".*(certificate verify failed: Hostname mismatch)", + level=logging.WARNING, + ): + await stream.connect( + ("127.0.0.1", self.port), + server_hostname="bar.example.com", + ) + # The server logs a warning while cleaning up the failed connection. + # Unfortunately there's no good hook to wait for this logging. + # It doesn't seem to happen on windows; I'm not sure why. + if platform.system() != "Windows": + await asyncio.sleep(0.1) + + @gen_test + async def test_check_disabled(self): + # check_hostname can be set to false and the connection will succeed even though it doesn't + # have the right hostname. + self.client_ssl_ctx.check_hostname = False + stream = SSLIOStream(socket.socket(), ssl_options=self.client_ssl_ctx) + await stream.connect( + ("127.0.0.1", self.port), + server_hostname="bar.example.com", + ) + + @skipIfNonUnix class TestPipeIOStream(TestReadWriteMixin, AsyncTestCase): @gen.coroutine diff --git a/tornado/test/locale_test.py b/tornado/test/locale_test.py index a12dc981e1..a2e0872b8f 100644 --- a/tornado/test/locale_test.py +++ b/tornado/test/locale_test.py @@ -33,7 +33,7 @@ def test_csv(self): ) locale = tornado.locale.get("fr_FR") self.assertTrue(isinstance(locale, tornado.locale.CSVLocale)) - self.assertEqual(locale.translate("school"), u"\u00e9cole") + self.assertEqual(locale.translate("school"), "\u00e9cole") def test_csv_bom(self): with open( @@ -53,7 +53,7 @@ def test_csv_bom(self): tornado.locale.load_translations(tmpdir) locale = tornado.locale.get("fr_FR") self.assertIsInstance(locale, tornado.locale.CSVLocale) - self.assertEqual(locale.translate("school"), u"\u00e9cole") + self.assertEqual(locale.translate("school"), "\u00e9cole") finally: shutil.rmtree(tmpdir) @@ -64,24 +64,22 @@ def test_gettext(self): ) locale = tornado.locale.get("fr_FR") self.assertTrue(isinstance(locale, tornado.locale.GettextLocale)) - self.assertEqual(locale.translate("school"), u"\u00e9cole") - self.assertEqual(locale.pgettext("law", "right"), u"le droit") - self.assertEqual(locale.pgettext("good", "right"), u"le bien") + self.assertEqual(locale.translate("school"), "\u00e9cole") + self.assertEqual(locale.pgettext("law", "right"), "le droit") + self.assertEqual(locale.pgettext("good", "right"), "le bien") + self.assertEqual(locale.pgettext("organization", "club", "clubs", 1), "le club") self.assertEqual( - locale.pgettext("organization", "club", "clubs", 1), u"le club" + locale.pgettext("organization", "club", "clubs", 2), "les clubs" ) - self.assertEqual( - locale.pgettext("organization", "club", "clubs", 2), u"les clubs" - ) - self.assertEqual(locale.pgettext("stick", "club", "clubs", 1), u"le b\xe2ton") - self.assertEqual(locale.pgettext("stick", "club", "clubs", 2), u"les b\xe2tons") + self.assertEqual(locale.pgettext("stick", "club", "clubs", 1), "le b\xe2ton") + self.assertEqual(locale.pgettext("stick", "club", "clubs", 2), "les b\xe2tons") class LocaleDataTest(unittest.TestCase): def test_non_ascii_name(self): name = tornado.locale.LOCALE_NAMES["es_LA"]["name"] self.assertTrue(isinstance(name, unicode_type)) - self.assertEqual(name, u"Espa\u00f1ol") + self.assertEqual(name, "Espa\u00f1ol") self.assertEqual(utf8(name), b"Espa\xc3\xb1ol") @@ -93,45 +91,55 @@ def test_format_date(self): locale.format_date(date, full_format=True), "April 28, 2013 at 6:35 pm" ) - now = datetime.datetime.utcnow() - - self.assertEqual( - locale.format_date(now - datetime.timedelta(seconds=2), full_format=False), - "2 seconds ago", - ) - self.assertEqual( - locale.format_date(now - datetime.timedelta(minutes=2), full_format=False), - "2 minutes ago", - ) - self.assertEqual( - locale.format_date(now - datetime.timedelta(hours=2), full_format=False), - "2 hours ago", - ) - - self.assertEqual( - locale.format_date( - now - datetime.timedelta(days=1), full_format=False, shorter=True - ), - "yesterday", - ) - - date = now - datetime.timedelta(days=2) - self.assertEqual( - locale.format_date(date, full_format=False, shorter=True), - locale._weekdays[date.weekday()], - ) - - date = now - datetime.timedelta(days=300) - self.assertEqual( - locale.format_date(date, full_format=False, shorter=True), - "%s %d" % (locale._months[date.month - 1], date.day), - ) - - date = now - datetime.timedelta(days=500) - self.assertEqual( - locale.format_date(date, full_format=False, shorter=True), - "%s %d, %d" % (locale._months[date.month - 1], date.day, date.year), - ) + aware_dt = datetime.datetime.now(datetime.timezone.utc) + naive_dt = aware_dt.replace(tzinfo=None) + for name, now in {"aware": aware_dt, "naive": naive_dt}.items(): + with self.subTest(dt=name): + self.assertEqual( + locale.format_date( + now - datetime.timedelta(seconds=2), full_format=False + ), + "2 seconds ago", + ) + self.assertEqual( + locale.format_date( + now - datetime.timedelta(minutes=2), full_format=False + ), + "2 minutes ago", + ) + self.assertEqual( + locale.format_date( + now - datetime.timedelta(hours=2), full_format=False + ), + "2 hours ago", + ) + + self.assertEqual( + locale.format_date( + now - datetime.timedelta(days=1), + full_format=False, + shorter=True, + ), + "yesterday", + ) + + date = now - datetime.timedelta(days=2) + self.assertEqual( + locale.format_date(date, full_format=False, shorter=True), + locale._weekdays[date.weekday()], + ) + + date = now - datetime.timedelta(days=300) + self.assertEqual( + locale.format_date(date, full_format=False, shorter=True), + "%s %d" % (locale._months[date.month - 1], date.day), + ) + + date = now - datetime.timedelta(days=500) + self.assertEqual( + locale.format_date(date, full_format=False, shorter=True), + "%s %d, %d" % (locale._months[date.month - 1], date.day, date.year), + ) def test_friendly_number(self): locale = tornado.locale.get("en_US") diff --git a/tornado/test/log_test.py b/tornado/test/log_test.py index 8450e50b90..fec4c389e2 100644 --- a/tornado/test/log_test.py +++ b/tornado/test/log_test.py @@ -49,8 +49,8 @@ def setUp(self): # variable when the tests are run, so just patch in some values # for testing. (testing with color off fails to expose some potential # encoding issues from the control characters) - self.formatter._colors = {logging.ERROR: u"\u0001"} - self.formatter._normal = u"\u0002" + self.formatter._colors = {logging.ERROR: "\u0001"} + self.formatter._normal = "\u0002" # construct a Logger directly to bypass getLogger's caching self.logger = logging.Logger("LogFormatterTest") self.logger.propagate = False @@ -66,11 +66,7 @@ def tearDown(self): os.rmdir(self.tempdir) def make_handler(self, filename): - # Base case: default setup without explicit encoding. - # In python 2, supports arbitrary byte strings and unicode objects - # that contain only ascii. In python 3, supports ascii-only unicode - # strings (but byte strings will be repr'd automatically). - return logging.FileHandler(filename) + return logging.FileHandler(filename, encoding="utf-8") def get_output(self): with open(self.filename, "rb") as f: @@ -93,16 +89,16 @@ def test_bytes_logging(self): def test_utf8_logging(self): with ignore_bytes_warning(): - self.logger.error(u"\u00e9".encode("utf8")) + self.logger.error("\u00e9".encode("utf8")) if issubclass(bytes, basestring_type): # on python 2, utf8 byte strings (and by extension ascii byte # strings) are passed through as-is. - self.assertEqual(self.get_output(), utf8(u"\u00e9")) + self.assertEqual(self.get_output(), utf8("\u00e9")) else: # on python 3, byte strings always get repr'd even if # they're ascii-only, so this degenerates into another # copy of test_bytes_logging. - self.assertEqual(self.get_output(), utf8(repr(utf8(u"\u00e9")))) + self.assertEqual(self.get_output(), utf8(repr(utf8("\u00e9")))) def test_bytes_exception_logging(self): try: @@ -112,21 +108,13 @@ def test_bytes_exception_logging(self): # This will be "Exception: \xe9" on python 2 or # "Exception: b'\xe9'" on python 3. output = self.get_output() - self.assertRegexpMatches(output, br"Exception.*\\xe9") + self.assertRegex(output, rb"Exception.*\\xe9") # The traceback contains newlines, which should not have been escaped. - self.assertNotIn(br"\n", output) - - -class UnicodeLogFormatterTest(LogFormatterTest): - def make_handler(self, filename): - # Adding an explicit encoding configuration allows non-ascii unicode - # strings in both python 2 and 3, without changing the behavior - # for byte strings. - return logging.FileHandler(filename, encoding="utf8") + self.assertNotIn(rb"\n", output) def test_unicode_logging(self): - self.logger.error(u"\u00e9") - self.assertEqual(self.get_output(), utf8(u"\u00e9")) + self.logger.error("\u00e9") + self.assertEqual(self.get_output(), utf8("\u00e9")) class EnablePrettyLoggingTest(unittest.TestCase): @@ -147,8 +135,8 @@ def test_log_file(self): self.logger.handlers[0].flush() filenames = glob.glob(tmpdir + "/test_log*") self.assertEqual(1, len(filenames)) - with open(filenames[0]) as f: - self.assertRegexpMatches(f.read(), r"^\[E [^]]*\] hello$") + with open(filenames[0], encoding="utf-8") as f: + self.assertRegex(f.read(), r"^\[E [^]]*\] hello$") finally: for handler in self.logger.handlers: handler.flush() @@ -167,8 +155,8 @@ def test_log_file_with_timed_rotating(self): self.logger.handlers[0].flush() filenames = glob.glob(tmpdir + "/test_log*") self.assertEqual(1, len(filenames)) - with open(filenames[0]) as f: - self.assertRegexpMatches(f.read(), r"^\[E [^]]*\] hello$") + with open(filenames[0], encoding="utf-8") as f: + self.assertRegex(f.read(), r"^\[E [^]]*\] hello$") finally: for handler in self.logger.handlers: handler.flush() diff --git a/tornado/test/netutil_test.py b/tornado/test/netutil_test.py index f36b7c271b..b35b7947f1 100644 --- a/tornado/test/netutil_test.py +++ b/tornado/test/netutil_test.py @@ -44,7 +44,14 @@ class _ResolverTestMixin(object): @gen_test def test_localhost(self: typing.Any): addrinfo = yield self.resolver.resolve("localhost", 80, socket.AF_UNSPEC) - self.assertIn((socket.AF_INET, ("127.0.0.1", 80)), addrinfo) + # Most of the time localhost resolves to either the ipv4 loopback + # address alone, or ipv4+ipv6. But some versions of pycares will only + # return the ipv6 version, so we have to check for either one alone. + self.assertTrue( + ((socket.AF_INET, ("127.0.0.1", 80)) in addrinfo) + or ((socket.AF_INET6, ("::1", 80)) in addrinfo), + f"loopback address not found in {addrinfo}", + ) # It is impossible to quickly and consistently generate an error in name @@ -204,6 +211,7 @@ def test_is_valid_ip(self): self.assertTrue(not is_valid_ip(" ")) self.assertTrue(not is_valid_ip("\n")) self.assertTrue(not is_valid_ip("\x00")) + self.assertTrue(not is_valid_ip("a" * 100)) class TestPortAllocation(unittest.TestCase): diff --git a/tornado/test/options_test.py b/tornado/test/options_test.py index 6aedbec32f..6f4021c683 100644 --- a/tornado/test/options_test.py +++ b/tornado/test/options_test.py @@ -204,6 +204,7 @@ def _define_options(self): options.define("timedelta", type=datetime.timedelta) options.define("email", type=Email) options.define("list-of-int", type=int, multiple=True) + options.define("list-of-str", type=str, multiple=True) return options def _check_options_values(self, options): @@ -216,6 +217,7 @@ def _check_options_values(self, options): self.assertEqual(options.email.value, "tornado@web.com") self.assertTrue(isinstance(options.email, Email)) self.assertEqual(options.list_of_int, [1, 2, 3]) + self.assertEqual(options.list_of_str, ["a", "b", "c"]) def test_types(self): options = self._define_options() @@ -230,6 +232,7 @@ def test_types(self): "--timedelta=45s", "--email=tornado@web.com", "--list-of-int=1,2,3", + "--list-of-str=a,b,c", ] ) self._check_options_values(options) @@ -262,7 +265,7 @@ def test_error_redefine(self): options.define("foo") with self.assertRaises(Error) as cm: options.define("foo") - self.assertRegexpMatches(str(cm.exception), "Option.*foo.*already defined") + self.assertRegex(str(cm.exception), "Option.*foo.*already defined") def test_error_redefine_underscore(self): # Ensure that the dash/underscore normalization doesn't @@ -279,9 +282,7 @@ def test_error_redefine_underscore(self): options.define(a) with self.assertRaises(Error) as cm: options.define(b) - self.assertRegexpMatches( - str(cm.exception), "Option.*foo.bar.*already defined" - ) + self.assertRegex(str(cm.exception), "Option.*foo.bar.*already defined") def test_dash_underscore_cli(self): # Dashes and underscores should be interchangeable. diff --git a/tornado/test/options_test_types.cfg b/tornado/test/options_test_types.cfg index e1d53cb9cc..9dfd92205e 100644 --- a/tornado/test/options_test_types.cfg +++ b/tornado/test/options_test_types.cfg @@ -9,3 +9,4 @@ datetime = datetime(2013, 4, 28, 5, 16) timedelta = timedelta(0, 45) email = Email('tornado@web.com') list_of_int = [1, 2, 3] +list_of_str = ["a", "b", "c"] diff --git a/tornado/test/options_test_types_str.cfg b/tornado/test/options_test_types_str.cfg index 25dfbc2bf5..b07d642891 100644 --- a/tornado/test/options_test_types_str.cfg +++ b/tornado/test/options_test_types_str.cfg @@ -6,3 +6,4 @@ datetime = '2013-04-28 05:16' timedelta = '45s' email = 'tornado@web.com' list_of_int = '1,2,3' +list_of_str = 'a,b,c' diff --git a/tornado/test/process_test.py b/tornado/test/process_test.py index 6ff8efde7b..ab290085b3 100644 --- a/tornado/test/process_test.py +++ b/tornado/test/process_test.py @@ -76,15 +76,15 @@ def get_url(path): sock.close() return try: - if asyncio is not None: - # Reset the global asyncio event loop, which was put into - # a broken state by the fork. - asyncio.set_event_loop(asyncio.new_event_loop()) if id in (0, 1): self.assertEqual(id, task_id()) - server = HTTPServer(self.get_app()) - server.add_sockets([sock]) - IOLoop.current().start() + + async def f(): + server = HTTPServer(self.get_app()) + server.add_sockets([sock]) + await asyncio.Event().wait() + + asyncio.run(f()) elif id == 2: self.assertEqual(id, task_id()) sock.close() diff --git a/tornado/test/resolve_test_helper.py b/tornado/test/resolve_test_helper.py index 491737ff29..b720a41140 100644 --- a/tornado/test/resolve_test_helper.py +++ b/tornado/test/resolve_test_helper.py @@ -7,4 +7,4 @@ # this deadlock. resolver = ThreadedResolver() -IOLoop.current().run_sync(lambda: resolver.resolve(u"localhost", 80)) +IOLoop.current().run_sync(lambda: resolver.resolve("localhost", 80)) diff --git a/tornado/test/runtests.py b/tornado/test/runtests.py index 6075b1e2bd..f35b372545 100644 --- a/tornado/test/runtests.py +++ b/tornado/test/runtests.py @@ -22,6 +22,7 @@ "tornado.test.asyncio_test", "tornado.test.auth_test", "tornado.test.autoreload_test", + "tornado.test.circlerefs_test", "tornado.test.concurrent_test", "tornado.test.curl_httpclient_test", "tornado.test.escape_test", @@ -128,37 +129,6 @@ def main(): warnings.filterwarnings( "error", category=PendingDeprecationWarning, module=r"tornado\..*" ) - # The unittest module is aggressive about deprecating redundant methods, - # leaving some without non-deprecated spellings that work on both - # 2.7 and 3.2 - warnings.filterwarnings( - "ignore", category=DeprecationWarning, message="Please use assert.* instead" - ) - warnings.filterwarnings( - "ignore", - category=PendingDeprecationWarning, - message="Please use assert.* instead", - ) - # Twisted 15.0.0 triggers some warnings on py3 with -bb. - warnings.filterwarnings("ignore", category=BytesWarning, module=r"twisted\..*") - if (3,) < sys.version_info < (3, 6): - # Prior to 3.6, async ResourceWarnings were rather noisy - # and even - # `python3.4 -W error -c 'import asyncio; asyncio.get_event_loop()'` - # would generate a warning. - warnings.filterwarnings( - "ignore", category=ResourceWarning, module=r"asyncio\..*" - ) - # This deprecation warning is introduced in Python 3.8 and is - # triggered by pycurl. Unforunately, because it is raised in the C - # layer it can't be filtered by module and we must match the - # message text instead (Tornado's C module uses PY_SSIZE_T_CLEAN - # so it's not at risk of running into this issue). - warnings.filterwarnings( - "ignore", - category=DeprecationWarning, - message="PY_SSIZE_T_CLEAN will be required", - ) logging.getLogger("tornado.access").setLevel(logging.CRITICAL) diff --git a/tornado/test/simple_httpclient_test.py b/tornado/test/simple_httpclient_test.py index eadd4ed303..593f81fd34 100644 --- a/tornado/test/simple_httpclient_test.py +++ b/tornado/test/simple_httpclient_test.py @@ -537,11 +537,14 @@ def create_client(self, **kwargs): ) def test_ssl_options(self): - resp = self.fetch("/hello", ssl_options={}) + resp = self.fetch("/hello", ssl_options={"cert_reqs": ssl.CERT_NONE}) self.assertEqual(resp.body, b"Hello world!") def test_ssl_context(self): - resp = self.fetch("/hello", ssl_options=ssl.SSLContext(ssl.PROTOCOL_SSLv23)) + ssl_ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) + ssl_ctx.check_hostname = False + ssl_ctx.verify_mode = ssl.CERT_NONE + resp = self.fetch("/hello", ssl_options=ssl_ctx) self.assertEqual(resp.body, b"Hello world!") def test_ssl_options_handshake_fail(self): @@ -555,8 +558,8 @@ def test_ssl_options_handshake_fail(self): def test_ssl_context_handshake_fail(self): with ExpectLog(gen_log, "SSL Error|Uncaught exception"): - ctx = ssl.SSLContext(ssl.PROTOCOL_SSLv23) - ctx.verify_mode = ssl.CERT_REQUIRED + # CERT_REQUIRED is set by default. + ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) with self.assertRaises(ssl.SSLError): self.fetch("/hello", ssl_options=ctx, raise_error=True) @@ -825,7 +828,7 @@ def test_chunked_with_content_length(self): with ExpectLog( gen_log, ( - "Malformed HTTP message from None: Response " + "Malformed HTTP message from None: Message " "with both Transfer-Encoding and Content-Length" ), level=logging.INFO, diff --git a/tornado/test/tcpclient_test.py b/tornado/test/tcpclient_test.py index 75f48186ec..ecfc82d6fd 100644 --- a/tornado/test/tcpclient_test.py +++ b/tornado/test/tcpclient_test.py @@ -91,7 +91,11 @@ def skipIfLocalhostV4(self): def do_test_connect(self, family, host, source_ip=None, source_port=None): port = self.start_server(family) stream = yield self.client.connect( - host, port, source_ip=source_ip, source_port=source_port + host, + port, + source_ip=source_ip, + source_port=source_port, + af=family, ) assert self.server is not None server_stream = yield self.server.queue.get() @@ -137,8 +141,7 @@ def test_refused_ipv4(self): yield self.client.connect("127.0.0.1", port) def test_source_ip_fail(self): - """Fail when trying to use the source IP Address '8.8.8.8'. - """ + """Fail when trying to use the source IP Address '8.8.8.8'.""" self.assertRaises( socket.error, self.do_test_connect, @@ -148,14 +151,12 @@ def test_source_ip_fail(self): ) def test_source_ip_success(self): - """Success when trying to use the source IP Address '127.0.0.1'. - """ + """Success when trying to use the source IP Address '127.0.0.1'.""" self.do_test_connect(socket.AF_INET, "127.0.0.1", source_ip="127.0.0.1") @skipIfNonUnix def test_source_port_fail(self): - """Fail when trying to use source port 1. - """ + """Fail when trying to use source port 1.""" if getpass.getuser() == "root": # Root can use any port so we can't easily force this to fail. # This is mainly relevant for docker. diff --git a/tornado/test/tcpserver_test.py b/tornado/test/tcpserver_test.py index 7c75acf6bc..c636c8586f 100644 --- a/tornado/test/tcpserver_test.py +++ b/tornado/test/tcpserver_test.py @@ -4,7 +4,6 @@ import textwrap import unittest -from tornado.escape import utf8, to_unicode from tornado import gen from tornado.iostream import IOStream from tornado.log import app_log @@ -12,6 +11,8 @@ from tornado.test.util import skipIfNonUnix from tornado.testing import AsyncTestCase, ExpectLog, bind_unused_port, gen_test +from typing import Tuple + class TCPServerTest(AsyncTestCase): @gen_test @@ -122,45 +123,52 @@ class TestMultiprocess(unittest.TestCase): # processes, each of which prints its task id to stdout (a single # byte, so we don't have to worry about atomicity of the shared # stdout stream) and then exits. - def run_subproc(self, code): - proc = subprocess.Popen( - sys.executable, stdin=subprocess.PIPE, stdout=subprocess.PIPE - ) - proc.stdin.write(utf8(code)) - proc.stdin.close() - proc.wait() - stdout = proc.stdout.read() - proc.stdout.close() - if proc.returncode != 0: - raise RuntimeError( - "Process returned %d. stdout=%r" % (proc.returncode, stdout) + def run_subproc(self, code: str) -> Tuple[str, str]: + try: + result = subprocess.run( + [sys.executable, "-Werror::DeprecationWarning"], + capture_output=True, + input=code, + encoding="utf8", + check=True, ) - return to_unicode(stdout) + except subprocess.CalledProcessError as e: + raise RuntimeError( + f"Process returned {e.returncode} stdout={e.stdout} stderr={e.stderr}" + ) from e + return result.stdout, result.stderr - def test_single(self): + def test_listen_single(self): # As a sanity check, run the single-process version through this test # harness too. code = textwrap.dedent( """ - from tornado.ioloop import IOLoop + import asyncio from tornado.tcpserver import TCPServer - server = TCPServer() - server.listen(0, address='127.0.0.1') - IOLoop.current().run_sync(lambda: None) + async def main(): + server = TCPServer() + server.listen(0, address='127.0.0.1') + + asyncio.run(main()) print('012', end='') """ ) - out = self.run_subproc(code) + out, err = self.run_subproc(code) self.assertEqual("".join(sorted(out)), "012") + self.assertEqual(err, "") - def test_simple(self): + def test_bind_start(self): code = textwrap.dedent( """ + import warnings + from tornado.ioloop import IOLoop from tornado.process import task_id from tornado.tcpserver import TCPServer + warnings.simplefilter("ignore", DeprecationWarning) + server = TCPServer() server.bind(0, address='127.0.0.1') server.start(3) @@ -168,13 +176,14 @@ def test_simple(self): print(task_id(), end='') """ ) - out = self.run_subproc(code) + out, err = self.run_subproc(code) self.assertEqual("".join(sorted(out)), "012") + self.assertEqual(err, "") - def test_advanced(self): + def test_add_sockets(self): code = textwrap.dedent( """ - from tornado.ioloop import IOLoop + import asyncio from tornado.netutil import bind_sockets from tornado.process import fork_processes, task_id from tornado.ioloop import IOLoop @@ -182,11 +191,40 @@ def test_advanced(self): sockets = bind_sockets(0, address='127.0.0.1') fork_processes(3) - server = TCPServer() - server.add_sockets(sockets) - IOLoop.current().run_sync(lambda: None) + async def post_fork_main(): + server = TCPServer() + server.add_sockets(sockets) + asyncio.run(post_fork_main()) print(task_id(), end='') """ ) - out = self.run_subproc(code) + out, err = self.run_subproc(code) + self.assertEqual("".join(sorted(out)), "012") + self.assertEqual(err, "") + + def test_listen_multi_reuse_port(self): + code = textwrap.dedent( + """ + import asyncio + import socket + from tornado.netutil import bind_sockets + from tornado.process import task_id, fork_processes + from tornado.tcpserver import TCPServer + + # Pick an unused port which we will be able to bind to multiple times. + (sock,) = bind_sockets(0, address='127.0.0.1', + family=socket.AF_INET, reuse_port=True) + port = sock.getsockname()[1] + + fork_processes(3) + + async def main(): + server = TCPServer() + server.listen(port, address='127.0.0.1', reuse_port=True) + asyncio.run(main()) + print(task_id(), end='') + """ + ) + out, err = self.run_subproc(code) self.assertEqual("".join(sorted(out)), "012") + self.assertEqual(err, "") diff --git a/tornado/test/template_test.py b/tornado/test/template_test.py index f71f03785f..801de50bc1 100644 --- a/tornado/test/template_test.py +++ b/tornado/test/template_test.py @@ -78,16 +78,16 @@ def test_escaping(self): ) def test_unicode_template(self): - template = Template(utf8(u"\u00e9")) - self.assertEqual(template.generate(), utf8(u"\u00e9")) + template = Template(utf8("\u00e9")) + self.assertEqual(template.generate(), utf8("\u00e9")) def test_unicode_literal_expression(self): # Unicode literals should be usable in templates. Note that this # test simulates unicode characters appearing directly in the # template file (with utf8 encoding), i.e. \u escapes would not # be used in the template file itself. - template = Template(utf8(u'{{ "\u00e9" }}')) - self.assertEqual(template.generate(), utf8(u"\u00e9")) + template = Template(utf8('{{ "\u00e9" }}')) + self.assertEqual(template.generate(), utf8("\u00e9")) def test_custom_namespace(self): loader = DictLoader( @@ -106,15 +106,15 @@ def test_unicode_apply(self): def upper(s): return to_unicode(s).upper() - template = Template(utf8(u"{% apply upper %}foo \u00e9{% end %}")) - self.assertEqual(template.generate(upper=upper), utf8(u"FOO \u00c9")) + template = Template(utf8("{% apply upper %}foo \u00e9{% end %}")) + self.assertEqual(template.generate(upper=upper), utf8("FOO \u00c9")) def test_bytes_apply(self): def upper(s): return utf8(to_unicode(s).upper()) - template = Template(utf8(u"{% apply upper %}foo \u00e9{% end %}")) - self.assertEqual(template.generate(upper=upper), utf8(u"FOO \u00c9")) + template = Template(utf8("{% apply upper %}foo \u00e9{% end %}")) + self.assertEqual(template.generate(upper=upper), utf8("FOO \u00c9")) def test_if(self): template = Template(utf8("{% if x > 4 %}yes{% else %}no{% end %}")) @@ -194,8 +194,8 @@ def test_no_inherit_future(self): self.assertEqual(template.generate(), "0") def test_non_ascii_name(self): - loader = DictLoader({u"t\u00e9st.html": "hello"}) - self.assertEqual(loader.load(u"t\u00e9st.html").generate(), b"hello") + loader = DictLoader({"t\u00e9st.html": "hello"}) + self.assertEqual(loader.load("t\u00e9st.html").generate(), b"hello") class StackTraceTest(unittest.TestCase): @@ -533,4 +533,4 @@ def setUp(self): def test_utf8_in_file(self): tmpl = self.loader.load("utf8.html") result = tmpl.generate() - self.assertEqual(to_unicode(result).strip(), u"H\u00e9llo") + self.assertEqual(to_unicode(result).strip(), "H\u00e9llo") diff --git a/tornado/test/test.crt b/tornado/test/test.crt index ffc49b06c1..c7f19e3e62 100644 --- a/tornado/test/test.crt +++ b/tornado/test/test.crt @@ -1,20 +1,18 @@ -----BEGIN CERTIFICATE----- -MIIDWzCCAkOgAwIBAgIUV4spou0CenmvKqa7Hml/MC+JKiAwDQYJKoZIhvcNAQEL -BQAwPTELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExGTAXBgNVBAoM -EFRvcm5hZG8gV2ViIFRlc3QwHhcNMTgwOTI5MTM1NjQ1WhcNMjgwOTI2MTM1NjQ1 -WjA9MQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEZMBcGA1UECgwQ -VG9ybmFkbyBXZWIgVGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB -AKT0LdyI8tW5uwP3ahE8BFSz+j3SsKBDv/0cKvqxVVE6sLEST2s3HjArZvIIG5sb -iBkWDrqnZ6UKDvB4jlobLGAkepxDbrxHWxK53n0C28XXGLqJQ01TlTZ5rpjttMeg -5SKNjHbxpOvpUwwQS4br4WjZKKyTGiXpFkFUty+tYVU35/U2yyvreWHmzpHx/25t -H7O2RBARVwJYKOGPtlH62lQjpIWfVfklY4Ip8Hjl3B6rBxPyBULmVQw0qgoZn648 -oa4oLjs0wnYBz01gVjNMDHej52SsB/ieH7W1TxFMzqOlcvHh41uFbQJPgcXsruSS -9Z4twzSWkUp2vk/C//4Sz38CAwEAAaNTMFEwHQYDVR0OBBYEFLf8fQ5+u8sDWAd3 -r5ZjZ5MmDWJeMB8GA1UdIwQYMBaAFLf8fQ5+u8sDWAd3r5ZjZ5MmDWJeMA8GA1Ud -EwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBADkkm3pIb9IeqVNmQ2uhQOgw -UwyToTYUHNTb/Nm5lzBTBqC8gbXAS24RQ30AB/7G115Uxeo+YMKfITxm/CgR+vhF -F59/YrzwXj+G8bdbuVl/UbB6f9RSp+Zo93rUZAtPWr77gxLUrcwSRzzDwxFjC2nC -6eigbkvt1OQY775RwnFAt7HKPclE0Out+cGJIboJuO1f3r57ZdyFH0GzbZEff/7K -atGXohijWJjYvU4mk0KFHORZrcBpsv9cfkFbmgVmiRwxRJ1tLauHM3Ne+VfqYE5M -4rTStSyz3ASqVKJ2iFMQueNR/tUOuDlfRt+0nhJMuYSSkW+KTgnwyOGU9cv+mxA= +MIIC1TCCAb2gAwIBAgIJAOV36k+idrqDMA0GCSqGSIb3DQEBCwUAMBoxGDAWBgNV +BAMMD2Zvby5leGFtcGxlLmNvbTAeFw0yMzExMTIwMTQ3MzhaFw0zMzExMDkwMTQ3 +MzhaMBoxGDAWBgNVBAMMD2Zvby5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEB +BQADggEPADCCAQoCggEBAKjfAL8hQ1G5yoR29D0NwqhL3EE9RShYLvzKvSNhOceR +e390XJLAi8PN8Xv8LkmoMITaLdRDtBwXcdw+kfHjcfXZ0cORJkxJFdk/38SsiBKV +ZzMO+1PVULfnQa92tHtahNsTGI5367WEALn9UNJLmP+jpX+3zohatUTbhlnRSruH +O/Mo5mLs1XJhQpdvp8BQNksJhiTQ7FsbcjGq6gZ75SnbfUR0PyohY0LTsrql00Tz +hCAEvm2TNiQ5s+PT5fFOg6Jh2ZGj1lYLQY3dDeqt9sdabvj7LANqfygbt2cf9yYn +a25UTRcAN7CNdWwTEfvnOVMITzCE8F2FmKDvJR+TX30CAwEAAaMeMBwwGgYDVR0R +BBMwEYIPZm9vLmV4YW1wbGUuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQBjKz4gM4Bz +JO7Ny1fwbBtraHCGYnDG8gBID3+/sQlMMFeuquJK+oc+1DOpr9wFlmgih67OszdM +X2Xl/HjtHPKwNqaDHXu5bQPFT5fXzAZ8HHEOXSV9IpHaNyS7TC7bYmD/ClCZeqXU +h7MBe5yPXfCCIqWyjZMZDQfT1v6J+WX3+lO9josMJCfNR5DzvJiPmSTUxrLD5SkT ++7iKxhM6eI83D+I188sGc2IMinkFp8jSRTlaH8WYiOd5QQ2r8GSYNM9M3z1sK7zv +0Bw3hWEQgpFbEaSH0OB72KYkMUZBqK9UoeSZWBrMXHFBNaY23tEKInEwlBGBELGc +acSinK6OBC0z -----END CERTIFICATE----- diff --git a/tornado/test/test.key b/tornado/test/test.key index 7cb7d8d23c..8eea05db5f 100644 --- a/tornado/test/test.key +++ b/tornado/test/test.key @@ -1,28 +1,28 @@ -----BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCk9C3ciPLVubsD -92oRPARUs/o90rCgQ7/9HCr6sVVROrCxEk9rNx4wK2byCBubG4gZFg66p2elCg7w -eI5aGyxgJHqcQ268R1sSud59AtvF1xi6iUNNU5U2ea6Y7bTHoOUijYx28aTr6VMM -EEuG6+Fo2Siskxol6RZBVLcvrWFVN+f1Nssr63lh5s6R8f9ubR+ztkQQEVcCWCjh -j7ZR+tpUI6SFn1X5JWOCKfB45dweqwcT8gVC5lUMNKoKGZ+uPKGuKC47NMJ2Ac9N -YFYzTAx3o+dkrAf4nh+1tU8RTM6jpXLx4eNbhW0CT4HF7K7kkvWeLcM0lpFKdr5P -wv/+Es9/AgMBAAECggEABi6AaXtYXloPgB6NgwfUwbfc8OQsalUfpMShd7OdluW0 -KW6eO05de0ClIvzay/1EJGyHMMeFQtIVrT1XWFkcWJ4FWkXMqJGkABenFtg8lDVz -X8o1E3jGZrw4ptKBq9mDvL/BO9PiclTUH+ecbPn6AIvi0lTQ7grGIryiAM9mjmLy -jpCwoutF2LD4RPNg8vqWe/Z1rQw5lp8FOHhRwPooHHeoq1bSrp8dqvVAwAam7Mmf -uFgI8jrNycPgr2cwEEtbq2TQ625MhVnCpwT+kErmAStfbXXuqv1X1ZZgiNxf+61C -OL0bhPRVIHmmjiK/5qHRuN4Q5u9/Yp2SJ4W5xadSQQKBgQDR7dnOlYYQiaoPJeD/ -7jcLVJbWwbr7bE19O/QpYAtkA/FtGlKr+hQxPhK6OYp+in8eHf+ga/NSAjCWRBoh -MNAVCJtiirHo2tFsLFOmlJpGL9n3sX8UnkJN90oHfWrzJ8BZnXaSw2eOuyw8LLj+ -Q+ISl6Go8/xfsuy3EDv4AP1wCwKBgQDJJ4vEV3Kr+bc6N/xeu+G0oHvRAWwuQpcx -9D+XpnqbJbFDnWKNE7oGsDCs8Qjr0CdFUN1pm1ppITDZ5N1cWuDg/47ZAXqEK6D1 -z13S7O0oQPlnsPL7mHs2Vl73muAaBPAojFvceHHfccr7Z94BXqKsiyfaWz6kclT/ -Nl4JTdsC3QKBgQCeYgozL2J/da2lUhnIXcyPstk+29kbueFYu/QBh2HwqnzqqLJ4 -5+t2H3P3plQUFp/DdDSZrvhcBiTsKiNgqThEtkKtfSCvIvBf4a2W/4TJsW6MzxCm -2KQDuK/UqM4Y+APKWN/N6Lln2VWNbNyBkWuuRVKFatccyJyJnSjxeqW7cwKBgGyN -idCYPIrwROAHLItXKvOWE5t0ABRq3TsZC2RkdA/b5HCPs4pclexcEriRjvXrK/Yt -MH94Ve8b+UftSUQ4ytjBMS6MrLg87y0YDhLwxv8NKUq65DXAUOW+8JsAmmWQOqY3 -MK+m1BT4TMklgVoN3w3sPsKIsSJ/jLz5cv/kYweFAoGAG4iWU1378tI2Ts/Fngsv -7eoWhoda77Y9D0Yoy20aN9VdMHzIYCBOubtRPEuwgaReNwbUBWap01J63yY/fF3K -8PTz6covjoOJqxQJOvM7nM0CsJawG9ccw3YXyd9KgRIdSt6ooEhb7N8W2EXYoKl3 -g1i2t41Q/SC3HUGC5mJjpO8= +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCo3wC/IUNRucqE +dvQ9DcKoS9xBPUUoWC78yr0jYTnHkXt/dFySwIvDzfF7/C5JqDCE2i3UQ7QcF3Hc +PpHx43H12dHDkSZMSRXZP9/ErIgSlWczDvtT1VC350GvdrR7WoTbExiOd+u1hAC5 +/VDSS5j/o6V/t86IWrVE24ZZ0Uq7hzvzKOZi7NVyYUKXb6fAUDZLCYYk0OxbG3Ix +quoGe+Up231EdD8qIWNC07K6pdNE84QgBL5tkzYkObPj0+XxToOiYdmRo9ZWC0GN +3Q3qrfbHWm74+ywDan8oG7dnH/cmJ2tuVE0XADewjXVsExH75zlTCE8whPBdhZig +7yUfk199AgMBAAECggEBAIGFmXL/Nj0GvVfgTPBPD5A5rxOyxMpu6IsnjO4H8mMp +KInXW/GLESf7W053W6FPCPe8yA3YZ9pr+P6uVw4qHwwsJwFS4Qb9v25D2YNluXBX +ezHkOcxQ/novO2gzKba69M961Ajh3b35Iv2EV2sUZKMehx9wgU6AFCxeG6vkJOez +UCX0WG467cdo4alfe/oQZLioU3t+GGCb23m13B9xaN2tqONNh2E2yp73MVJ1Q74R +HVBkQxciHd3iJee5/4AGUJl9TLv8wAT1cf3OhcGlvOlcfSYtuNUY32TPWit1Or1y +i9fPkjo8SBw52TN5RRmjIlpNMxeK+G4+XtO1Y47TlZkCgYEA3Y+NK8mz9kTnorkf +R7CSOAaiste8c3CJbbbaTk7oTxK4MISL1RX+sFK65cnV54Wo75hCZxsjV2oEQ+4r +UOGw1JxcV16V6iP/AaQS41xsxZca/xnC//YojBN6kP+OV+/ByF4HNs5eDN6uHg0y +OOfBWi6oc449CFFMxVnrQ0SymaMCgYEAwx7M9xQ1eth902Wq2VNszFIgdgptGYhj +XbWsAFYKII+aUXC92sVL5sO33vNyhBbyMN1aSRXYe8B5EnwsP31o5csrHQw6i/Dp +jUx1AUBYkNPgL9ctqlTQf1nb0LenGlCUBD6jrSrJVHeOF4y+HIZHXNZ++otH7+eu +b3dbHgV/9F8CgYBTopO0utAvH3WdDGqNYk7fzUlvX1ao8Qs/mi2wL8MrzjIvRmmO +h137607X3Sfc3KyXvQ8b4relkMSJbAd34aohp+CHrpHCr9HcKbZjkwkQUWkEcRIW +EzLdJaE3yPBPq5an7y6j9qS0EP8DIxIZPwrS4xf9fuz1DdOAD+BqJS2SJwKBgQCt +zZzTpduxbnA+Qrx503cBVVJ28viVmsiwK2hn8DwbHu9eBegHnGDs0H/Th9UE1g+r ++TA4E85/BUaTcapUb5hlwKDJwh/QkaroYyeCEtgRQbnbw3d41w3Vsqw78atWpFoE +oetYD9nAdLJMReD+NZoRlzsKX9CXYS8fORkf19RPTwKBgQCQdvDMicrtnJ4U2MOA +y+59K7V77VRfHLecjAMGblBGmrtoQSBvQiFznXm0DUOSy3eZRITwe01/t+RUDhx9 +MVLlyNzwRCVHzPe7kUI10GM4W5ZKAf8f/t0KjBrQBeYtRUOEI3QVzsVzc1hY8Fv8 +YHOhmI8Tdd2biF+lqXKC6vGlvQ== -----END PRIVATE KEY----- diff --git a/tornado/test/testing_test.py b/tornado/test/testing_test.py index 37cb24602a..4432bb1758 100644 --- a/tornado/test/testing_test.py +++ b/tornado/test/testing_test.py @@ -8,6 +8,7 @@ import gc import os import platform +import sys import traceback import unittest import warnings @@ -106,7 +107,7 @@ def test_fetch_segment(self): def test_fetch_full_http_url(self): # Ensure that self.fetch() recognizes absolute urls and does # not transform them into references to our main test server. - path = "http://localhost:%d/path" % self.second_port + path = "http://127.0.0.1:%d/path" % self.second_port response = self.fetch(path) self.assertEqual(response.request.url, path) @@ -116,7 +117,11 @@ def tearDown(self): super().tearDown() -class AsyncTestCaseWrapperTest(unittest.TestCase): +class AsyncTestCaseReturnAssertionsTest(unittest.TestCase): + # These tests verify that tests that return non-None values (without being decorated with + # @gen_test) raise errors instead of incorrectly succeeding. These tests should be removed or + # updated when the _callTestMethod method is removed from AsyncTestCase (the same checks will + # still happen, but they'll be performed in the stdlib as DeprecationWarnings) def test_undecorated_generator(self): class Test(AsyncTestCase): def test_gen(self): @@ -132,6 +137,12 @@ def test_gen(self): platform.python_implementation() == "PyPy", "pypy destructor warnings cannot be silenced", ) + @unittest.skipIf( + # This check actually exists in 3.11 but it changed in 3.12 in a way that breaks + # this test. + sys.version_info >= (3, 12), + "py312 has its own check for test case returns", + ) def test_undecorated_coroutine(self): class Test(AsyncTestCase): async def test_coro(self): @@ -325,29 +336,5 @@ async def test(self): self.finished = True -class GetNewIOLoopTest(AsyncTestCase): - def get_new_ioloop(self): - # Use the current loop instead of creating a new one here. - return ioloop.IOLoop.current() - - def setUp(self): - # This simulates the effect of an asyncio test harness like - # pytest-asyncio. - self.orig_loop = asyncio.get_event_loop() - self.new_loop = asyncio.new_event_loop() - asyncio.set_event_loop(self.new_loop) - super().setUp() - - def tearDown(self): - super().tearDown() - # AsyncTestCase must not affect the existing asyncio loop. - self.assertFalse(asyncio.get_event_loop().is_closed()) - asyncio.set_event_loop(self.orig_loop) - self.new_loop.close() - - def test_loop(self): - self.assertIs(self.io_loop.asyncio_loop, self.new_loop) # type: ignore - - if __name__ == "__main__": unittest.main() diff --git a/tornado/test/twisted_test.py b/tornado/test/twisted_test.py index 661953d73e..36a541a7a8 100644 --- a/tornado/test/twisted_test.py +++ b/tornado/test/twisted_test.py @@ -13,31 +13,12 @@ # License for the specific language governing permissions and limitations # under the License. -import asyncio -import logging -import signal import unittest -import warnings -from tornado.escape import utf8 -from tornado import gen -from tornado.httpclient import AsyncHTTPClient -from tornado.httpserver import HTTPServer -from tornado.ioloop import IOLoop -from tornado.testing import bind_unused_port, AsyncTestCase, gen_test -from tornado.web import RequestHandler, Application +from tornado.testing import AsyncTestCase, gen_test try: - from twisted.internet.defer import ( # type: ignore - Deferred, - inlineCallbacks, - returnValue, - ) - from twisted.internet.protocol import Protocol # type: ignore - from twisted.internet.asyncioreactor import AsyncioSelectorReactor # type: ignore - from twisted.web.client import Agent, readBody # type: ignore - from twisted.web.resource import Resource # type: ignore - from twisted.web.server import Site # type: ignore + from twisted.internet.defer import inlineCallbacks # type: ignore have_twisted = True except ImportError: @@ -49,173 +30,6 @@ skipIfNoTwisted = unittest.skipUnless(have_twisted, "twisted module not present") -def save_signal_handlers(): - saved = {} - signals = [signal.SIGINT, signal.SIGTERM] - if hasattr(signal, "SIGCHLD"): - signals.append(signal.SIGCHLD) - for sig in signals: - saved[sig] = signal.getsignal(sig) - if "twisted" in repr(saved): - # This indicates we're not cleaning up after ourselves properly. - raise Exception("twisted signal handlers already installed") - return saved - - -def restore_signal_handlers(saved): - for sig, handler in saved.items(): - signal.signal(sig, handler) - - -# Test various combinations of twisted and tornado http servers, -# http clients, and event loop interfaces. - - -@skipIfNoTwisted -class CompatibilityTests(unittest.TestCase): - def setUp(self): - self.saved_signals = save_signal_handlers() - self.saved_policy = asyncio.get_event_loop_policy() - if hasattr(asyncio, "WindowsSelectorEventLoopPolicy"): - # Twisted requires a selector event loop, even if Tornado is - # doing its own tricks in AsyncIOLoop to support proactors. - # Setting an AddThreadSelectorEventLoop exposes various edge - # cases so just use a regular selector. - asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) # type: ignore - self.io_loop = IOLoop() - self.io_loop.make_current() - self.reactor = AsyncioSelectorReactor() - - def tearDown(self): - self.reactor.disconnectAll() - self.io_loop.clear_current() - self.io_loop.close(all_fds=True) - asyncio.set_event_loop_policy(self.saved_policy) - restore_signal_handlers(self.saved_signals) - - def start_twisted_server(self): - class HelloResource(Resource): - isLeaf = True - - def render_GET(self, request): - return b"Hello from twisted!" - - site = Site(HelloResource()) - port = self.reactor.listenTCP(0, site, interface="127.0.0.1") - self.twisted_port = port.getHost().port - - def start_tornado_server(self): - class HelloHandler(RequestHandler): - def get(self): - self.write("Hello from tornado!") - - app = Application([("/", HelloHandler)], log_function=lambda x: None) - server = HTTPServer(app) - sock, self.tornado_port = bind_unused_port() - server.add_sockets([sock]) - - def run_reactor(self): - # In theory, we can run the event loop through Tornado, - # Twisted, or asyncio interfaces. However, since we're trying - # to avoid installing anything as the global event loop, only - # the twisted interface gets everything wired up correectly - # without extra hacks. This method is a part of a - # no-longer-used generalization that allowed us to test - # different combinations. - self.stop_loop = self.reactor.stop - self.stop = self.reactor.stop - self.reactor.run() - - def tornado_fetch(self, url, runner): - client = AsyncHTTPClient() - fut = asyncio.ensure_future(client.fetch(url)) - fut.add_done_callback(lambda f: self.stop_loop()) - runner() - return fut.result() - - def twisted_fetch(self, url, runner): - # http://twistedmatrix.com/documents/current/web/howto/client.html - chunks = [] - client = Agent(self.reactor) - d = client.request(b"GET", utf8(url)) - - class Accumulator(Protocol): - def __init__(self, finished): - self.finished = finished - - def dataReceived(self, data): - chunks.append(data) - - def connectionLost(self, reason): - self.finished.callback(None) - - def callback(response): - finished = Deferred() - response.deliverBody(Accumulator(finished)) - return finished - - d.addCallback(callback) - - def shutdown(failure): - if hasattr(self, "stop_loop"): - self.stop_loop() - elif failure is not None: - # loop hasn't been initialized yet; try our best to - # get an error message out. (the runner() interaction - # should probably be refactored). - try: - failure.raiseException() - except: - logging.error("exception before starting loop", exc_info=True) - - d.addBoth(shutdown) - runner() - self.assertTrue(chunks) - return b"".join(chunks) - - def twisted_coroutine_fetch(self, url, runner): - body = [None] - - @gen.coroutine - def f(): - # This is simpler than the non-coroutine version, but it cheats - # by reading the body in one blob instead of streaming it with - # a Protocol. - client = Agent(self.reactor) - response = yield client.request(b"GET", utf8(url)) - with warnings.catch_warnings(): - # readBody has a buggy DeprecationWarning in Twisted 15.0: - # https://twistedmatrix.com/trac/changeset/43379 - warnings.simplefilter("ignore", category=DeprecationWarning) - body[0] = yield readBody(response) - self.stop_loop() - - self.io_loop.add_callback(f) - runner() - return body[0] - - def testTwistedServerTornadoClientReactor(self): - self.start_twisted_server() - response = self.tornado_fetch( - "http://127.0.0.1:%d" % self.twisted_port, self.run_reactor - ) - self.assertEqual(response.body, b"Hello from twisted!") - - def testTornadoServerTwistedClientReactor(self): - self.start_tornado_server() - response = self.twisted_fetch( - "http://127.0.0.1:%d" % self.tornado_port, self.run_reactor - ) - self.assertEqual(response, b"Hello from tornado!") - - def testTornadoServerTwistedCoroutineClientReactor(self): - self.start_tornado_server() - response = self.twisted_coroutine_fetch( - "http://127.0.0.1:%d" % self.tornado_port, self.run_reactor - ) - self.assertEqual(response, b"Hello from tornado!") - - @skipIfNoTwisted class ConvertDeferredTest(AsyncTestCase): @gen_test @@ -226,7 +40,7 @@ def fn(): # inlineCallbacks doesn't work with regular functions; # must have a yield even if it's unreachable. yield - returnValue(42) + return 42 res = yield fn() self.assertEqual(res, 42) diff --git a/tornado/test/util_test.py b/tornado/test/util_test.py index 0cbc13c60f..02cf0c19bd 100644 --- a/tornado/test/util_test.py +++ b/tornado/test/util_test.py @@ -4,7 +4,7 @@ import datetime import unittest -import tornado.escape +import tornado from tornado.escape import utf8 from tornado.util import ( raise_exc_info, @@ -214,7 +214,7 @@ def test_config_inner_level(self): class UnicodeLiteralTest(unittest.TestCase): def test_unicode_escapes(self): - self.assertEqual(utf8(u"\u00e9"), b"\xc3\xa9") + self.assertEqual(utf8("\u00e9"), b"\xc3\xa9") class ExecInTest(unittest.TestCase): @@ -276,7 +276,7 @@ def test_import_member(self): self.assertIs(import_object("tornado.escape.utf8"), utf8) def test_import_member_unicode(self): - self.assertIs(import_object(u"tornado.escape.utf8"), utf8) + self.assertIs(import_object("tornado.escape.utf8"), utf8) def test_import_module(self): self.assertIs(import_object("tornado.escape"), tornado.escape) @@ -285,7 +285,7 @@ def test_import_module_unicode(self): # The internal implementation of __import__ differs depending on # whether the thing being imported is a module or not. # This variant requires a byte string in python 2. - self.assertIs(import_object(u"tornado.escape"), tornado.escape) + self.assertIs(import_object("tornado.escape"), tornado.escape) class ReUnescapeTest(unittest.TestCase): diff --git a/tornado/test/web_test.py b/tornado/test/web_test.py index 5490ba2414..fec66f39ac 100644 --- a/tornado/test/web_test.py +++ b/tornado/test/web_test.py @@ -17,6 +17,7 @@ from tornado.simple_httpclient import SimpleAsyncHTTPClient from tornado.template import DictLoader from tornado.testing import AsyncHTTPTestCase, AsyncTestCase, ExpectLog, gen_test +from tornado.test.util import ignore_deprecation from tornado.util import ObjectDict, unicode_type from tornado.web import ( Application, @@ -97,7 +98,7 @@ def get(self): class CookieTestRequestHandler(RequestHandler): - # stub out enough methods to make the secure_cookie functions work + # stub out enough methods to make the signed_cookie functions work def __init__(self, cookie_secret="0123456789", key_version=None): # don't call super.__init__ self._cookies = {} # type: typing.Dict[str, bytes] @@ -121,15 +122,15 @@ def set_cookie(self, name, value, expires_days=None): class SecureCookieV1Test(unittest.TestCase): def test_round_trip(self): handler = CookieTestRequestHandler() - handler.set_secure_cookie("foo", b"bar", version=1) - self.assertEqual(handler.get_secure_cookie("foo", min_version=1), b"bar") + handler.set_signed_cookie("foo", b"bar", version=1) + self.assertEqual(handler.get_signed_cookie("foo", min_version=1), b"bar") def test_cookie_tampering_future_timestamp(self): handler = CookieTestRequestHandler() # this string base64-encodes to '12345678' - handler.set_secure_cookie("foo", binascii.a2b_hex(b"d76df8e7aefc"), version=1) + handler.set_signed_cookie("foo", binascii.a2b_hex(b"d76df8e7aefc"), version=1) cookie = handler._cookies["foo"] - match = re.match(br"12345678\|([0-9]+)\|([0-9a-f]+)", cookie) + match = re.match(rb"12345678\|([0-9]+)\|([0-9a-f]+)", cookie) assert match is not None timestamp = match.group(1) sig = match.group(2) @@ -160,14 +161,14 @@ def test_cookie_tampering_future_timestamp(self): ) # it gets rejected with ExpectLog(gen_log, "Cookie timestamp in future"): - self.assertTrue(handler.get_secure_cookie("foo", min_version=1) is None) + self.assertTrue(handler.get_signed_cookie("foo", min_version=1) is None) def test_arbitrary_bytes(self): # Secure cookies accept arbitrary data (which is base64 encoded). # Note that normal cookies accept only a subset of ascii. handler = CookieTestRequestHandler() - handler.set_secure_cookie("foo", b"\xe9", version=1) - self.assertEqual(handler.get_secure_cookie("foo", min_version=1), b"\xe9") + handler.set_signed_cookie("foo", b"\xe9", version=1) + self.assertEqual(handler.get_signed_cookie("foo", min_version=1), b"\xe9") # See SignedValueTest below for more. @@ -176,46 +177,46 @@ class SecureCookieV2Test(unittest.TestCase): def test_round_trip(self): handler = CookieTestRequestHandler() - handler.set_secure_cookie("foo", b"bar", version=2) - self.assertEqual(handler.get_secure_cookie("foo", min_version=2), b"bar") + handler.set_signed_cookie("foo", b"bar", version=2) + self.assertEqual(handler.get_signed_cookie("foo", min_version=2), b"bar") def test_key_version_roundtrip(self): handler = CookieTestRequestHandler( cookie_secret=self.KEY_VERSIONS, key_version=0 ) - handler.set_secure_cookie("foo", b"bar") - self.assertEqual(handler.get_secure_cookie("foo"), b"bar") + handler.set_signed_cookie("foo", b"bar") + self.assertEqual(handler.get_signed_cookie("foo"), b"bar") def test_key_version_roundtrip_differing_version(self): handler = CookieTestRequestHandler( cookie_secret=self.KEY_VERSIONS, key_version=1 ) - handler.set_secure_cookie("foo", b"bar") - self.assertEqual(handler.get_secure_cookie("foo"), b"bar") + handler.set_signed_cookie("foo", b"bar") + self.assertEqual(handler.get_signed_cookie("foo"), b"bar") def test_key_version_increment_version(self): handler = CookieTestRequestHandler( cookie_secret=self.KEY_VERSIONS, key_version=0 ) - handler.set_secure_cookie("foo", b"bar") + handler.set_signed_cookie("foo", b"bar") new_handler = CookieTestRequestHandler( cookie_secret=self.KEY_VERSIONS, key_version=1 ) new_handler._cookies = handler._cookies - self.assertEqual(new_handler.get_secure_cookie("foo"), b"bar") + self.assertEqual(new_handler.get_signed_cookie("foo"), b"bar") def test_key_version_invalidate_version(self): handler = CookieTestRequestHandler( cookie_secret=self.KEY_VERSIONS, key_version=0 ) - handler.set_secure_cookie("foo", b"bar") + handler.set_signed_cookie("foo", b"bar") new_key_versions = self.KEY_VERSIONS.copy() new_key_versions.pop(0) new_handler = CookieTestRequestHandler( cookie_secret=new_key_versions, key_version=1 ) new_handler._cookies = handler._cookies - self.assertEqual(new_handler.get_secure_cookie("foo"), None) + self.assertEqual(new_handler.get_signed_cookie("foo"), None) class FinalReturnTest(WebTestCase): @@ -274,7 +275,7 @@ def get(self): # Try setting cookies with different argument types # to ensure that everything gets encoded correctly self.set_cookie("str", "asdf") - self.set_cookie("unicode", u"qwer") + self.set_cookie("unicode", "qwer") self.set_cookie("bytes", b"zxcv") class GetCookieHandler(RequestHandler): @@ -287,7 +288,7 @@ class SetCookieDomainHandler(RequestHandler): def get(self): # unicode domain and path arguments shouldn't break things # either (see bug #285) - self.set_cookie("unicode_args", "blah", domain=u"foo.com", path=u"/foo") + self.set_cookie("unicode_args", "blah", domain="foo.com", path="/foo") class SetCookieSpecialCharHandler(RequestHandler): def get(self): @@ -318,6 +319,11 @@ def get(self): self.set_cookie("c", "1", httponly=True) self.set_cookie("d", "1", httponly=False) + class SetCookieDeprecatedArgs(RequestHandler): + def get(self): + # Mixed case is supported, but deprecated + self.set_cookie("a", "b", HttpOnly=True, pATH="/foo") + return [ ("/set", SetCookieHandler), ("/get", GetCookieHandler), @@ -327,6 +333,7 @@ def get(self): ("/set_max_age", SetCookieMaxAgeHandler), ("/set_expires_days", SetCookieExpiresDaysHandler), ("/set_falsy_flags", SetCookieFalsyFlags), + ("/set_deprecated", SetCookieDeprecatedArgs), ] def test_set_cookie(self): @@ -397,10 +404,10 @@ def test_set_cookie_expires_days(self): match = re.match("foo=bar; expires=(?P.+); Path=/", header) assert match is not None - expires = datetime.datetime.utcnow() + datetime.timedelta(days=10) - parsed = email.utils.parsedate(match.groupdict()["expires"]) - assert parsed is not None - header_expires = datetime.datetime(*parsed[:6]) + expires = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta( + days=10 + ) + header_expires = email.utils.parsedate_to_datetime(match.groupdict()["expires"]) self.assertTrue(abs((expires - header_expires).total_seconds()) < 10) def test_set_cookie_false_flags(self): @@ -413,6 +420,12 @@ def test_set_cookie_false_flags(self): self.assertEqual(headers[2].lower(), "c=1; httponly; path=/") self.assertEqual(headers[3].lower(), "d=1; path=/") + def test_set_cookie_deprecated(self): + with ignore_deprecation(): + response = self.fetch("/set_deprecated") + header = response.headers.get("Set-Cookie") + self.assertEqual(header, "a=b; HttpOnly; Path=/foo") + class AuthRedirectRequestHandler(RequestHandler): def initialize(self, login_url): @@ -542,9 +555,9 @@ def test_group_encoding(self): self.assertEqual( self.fetch_json("/group/%C3%A9?arg=%C3%A9"), { - u"path": u"/group/%C3%A9", - u"path_args": [u"\u00e9"], - u"args": {u"arg": [u"\u00e9"]}, + "path": "/group/%C3%A9", + "path_args": ["\u00e9"], + "args": {"arg": ["\u00e9"]}, }, ) @@ -585,7 +598,7 @@ def prepare(self): raise Exception( "unexpected values for cookie keys: %r" % self.cookies.keys() ) - self.check_type("get_secure_cookie", self.get_secure_cookie("asdf"), bytes) + self.check_type("get_signed_cookie", self.get_signed_cookie("asdf"), bytes) self.check_type("get_cookie", self.get_cookie("asdf"), str) self.check_type("xsrf_token", self.xsrf_token, bytes) @@ -813,15 +826,13 @@ def test_decode_argument(self): data = json_decode(response.body) self.assertEqual( data, - {u"path": [u"unicode", u"\u00e9"], u"query": [u"unicode", u"\u00e9"]}, + {"path": ["unicode", "\u00e9"], "query": ["unicode", "\u00e9"]}, ) response = self.fetch("/decode_arg/%C3%A9?foo=%C3%A9") response.rethrow() data = json_decode(response.body) - self.assertEqual( - data, {u"path": [u"bytes", u"c3a9"], u"query": [u"bytes", u"c3a9"]} - ) + self.assertEqual(data, {"path": ["bytes", "c3a9"], "query": ["bytes", "c3a9"]}) def test_decode_argument_invalid_unicode(self): # test that invalid unicode in URLs causes 400, not 500 @@ -843,7 +854,7 @@ def test_decode_argument_plus(self): data = json_decode(response.body) self.assertEqual( data, - {u"path": [u"unicode", u"1 + 1"], u"query": [u"unicode", u"1 + 1"]}, + {"path": ["unicode", "1 + 1"], "query": ["unicode", "1 + 1"]}, ) def test_reverse_url(self): @@ -851,7 +862,7 @@ def test_reverse_url(self): self.assertEqual(self.app.reverse_url("decode_arg", 42), "/decode_arg/42") self.assertEqual(self.app.reverse_url("decode_arg", b"\xe9"), "/decode_arg/%E9") self.assertEqual( - self.app.reverse_url("decode_arg", u"\u00e9"), "/decode_arg/%C3%A9" + self.app.reverse_url("decode_arg", "\u00e9"), "/decode_arg/%C3%A9" ) self.assertEqual( self.app.reverse_url("decode_arg", "1 + 1"), "/decode_arg/1%20%2B%201" @@ -892,8 +903,8 @@ def test_uimodule_resources(self): ) def test_optional_path(self): - self.assertEqual(self.fetch_json("/optional_path/foo"), {u"path": u"foo"}) - self.assertEqual(self.fetch_json("/optional_path/"), {u"path": None}) + self.assertEqual(self.fetch_json("/optional_path/foo"), {"path": "foo"}) + self.assertEqual(self.fetch_json("/optional_path/"), {"path": None}) def test_multi_header(self): response = self.fetch("/multi_header") @@ -1117,6 +1128,15 @@ def test_static_files(self): self.assertTrue(b"Disallow: /" in response.body) self.assertEqual(response.headers.get("Content-Type"), "text/plain") + def test_static_files_cacheable(self): + # Test that the version parameter triggers cache-control headers. This + # test is pretty weak but it gives us coverage of the code path which + # was important for detecting the deprecation of datetime.utcnow. + response = self.fetch("/robots.txt?v=12345") + self.assertTrue(b"Disallow: /" in response.body) + self.assertIn("Cache-Control", response.headers) + self.assertIn("Expires", response.headers) + def test_static_compressed_files(self): response = self.fetch("/static/sample.xml.gz") self.assertEqual(response.headers.get("Content-Type"), "application/gzip") @@ -1260,7 +1280,7 @@ def test_static_with_range_full_file(self): # to ``Range: bytes=0-`` :( self.assertEqual(response.code, 200) robots_file_path = os.path.join(self.static_dir, "robots.txt") - with open(robots_file_path) as f: + with open(robots_file_path, encoding="utf-8") as f: self.assertEqual(response.body, utf8(f.read())) self.assertEqual(response.headers.get("Content-Length"), "26") self.assertEqual(response.headers.get("Content-Range"), None) @@ -1271,7 +1291,7 @@ def test_static_with_range_full_past_end(self): ) self.assertEqual(response.code, 200) robots_file_path = os.path.join(self.static_dir, "robots.txt") - with open(robots_file_path) as f: + with open(robots_file_path, encoding="utf-8") as f: self.assertEqual(response.body, utf8(f.read())) self.assertEqual(response.headers.get("Content-Length"), "26") self.assertEqual(response.headers.get("Content-Range"), None) @@ -1282,7 +1302,7 @@ def test_static_with_range_partial_past_end(self): ) self.assertEqual(response.code, 206) robots_file_path = os.path.join(self.static_dir, "robots.txt") - with open(robots_file_path) as f: + with open(robots_file_path, encoding="utf-8") as f: self.assertEqual(response.body, utf8(f.read()[1:])) self.assertEqual(response.headers.get("Content-Length"), "25") self.assertEqual(response.headers.get("Content-Range"), "bytes 1-25/26") @@ -1309,7 +1329,7 @@ def test_static_with_range_neg_past_start(self): ) self.assertEqual(response.code, 200) robots_file_path = os.path.join(self.static_dir, "robots.txt") - with open(robots_file_path) as f: + with open(robots_file_path, encoding="utf-8") as f: self.assertEqual(response.body, utf8(f.read())) self.assertEqual(response.headers.get("Content-Length"), "26") self.assertEqual(response.headers.get("Content-Range"), None) @@ -1426,6 +1446,43 @@ def test_static_default_redirect(self): self.assertTrue(response.headers["Location"].endswith("/static/dir/")) +class StaticDefaultFilenameRootTest(WebTestCase): + def get_app_kwargs(self): + return dict( + static_path=os.path.abspath(relpath("static")), + static_handler_args=dict(default_filename="index.html"), + static_url_prefix="/", + ) + + def get_handlers(self): + return [] + + def get_http_client(self): + # simple_httpclient only: curl doesn't let you send a request starting + # with two slashes. + return SimpleAsyncHTTPClient() + + def test_no_open_redirect(self): + # This test verifies that the open redirect that affected some configurations + # prior to Tornado 6.3.2 is no longer possible. The vulnerability required + # a static_url_prefix of "/" and a default_filename (any value) to be set. + # The absolute* server-side path to the static directory must also be known. + # + # * Almost absolute: On windows, the drive letter is stripped from the path. + test_dir = os.path.dirname(__file__) + drive, tail = os.path.splitdrive(test_dir) + if os.name == "posix": + self.assertEqual(tail, test_dir) + else: + test_dir = tail + with ExpectLog(gen_log, ".*cannot redirect path with two initial slashes"): + response = self.fetch( + f"//evil.com/../{test_dir}/static/dir", + follow_redirects=False, + ) + self.assertEqual(response.code, 403) + + class StaticFileWithPathTest(WebTestCase): def get_app_kwargs(self): return dict( @@ -1592,7 +1649,7 @@ def get(self, path): return [ ("/str/(?P.*)", EchoHandler), - (u"/unicode/(?P.*)", EchoHandler), + ("/unicode/(?P.*)", EchoHandler), ] def test_named_urlspec_groups(self): @@ -1686,11 +1743,10 @@ def get(self): def test_date_header(self): response = self.fetch("/") - parsed = email.utils.parsedate(response.headers["Date"]) - assert parsed is not None - header_date = datetime.datetime(*parsed[:6]) + header_date = email.utils.parsedate_to_datetime(response.headers["Date"]) self.assertTrue( - header_date - datetime.datetime.utcnow() < datetime.timedelta(seconds=2) + header_date - datetime.datetime.now(datetime.timezone.utc) + < datetime.timedelta(seconds=2) ) @@ -2837,7 +2893,7 @@ def test_xsrf_success_header(self): body=b"", headers=dict( {"X-Xsrftoken": self.xsrf_token}, # type: ignore - **self.cookie_headers() + **self.cookie_headers(), ), ) self.assertEqual(response.code, 200) @@ -2921,6 +2977,65 @@ def test_versioning(self): self.assertEqual(response.code, 200) +# A subset of the previous test with a different cookie name +class XSRFCookieNameTest(SimpleHandlerTestCase): + class Handler(RequestHandler): + def get(self): + self.write(self.xsrf_token) + + def post(self): + self.write("ok") + + def get_app_kwargs(self): + return dict( + xsrf_cookies=True, + xsrf_cookie_name="__Host-xsrf", + xsrf_cookie_kwargs={"secure": True}, + ) + + def setUp(self): + super().setUp() + self.xsrf_token = self.get_token() + + def get_token(self, old_token=None): + if old_token is not None: + headers = self.cookie_headers(old_token) + else: + headers = None + response = self.fetch("/", headers=headers) + response.rethrow() + return native_str(response.body) + + def cookie_headers(self, token=None): + if token is None: + token = self.xsrf_token + return {"Cookie": "__Host-xsrf=" + token} + + def test_xsrf_fail_no_token(self): + with ExpectLog(gen_log, ".*'_xsrf' argument missing"): + response = self.fetch("/", method="POST", body=b"") + self.assertEqual(response.code, 403) + + def test_xsrf_fail_body_no_cookie(self): + with ExpectLog(gen_log, ".*XSRF cookie does not match POST"): + response = self.fetch( + "/", + method="POST", + body=urllib.parse.urlencode(dict(_xsrf=self.xsrf_token)), + ) + self.assertEqual(response.code, 403) + + def test_xsrf_success_post_body(self): + response = self.fetch( + "/", + method="POST", + # Note that renaming the cookie doesn't rename the POST param + body=urllib.parse.urlencode(dict(_xsrf=self.xsrf_token)), + headers=self.cookie_headers(), + ) + self.assertEqual(response.code, 200) + + class XSRFCookieKwargsTest(SimpleHandlerTestCase): class Handler(RequestHandler): def get(self): @@ -2940,10 +3055,12 @@ def test_xsrf_httponly(self): match = re.match(".*; expires=(?P.+);.*", header) assert match is not None - expires = datetime.datetime.utcnow() + datetime.timedelta(days=2) - parsed = email.utils.parsedate(match.groupdict()["expires"]) - assert parsed is not None - header_expires = datetime.datetime(*parsed[:6]) + expires = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta( + days=2 + ) + header_expires = email.utils.parsedate_to_datetime(match.groupdict()["expires"]) + if header_expires.tzinfo is None: + header_expires = header_expires.replace(tzinfo=datetime.timezone.utc) self.assertTrue(abs((expires - header_expires).total_seconds()) < 10) @@ -3154,3 +3271,39 @@ def test_redirect_pattern(self): response = self.fetch("/a/b/c", follow_redirects=False) self.assertEqual(response.code, 301) self.assertEqual(response.headers["Location"], "/b/a/c") + + +class AcceptLanguageTest(WebTestCase): + """Test evaluation of Accept-Language header""" + + def get_handlers(self): + locale.load_gettext_translations( + os.path.join(os.path.dirname(__file__), "gettext_translations"), + "tornado_test", + ) + + class AcceptLanguageHandler(RequestHandler): + def get(self): + self.set_header( + "Content-Language", self.get_browser_locale().code.replace("_", "-") + ) + self.finish(b"") + + return [ + ("/", AcceptLanguageHandler), + ] + + def test_accept_language(self): + response = self.fetch("/", headers={"Accept-Language": "fr-FR;q=0.9"}) + self.assertEqual(response.headers["Content-Language"], "fr-FR") + + response = self.fetch("/", headers={"Accept-Language": "fr-FR; q=0.9"}) + self.assertEqual(response.headers["Content-Language"], "fr-FR") + + def test_accept_language_ignore(self): + response = self.fetch("/", headers={"Accept-Language": "fr-FR;q=0"}) + self.assertEqual(response.headers["Content-Language"], "en-US") + + def test_accept_language_invalid(self): + response = self.fetch("/", headers={"Accept-Language": "fr-FR;q=-1"}) + self.assertEqual(response.headers["Content-Language"], "en-US") diff --git a/tornado/test/websocket_test.py b/tornado/test/websocket_test.py index befe06dd39..4d39f37046 100644 --- a/tornado/test/websocket_test.py +++ b/tornado/test/websocket_test.py @@ -1,5 +1,7 @@ import asyncio +import contextlib import functools +import socket import traceback import typing import unittest @@ -9,6 +11,7 @@ from tornado.httpclient import HTTPError, HTTPRequest from tornado.locks import Event from tornado.log import gen_log, app_log +from tornado.netutil import Resolver from tornado.simple_httpclient import SimpleAsyncHTTPClient from tornado.template import DictLoader from tornado.testing import AsyncHTTPTestCase, gen_test, bind_unused_port, ExpectLog @@ -211,11 +214,21 @@ def open(self): class WebSocketBaseTestCase(AsyncHTTPTestCase): + def setUp(self): + super().setUp() + self.conns_to_close = [] + + def tearDown(self): + for conn in self.conns_to_close: + conn.close() + super().tearDown() + @gen.coroutine def ws_connect(self, path, **kwargs): ws = yield websocket_connect( "ws://127.0.0.1:%d%s" % (self.get_http_port(), path), **kwargs ) + self.conns_to_close.append(ws) raise gen.Return(ws) @@ -341,9 +354,16 @@ def test_binary_message(self): @gen_test def test_unicode_message(self): ws = yield self.ws_connect("/echo") - ws.write_message(u"hello \u00e9") + ws.write_message("hello \u00e9") response = yield ws.read_message() - self.assertEqual(response, u"hello \u00e9") + self.assertEqual(response, "hello \u00e9") + + @gen_test + def test_error_in_closed_client_write_message(self): + ws = yield self.ws_connect("/echo") + ws.close() + with self.assertRaises(WebSocketClosedError): + ws.write_message("hello \u00e9") @gen_test def test_render_message(self): @@ -381,46 +401,56 @@ def test_websocket_network_fail(self): sock, port = bind_unused_port() sock.close() with self.assertRaises(IOError): - with ExpectLog(gen_log, ".*"): + with ExpectLog(gen_log, ".*", required=False): yield websocket_connect( "ws://127.0.0.1:%d/" % port, connect_timeout=3600 ) @gen_test def test_websocket_close_buffered_data(self): - ws = yield websocket_connect("ws://127.0.0.1:%d/echo" % self.get_http_port()) - ws.write_message("hello") - ws.write_message("world") - # Close the underlying stream. - ws.stream.close() + with contextlib.closing( + (yield websocket_connect("ws://127.0.0.1:%d/echo" % self.get_http_port())) + ) as ws: + ws.write_message("hello") + ws.write_message("world") + # Close the underlying stream. + ws.stream.close() @gen_test def test_websocket_headers(self): # Ensure that arbitrary headers can be passed through websocket_connect. - ws = yield websocket_connect( - HTTPRequest( - "ws://127.0.0.1:%d/header" % self.get_http_port(), - headers={"X-Test": "hello"}, + with contextlib.closing( + ( + yield websocket_connect( + HTTPRequest( + "ws://127.0.0.1:%d/header" % self.get_http_port(), + headers={"X-Test": "hello"}, + ) + ) ) - ) - response = yield ws.read_message() - self.assertEqual(response, "hello") + ) as ws: + response = yield ws.read_message() + self.assertEqual(response, "hello") @gen_test def test_websocket_header_echo(self): # Ensure that headers can be returned in the response. # Specifically, that arbitrary headers passed through websocket_connect # can be returned. - ws = yield websocket_connect( - HTTPRequest( - "ws://127.0.0.1:%d/header_echo" % self.get_http_port(), - headers={"X-Test-Hello": "hello"}, + with contextlib.closing( + ( + yield websocket_connect( + HTTPRequest( + "ws://127.0.0.1:%d/header_echo" % self.get_http_port(), + headers={"X-Test-Hello": "hello"}, + ) + ) + ) + ) as ws: + self.assertEqual(ws.headers.get("X-Test-Hello"), "hello") + self.assertEqual( + ws.headers.get("X-Extra-Response-Header"), "Extra-Response-Value" ) - ) - self.assertEqual(ws.headers.get("X-Test-Hello"), "hello") - self.assertEqual( - ws.headers.get("X-Extra-Response-Header"), "Extra-Response-Value" - ) @gen_test def test_server_close_reason(self): @@ -486,10 +516,12 @@ def test_check_origin_valid_no_path(self): url = "ws://127.0.0.1:%d/echo" % port headers = {"Origin": "http://127.0.0.1:%d" % port} - ws = yield websocket_connect(HTTPRequest(url, headers=headers)) - ws.write_message("hello") - response = yield ws.read_message() - self.assertEqual(response, "hello") + with contextlib.closing( + (yield websocket_connect(HTTPRequest(url, headers=headers))) + ) as ws: + ws.write_message("hello") + response = yield ws.read_message() + self.assertEqual(response, "hello") @gen_test def test_check_origin_valid_with_path(self): @@ -498,10 +530,12 @@ def test_check_origin_valid_with_path(self): url = "ws://127.0.0.1:%d/echo" % port headers = {"Origin": "http://127.0.0.1:%d/something" % port} - ws = yield websocket_connect(HTTPRequest(url, headers=headers)) - ws.write_message("hello") - response = yield ws.read_message() - self.assertEqual(response, "hello") + with contextlib.closing( + (yield websocket_connect(HTTPRequest(url, headers=headers))) + ) as ws: + ws.write_message("hello") + response = yield ws.read_message() + self.assertEqual(response, "hello") @gen_test def test_check_origin_invalid_partial_url(self): @@ -532,6 +566,15 @@ def test_check_origin_invalid(self): def test_check_origin_invalid_subdomains(self): port = self.get_http_port() + # CaresResolver may return ipv6-only results for localhost, but our + # server is only running on ipv4. Test for this edge case and skip + # the test if it happens. + addrinfo = yield Resolver().resolve("localhost", port) + families = set(addr[0] for addr in addrinfo) + if socket.AF_INET not in families: + self.skipTest("localhost does not resolve to ipv4") + return + url = "ws://localhost:%d/echo" % port # Subdomains should be disallowed by default. If we could pass a # resolver to websocket_connect we could test sibling domains as well. @@ -789,6 +832,7 @@ def test_client_ping(self): response = yield ws.read_message() self.assertEqual(response, "got ping") # TODO: test that the connection gets closed if ping responses stop. + ws.close() class ManualPingTest(WebSocketBaseTestCase): diff --git a/tornado/test/wsgi_test.py b/tornado/test/wsgi_test.py index f98da5bd73..9fbc744e11 100644 --- a/tornado/test/wsgi_test.py +++ b/tornado/test/wsgi_test.py @@ -1,20 +1,116 @@ +import asyncio +import concurrent.futures +import threading + from wsgiref.validate import validator -from tornado.testing import AsyncHTTPTestCase +from tornado.routing import RuleRouter +from tornado.testing import AsyncHTTPTestCase, gen_test from tornado.wsgi import WSGIContainer -class WSGIContainerTest(AsyncHTTPTestCase): +class WSGIAppMixin: # TODO: Now that WSGIAdapter is gone, this is a pretty weak test. - def wsgi_app(self, environ, start_response): + def get_executor(self): + raise NotImplementedError() + + def get_app(self): + executor = self.get_executor() + # The barrier test in DummyExecutorTest will always wait the full + # value of this timeout, so we don't want it to be too high. + self.barrier = threading.Barrier(2, timeout=0.3) + + def make_container(app): + return WSGIContainer(validator(app), executor=executor) + + return RuleRouter( + [ + ("/simple", make_container(self.simple_wsgi_app)), + ("/barrier", make_container(self.barrier_wsgi_app)), + ("/streaming_barrier", make_container(self.streaming_barrier_wsgi_app)), + ] + ) + + def respond_plain(self, start_response): status = "200 OK" response_headers = [("Content-Type", "text/plain")] start_response(status, response_headers) + + def simple_wsgi_app(self, environ, start_response): + self.respond_plain(start_response) return [b"Hello world!"] - def get_app(self): - return WSGIContainer(validator(self.wsgi_app)) + def barrier_wsgi_app(self, environ, start_response): + self.respond_plain(start_response) + try: + n = self.barrier.wait() + except threading.BrokenBarrierError: + return [b"broken barrier"] + else: + return [b"ok %d" % n] + + def streaming_barrier_wsgi_app(self, environ, start_response): + self.respond_plain(start_response) + yield b"ok " + try: + n = self.barrier.wait() + except threading.BrokenBarrierError: + yield b"broken barrier" + else: + yield b"%d" % n + + +class WSGIContainerDummyExecutorTest(WSGIAppMixin, AsyncHTTPTestCase): + def get_executor(self): + return None + + def test_simple(self): + response = self.fetch("/simple") + self.assertEqual(response.body, b"Hello world!") + + @gen_test + async def test_concurrent_barrier(self): + self.barrier.reset() + resps = await asyncio.gather( + self.http_client.fetch(self.get_url("/barrier")), + self.http_client.fetch(self.get_url("/barrier")), + ) + for resp in resps: + self.assertEqual(resp.body, b"broken barrier") + + @gen_test + async def test_concurrent_streaming_barrier(self): + self.barrier.reset() + resps = await asyncio.gather( + self.http_client.fetch(self.get_url("/streaming_barrier")), + self.http_client.fetch(self.get_url("/streaming_barrier")), + ) + for resp in resps: + self.assertEqual(resp.body, b"ok broken barrier") + + +class WSGIContainerThreadPoolTest(WSGIAppMixin, AsyncHTTPTestCase): + def get_executor(self): + return concurrent.futures.ThreadPoolExecutor() def test_simple(self): - response = self.fetch("/") + response = self.fetch("/simple") self.assertEqual(response.body, b"Hello world!") + + @gen_test + async def test_concurrent_barrier(self): + self.barrier.reset() + resps = await asyncio.gather( + self.http_client.fetch(self.get_url("/barrier")), + self.http_client.fetch(self.get_url("/barrier")), + ) + self.assertEqual([b"ok 0", b"ok 1"], sorted([resp.body for resp in resps])) + + @gen_test + async def test_concurrent_streaming_barrier(self): + self.barrier.reset() + resps = await asyncio.gather( + self.http_client.fetch(self.get_url("/streaming_barrier")), + self.http_client.fetch(self.get_url("/streaming_barrier")), + ) + self.assertEqual([b"ok 0", b"ok 1"], sorted([resp.body for resp in resps])) diff --git a/tornado/testing.py b/tornado/testing.py index 3351b9256d..4c33b3e22f 100644 --- a/tornado/testing.py +++ b/tornado/testing.py @@ -20,6 +20,7 @@ import socket import sys import unittest +import warnings from tornado import gen from tornado.httpclient import AsyncHTTPClient, HTTPResponse @@ -33,14 +34,10 @@ from tornado.web import Application import typing -from typing import Tuple, Any, Callable, Type, Dict, Union, Optional +from typing import Tuple, Any, Callable, Type, Dict, Union, Optional, Coroutine from types import TracebackType if typing.TYPE_CHECKING: - # Coroutine wasn't added to typing until 3.5.3, so only import it - # when mypy is running and use forward references. - from typing import Coroutine # noqa: F401 - _ExcInfoTuple = Tuple[ Optional[Type[BaseException]], Optional[BaseException], Optional[TracebackType] ] @@ -49,7 +46,9 @@ _NON_OWNED_IOLOOPS = AsyncIOMainLoop -def bind_unused_port(reuse_port: bool = False) -> Tuple[socket.socket, int]: +def bind_unused_port( + reuse_port: bool = False, address: str = "127.0.0.1" +) -> Tuple[socket.socket, int]: """Binds a server socket to an available port on localhost. Returns a tuple (socket, port). @@ -57,9 +56,13 @@ def bind_unused_port(reuse_port: bool = False) -> Tuple[socket.socket, int]: .. versionchanged:: 4.4 Always binds to ``127.0.0.1`` without resolving the name ``localhost``. + + .. versionchanged:: 6.2 + Added optional ``address`` argument to + override the default "127.0.0.1". """ sock = netutil.bind_sockets( - 0, "127.0.0.1", family=socket.AF_INET, reuse_port=reuse_port + 0, address, family=socket.AF_INET, reuse_port=reuse_port )[0] port = sock.getsockname()[1] return sock, port @@ -81,38 +84,6 @@ def get_async_test_timeout() -> float: return 5 -class _TestMethodWrapper(object): - """Wraps a test method to raise an error if it returns a value. - - This is mainly used to detect undecorated generators (if a test - method yields it must use a decorator to consume the generator), - but will also detect other kinds of return values (these are not - necessarily errors, but we alert anyway since there is no good - reason to return a value from a test). - """ - - def __init__(self, orig_method: Callable) -> None: - self.orig_method = orig_method - - def __call__(self, *args: Any, **kwargs: Any) -> None: - result = self.orig_method(*args, **kwargs) - if isinstance(result, Generator) or inspect.iscoroutine(result): - raise TypeError( - "Generator and coroutine test methods should be" - " decorated with tornado.testing.gen_test" - ) - elif result is not None: - raise ValueError("Return value from test method ignored: %r" % result) - - def __getattr__(self, name: str) -> Any: - """Proxy all unknown attributes to the original method. - - This is important for some of the decorators in the `unittest` - module, such as `unittest.skipIf`. - """ - return getattr(self.orig_method, name) - - class AsyncTestCase(unittest.TestCase): """`~unittest.TestCase` subclass for testing `.IOLoop`-based asynchronous code. @@ -131,7 +102,8 @@ class AsyncTestCase(unittest.TestCase): By default, a new `.IOLoop` is constructed for each test and is available as ``self.io_loop``. If the code being tested requires a - global `.IOLoop`, subclasses should override `get_new_ioloop` to return it. + reused global `.IOLoop`, subclasses should override `get_new_ioloop` to return it, + although this is deprecated as of Tornado 6.3. The `.IOLoop`'s ``start`` and ``stop`` methods should not be called directly. Instead, use `self.stop ` and `self.wait @@ -168,19 +140,26 @@ def __init__(self, methodName: str = "runTest") -> None: self.__stop_args = None # type: Any self.__timeout = None # type: Optional[object] - # It's easy to forget the @gen_test decorator, but if you do - # the test will silently be ignored because nothing will consume - # the generator. Replace the test method with a wrapper that will - # make sure it's not an undecorated generator. - setattr(self, methodName, _TestMethodWrapper(getattr(self, methodName))) - # Not used in this class itself, but used by @gen_test self._test_generator = None # type: Optional[Union[Generator, Coroutine]] def setUp(self) -> None: + py_ver = sys.version_info + if ((3, 10, 0) <= py_ver < (3, 10, 9)) or ((3, 11, 0) <= py_ver <= (3, 11, 1)): + # Early releases in the Python 3.10 and 3.1 series had deprecation + # warnings that were later reverted; we must suppress them here. + setup_with_context_manager(self, warnings.catch_warnings()) + warnings.filterwarnings( + "ignore", + message="There is no current event loop", + category=DeprecationWarning, + module=r"tornado\..*", + ) super().setUp() + if type(self).get_new_ioloop is not AsyncTestCase.get_new_ioloop: + warnings.warn("get_new_ioloop is deprecated", DeprecationWarning) self.io_loop = self.get_new_ioloop() - self.io_loop.make_current() + asyncio.set_event_loop(self.io_loop.asyncio_loop) # type: ignore[attr-defined] def tearDown(self) -> None: # Native coroutines tend to produce warnings if they're not @@ -188,13 +167,10 @@ def tearDown(self) -> None: # this always happens in tests, so cancel any tasks that are # still pending by the time we get here. asyncio_loop = self.io_loop.asyncio_loop # type: ignore - if hasattr(asyncio, "all_tasks"): # py37 - tasks = asyncio.all_tasks(asyncio_loop) # type: ignore - else: - tasks = asyncio.Task.all_tasks(asyncio_loop) + tasks = asyncio.all_tasks(asyncio_loop) # Tasks that are done may still appear here and may contain # non-cancellation exceptions, so filter them out. - tasks = [t for t in tasks if not t.done()] + tasks = [t for t in tasks if not t.done()] # type: ignore for t in tasks: t.cancel() # Allow the tasks to run and finalize themselves (which means @@ -215,7 +191,7 @@ def tearDown(self) -> None: # Clean up Subprocess, so it can be used again with a new ioloop. Subprocess.uninitialize() - self.io_loop.clear_current() + asyncio.set_event_loop(None) if not isinstance(self.io_loop, _NON_OWNED_IOLOOPS): # Try to clean up any file descriptors left open in the ioloop. # This avoids leaks, especially when tests are run repeatedly @@ -239,8 +215,11 @@ def get_new_ioloop(self) -> IOLoop: singletons using the default `.IOLoop`) or if a per-test event loop is being provided by another system (such as ``pytest-asyncio``). + + .. deprecated:: 6.3 + This method will be removed in Tornado 7.0. """ - return IOLoop() + return IOLoop(make_current=False) def _handle_exception( self, typ: Type[Exception], value: Exception, tb: TracebackType @@ -271,6 +250,30 @@ def run( self.__rethrow() return ret + def _callTestMethod(self, method: Callable) -> None: + """Run the given test method, raising an error if it returns non-None. + + Failure to decorate asynchronous test methods with ``@gen_test`` can lead to tests + incorrectly passing. + + Remove this override when Python 3.10 support is dropped. This check (in the form of a + DeprecationWarning) became a part of the standard library in 3.11. + + Note that ``_callTestMethod`` is not documented as a public interface. However, it is + present in all supported versions of Python (3.8+), and if it goes away in the future that's + OK because we can just remove this override as noted above. + """ + # Calling super()._callTestMethod would hide the return value, even in python 3.8-3.10 + # where the check isn't being done for us. + result = method() + if isinstance(result, Generator) or inspect.iscoroutine(result): + raise TypeError( + "Generator and coroutine test methods should be" + " decorated with tornado.testing.gen_test" + ) + elif result is not None: + raise ValueError("Return value from test method ignored: %r" % result) + def stop(self, _arg: Any = None, **kwargs: Any) -> None: """Stops the `.IOLoop`, causing one pending (or future) call to `wait()` to return. @@ -499,7 +502,9 @@ def get_ssl_options(self) -> Dict[str, Any]: def default_ssl_options() -> Dict[str, Any]: # Testing keys were generated with: # openssl req -new -keyout tornado/test/test.key \ - # -out tornado/test/test.crt -nodes -days 3650 -x509 + # -out tornado/test/test.crt \ + # -nodes -days 3650 -x509 \ + # -subj "/CN=foo.example.com" -addext "subjectAltName = DNS:foo.example.com" module_dir = os.path.dirname(__file__) return dict( certfile=os.path.join(module_dir, "test", "test.crt"), @@ -590,7 +595,7 @@ def pre_coroutine(self, *args, **kwargs): if inspect.iscoroutinefunction(f): coro = pre_coroutine else: - coro = gen.coroutine(pre_coroutine) + coro = gen.coroutine(pre_coroutine) # type: ignore[assignment] @functools.wraps(coro) def post_coroutine(self, *args, **kwargs): @@ -609,7 +614,7 @@ def post_coroutine(self, *args, **kwargs): if self._test_generator is not None and getattr( self._test_generator, "cr_running", True ): - self._test_generator.throw(type(e), e) + self._test_generator.throw(e) # In case the test contains an overly broad except # clause, we may get back here. # Coroutine was stopped or didn't raise a useful stack trace, @@ -661,28 +666,37 @@ def __init__( ) -> None: """Constructs an ExpectLog context manager. - :param logger: Logger object (or name of logger) to watch. Pass - an empty string to watch the root logger. - :param regex: Regular expression to match. Any log entries on - the specified logger that match this regex will be suppressed. - :param required: If true, an exception will be raised if the end of - the ``with`` statement is reached without matching any log entries. + :param logger: Logger object (or name of logger) to watch. Pass an + empty string to watch the root logger. + :param regex: Regular expression to match. Any log entries on the + specified logger that match this regex will be suppressed. + :param required: If true, an exception will be raised if the end of the + ``with`` statement is reached without matching any log entries. :param level: A constant from the ``logging`` module indicating the expected log level. If this parameter is provided, only log messages at this level will be considered to match. Additionally, the - supplied ``logger`` will have its level adjusted if necessary - (for the duration of the ``ExpectLog`` to enable the expected - message. + supplied ``logger`` will have its level adjusted if necessary (for + the duration of the ``ExpectLog`` to enable the expected message. .. versionchanged:: 6.1 Added the ``level`` parameter. + + .. deprecated:: 6.3 + In Tornado 7.0, only ``WARNING`` and higher logging levels will be + matched by default. To match ``INFO`` and lower levels, the ``level`` + argument must be used. This is changing to minimize differences + between ``tornado.testing.main`` (which enables ``INFO`` logs by + default) and most other test runners (including those in IDEs) + which have ``INFO`` logs disabled by default. """ if isinstance(logger, basestring_type): logger = logging.getLogger(logger) self.logger = logger self.regex = re.compile(regex) self.required = required - self.matched = False + # matched and deprecated_level_matched are a counter for the respective event. + self.matched = 0 + self.deprecated_level_matched = 0 self.logged_stack = False self.level = level self.orig_level = None # type: Optional[int] @@ -692,13 +706,20 @@ def filter(self, record: logging.LogRecord) -> bool: self.logged_stack = True message = record.getMessage() if self.regex.match(message): + if self.level is None and record.levelno < logging.WARNING: + # We're inside the logging machinery here so generating a DeprecationWarning + # here won't be reported cleanly (if warnings-as-errors is enabled, the error + # just gets swallowed by the logging module), and even if it were it would + # have the wrong stack trace. Just remember this fact and report it in + # __exit__ instead. + self.deprecated_level_matched += 1 if self.level is not None and record.levelno != self.level: app_log.warning( "Got expected log message %r at unexpected level (%s vs %s)" % (message, logging.getLevelName(self.level), record.levelname) ) return True - self.matched = True + self.matched += 1 return False return True @@ -720,6 +741,23 @@ def __exit__( self.logger.removeFilter(self) if not typ and self.required and not self.matched: raise Exception("did not get expected log message") + if ( + not typ + and self.required + and (self.deprecated_level_matched >= self.matched) + ): + warnings.warn( + "ExpectLog matched at INFO or below without level argument", + DeprecationWarning, + ) + + +# From https://nedbatchelder.com/blog/201508/using_context_managers_in_test_setup.html +def setup_with_context_manager(testcase: unittest.TestCase, cm: Any) -> Any: + """Use a contextmanager to setUp a test case.""" + val = cm.__enter__() + testcase.addCleanup(cm.__exit__, None, None, None) + return val def main(**kwargs: Any) -> None: diff --git a/tornado/util.py b/tornado/util.py index 77c5f942ec..3a3a52f1f2 100644 --- a/tornado/util.py +++ b/tornado/util.py @@ -11,6 +11,7 @@ """ import array +import asyncio import atexit from inspect import getfullargspec import os @@ -63,19 +64,13 @@ def is_finalizing() -> bool: is_finalizing = _get_emulated_is_finalizing() -class TimeoutError(Exception): - """Exception raised by `.with_timeout` and `.IOLoop.run_sync`. - - .. versionchanged:: 5.0: - Unified ``tornado.gen.TimeoutError`` and - ``tornado.ioloop.TimeoutError`` as ``tornado.util.TimeoutError``. - Both former names remain as aliases. - """ +# versionchanged:: 6.2 +# no longer our own TimeoutError, use standard asyncio class +TimeoutError = asyncio.TimeoutError class ObjectDict(Dict[str, Any]): - """Makes a dictionary behave like an object, with attribute-style access. - """ + """Makes a dictionary behave like an object, with attribute-style access.""" def __getattr__(self, name: str) -> Any: try: @@ -115,8 +110,7 @@ def decompress(self, value: bytes, max_length: int = 0) -> bytes: @property def unconsumed_tail(self) -> bytes: - """Returns the unconsumed portion left over - """ + """Returns the unconsumed portion left over""" return self.decompressobj.unconsumed_tail def flush(self) -> bytes: @@ -168,14 +162,8 @@ def exec_in( def raise_exc_info( - exc_info, # type: Tuple[Optional[type], Optional[BaseException], Optional[TracebackType]] -): - # type: (...) -> typing.NoReturn - # - # This function's type annotation must use comments instead of - # real annotations because typing.NoReturn does not exist in - # python 3.5's typing module. The formatting is funky because this - # is apparently what flake8 wants. + exc_info: Tuple[Optional[type], Optional[BaseException], Optional["TracebackType"]] +) -> typing.NoReturn: try: if exc_info[1] is not None: raise exc_info[1].with_traceback(exc_info[2]) diff --git a/tornado/web.py b/tornado/web.py index 546e6ecf11..039396470f 100644 --- a/tornado/web.py +++ b/tornado/web.py @@ -22,19 +22,22 @@ .. testcode:: - import tornado.ioloop - import tornado.web + import asyncio + import tornado class MainHandler(tornado.web.RequestHandler): def get(self): self.write("Hello, world") - if __name__ == "__main__": + async def main(): application = tornado.web.Application([ (r"/", MainHandler), ]) application.listen(8888) - tornado.ioloop.IOLoop.current().start() + await asyncio.Event().wait() + + if __name__ == "__main__": + asyncio.run(main()) .. testoutput:: :hide: @@ -72,9 +75,11 @@ def get(self): import numbers import os.path import re +import socket import sys import threading import time +import warnings import tornado import traceback import types @@ -87,7 +92,6 @@ def get(self): from tornado.httpserver import HTTPServer from tornado import httputil from tornado import iostream -import tornado.locale from tornado import locale from tornado.log import access_log, app_log, gen_log from tornado import template @@ -118,6 +122,7 @@ def get(self): Iterable, Generator, Type, + TypeVar, cast, overload, ) @@ -160,7 +165,7 @@ def get(self): """ DEFAULT_SIGNED_VALUE_MIN_VERSION = 1 -"""The oldest signed value accepted by `.RequestHandler.get_secure_cookie`. +"""The oldest signed value accepted by `.RequestHandler.get_signed_cookie`. May be overridden by passing a ``min_version`` keyword argument. @@ -204,7 +209,7 @@ def __init__( self, application: "Application", request: httputil.HTTPServerRequest, - **kwargs: Any + **kwargs: Any, ) -> None: super().__init__() @@ -399,14 +404,10 @@ def _convert_header_value(self, value: _HeaderTypes) -> str: # cases are covered by the first match for str. if isinstance(value, str): retval = value - elif isinstance(value, bytes): # py3 + elif isinstance(value, bytes): # Non-ascii characters in headers are not well supported, # but if you pass bytes, use latin1 so they pass through as-is. retval = value.decode("latin1") - elif isinstance(value, unicode_type): # py2 - # TODO: This is inconsistent with the use of latin1 above, - # but it's been that way for a long time. Should it change? - retval = escape.utf8(value) elif isinstance(value, numbers.Integral): # return immediately since we know the converted value will be safe return str(value) @@ -601,21 +602,33 @@ def set_cookie( expires: Optional[Union[float, Tuple, datetime.datetime]] = None, path: str = "/", expires_days: Optional[float] = None, - **kwargs: Any + # Keyword-only args start here for historical reasons. + *, + max_age: Optional[int] = None, + httponly: bool = False, + secure: bool = False, + samesite: Optional[str] = None, + **kwargs: Any, ) -> None: """Sets an outgoing cookie name/value with the given options. Newly-set cookies are not immediately visible via `get_cookie`; they are not present until the next request. - expires may be a numeric timestamp as returned by `time.time`, + Most arguments are passed directly to `http.cookies.Morsel` directly. + See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie + for more information. + + ``expires`` may be a numeric timestamp as returned by `time.time`, a time tuple as returned by `time.gmtime`, or a - `datetime.datetime` object. + `datetime.datetime` object. ``expires_days`` is provided as a convenience + to set an expiration time in days from today (if both are set, ``expires`` + is used). - Additional keyword arguments are set on the cookies.Morsel - directly. - See https://docs.python.org/3/library/http.cookies.html#http.cookies.Morsel - for available attributes. + .. deprecated:: 6.3 + Keyword arguments are currently accepted case-insensitively. + In Tornado 7.0 this will be changed to only accept lowercase + arguments. """ # The cookie library only accepts type str, in both python 2 and 3 name = escape.native_str(name) @@ -634,61 +647,102 @@ def set_cookie( if domain: morsel["domain"] = domain if expires_days is not None and not expires: - expires = datetime.datetime.utcnow() + datetime.timedelta(days=expires_days) + expires = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta( + days=expires_days + ) if expires: morsel["expires"] = httputil.format_timestamp(expires) if path: morsel["path"] = path - for k, v in kwargs.items(): - if k == "max_age": - k = "max-age" - - # skip falsy values for httponly and secure flags because - # SimpleCookie sets them regardless - if k in ["httponly", "secure"] and not v: - continue - - morsel[k] = v + if max_age: + # Note change from _ to -. + morsel["max-age"] = str(max_age) + if httponly: + # Note that SimpleCookie ignores the value here. The presense of an + # httponly (or secure) key is treated as true. + morsel["httponly"] = True + if secure: + morsel["secure"] = True + if samesite: + morsel["samesite"] = samesite + if kwargs: + # The setitem interface is case-insensitive, so continue to support + # kwargs for backwards compatibility until we can remove deprecated + # features. + for k, v in kwargs.items(): + morsel[k] = v + warnings.warn( + f"Deprecated arguments to set_cookie: {set(kwargs.keys())} " + "(should be lowercase)", + DeprecationWarning, + ) - def clear_cookie( - self, name: str, path: str = "/", domain: Optional[str] = None - ) -> None: + def clear_cookie(self, name: str, **kwargs: Any) -> None: """Deletes the cookie with the given name. - Due to limitations of the cookie protocol, you must pass the same - path and domain to clear a cookie as were used when that cookie - was set (but there is no way to find out on the server side - which values were used for a given cookie). + This method accepts the same arguments as `set_cookie`, except for + ``expires`` and ``max_age``. Clearing a cookie requires the same + ``domain`` and ``path`` arguments as when it was set. In some cases the + ``samesite`` and ``secure`` arguments are also required to match. Other + arguments are ignored. Similar to `set_cookie`, the effect of this method will not be seen until the following request. + + .. versionchanged:: 6.3 + + Now accepts all keyword arguments that ``set_cookie`` does. + The ``samesite`` and ``secure`` flags have recently become + required for clearing ``samesite="none"`` cookies. """ - expires = datetime.datetime.utcnow() - datetime.timedelta(days=365) - self.set_cookie(name, value="", path=path, expires=expires, domain=domain) + for excluded_arg in ["expires", "max_age"]: + if excluded_arg in kwargs: + raise TypeError( + f"clear_cookie() got an unexpected keyword argument '{excluded_arg}'" + ) + expires = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta( + days=365 + ) + self.set_cookie(name, value="", expires=expires, **kwargs) - def clear_all_cookies(self, path: str = "/", domain: Optional[str] = None) -> None: - """Deletes all the cookies the user sent with this request. + def clear_all_cookies(self, **kwargs: Any) -> None: + """Attempt to delete all the cookies the user sent with this request. - See `clear_cookie` for more information on the path and domain - parameters. + See `clear_cookie` for more information on keyword arguments. Due to + limitations of the cookie protocol, it is impossible to determine on the + server side which values are necessary for the ``domain``, ``path``, + ``samesite``, or ``secure`` arguments, this method can only be + successful if you consistently use the same values for these arguments + when setting cookies. - Similar to `set_cookie`, the effect of this method will not be - seen until the following request. + Similar to `set_cookie`, the effect of this method will not be seen + until the following request. .. versionchanged:: 3.2 Added the ``path`` and ``domain`` parameters. + + .. versionchanged:: 6.3 + + Now accepts all keyword arguments that ``set_cookie`` does. + + .. deprecated:: 6.3 + + The increasingly complex rules governing cookies have made it + impossible for a ``clear_all_cookies`` method to work reliably + since all we know about cookies are their names. Applications + should generally use ``clear_cookie`` one at a time instead. """ for name in self.request.cookies: - self.clear_cookie(name, path=path, domain=domain) + self.clear_cookie(name, **kwargs) - def set_secure_cookie( + def set_signed_cookie( self, name: str, value: Union[str, bytes], expires_days: Optional[float] = 30, version: Optional[int] = None, - **kwargs: Any + **kwargs: Any, ) -> None: """Signs and timestamps a cookie so it cannot be forged. @@ -696,11 +750,11 @@ def set_secure_cookie( to use this method. It should be a long, random sequence of bytes to be used as the HMAC secret for the signature. - To read a cookie set with this method, use `get_secure_cookie()`. + To read a cookie set with this method, use `get_signed_cookie()`. Note that the ``expires_days`` parameter sets the lifetime of the cookie in the browser, but is independent of the ``max_age_days`` - parameter to `get_secure_cookie`. + parameter to `get_signed_cookie`. A value of None limits the lifetime to the current browser session. Secure cookies may contain arbitrary byte values, not just unicode @@ -713,22 +767,30 @@ def set_secure_cookie( Added the ``version`` argument. Introduced cookie version 2 and made it the default. + + .. versionchanged:: 6.3 + + Renamed from ``set_secure_cookie`` to ``set_signed_cookie`` to + avoid confusion with other uses of "secure" in cookie attributes + and prefixes. The old name remains as an alias. """ self.set_cookie( name, self.create_signed_value(name, value, version=version), expires_days=expires_days, - **kwargs + **kwargs, ) + set_secure_cookie = set_signed_cookie + def create_signed_value( self, name: str, value: Union[str, bytes], version: Optional[int] = None ) -> bytes: """Signs and timestamps a string so it cannot be forged. - Normally used via set_secure_cookie, but provided as a separate + Normally used via set_signed_cookie, but provided as a separate method for non-cookie uses. To decode a value not stored - as a cookie use the optional value argument to get_secure_cookie. + as a cookie use the optional value argument to get_signed_cookie. .. versionchanged:: 3.2.1 @@ -747,7 +809,7 @@ def create_signed_value( secret, name, value, version=version, key_version=key_version ) - def get_secure_cookie( + def get_signed_cookie( self, name: str, value: Optional[str] = None, @@ -761,12 +823,19 @@ def get_secure_cookie( Similar to `get_cookie`, this method only returns cookies that were present in the request. It does not see outgoing cookies set by - `set_secure_cookie` in this handler. + `set_signed_cookie` in this handler. .. versionchanged:: 3.2.1 Added the ``min_version`` argument. Introduced cookie version 2; both versions 1 and 2 are accepted by default. + + .. versionchanged:: 6.3 + + Renamed from ``get_secure_cookie`` to ``get_signed_cookie`` to + avoid confusion with other uses of "secure" in cookie attributes + and prefixes. The old name remains as an alias. + """ self.require_setting("cookie_secret", "secure cookies") if value is None: @@ -779,12 +848,22 @@ def get_secure_cookie( min_version=min_version, ) - def get_secure_cookie_key_version( + get_secure_cookie = get_signed_cookie + + def get_signed_cookie_key_version( self, name: str, value: Optional[str] = None ) -> Optional[int]: """Returns the signing key version of the secure cookie. The version is returned as int. + + .. versionchanged:: 6.3 + + Renamed from ``get_secure_cookie_key_version`` to + ``set_signed_cookie_key_version`` to avoid confusion with other + uses of "secure" in cookie attributes and prefixes. The old name + remains as an alias. + """ self.require_setting("cookie_secret", "secure cookies") if value is None: @@ -793,6 +872,8 @@ def get_secure_cookie_key_version( return None return get_signature_key_version(value) + get_secure_cookie_key_version = get_signed_cookie_key_version + def redirect( self, url: str, permanent: bool = False, status: Optional[int] = None ) -> None: @@ -996,7 +1077,7 @@ def render_string(self, template_name: str, **kwargs: Any) -> bytes: if not template_path: frame = sys._getframe(0) web_file = frame.f_code.co_filename - while frame.f_code.co_filename == web_file: + while frame.f_code.co_filename == web_file and frame.f_back is not None: frame = frame.f_back assert frame.f_code.co_filename is not None template_path = os.path.dirname(frame.f_code.co_filename) @@ -1290,14 +1371,17 @@ def get_browser_locale(self, default: str = "en_US") -> tornado.locale.Locale: locales = [] for language in languages: parts = language.strip().split(";") - if len(parts) > 1 and parts[1].startswith("q="): + if len(parts) > 1 and parts[1].strip().startswith("q="): try: - score = float(parts[1][2:]) + score = float(parts[1].strip()[2:]) + if score < 0: + raise ValueError() except (ValueError, TypeError): score = 0.0 else: score = 1.0 - locales.append((parts[0], score)) + if score > 0: + locales.append((parts[0], score)) if locales: locales.sort(key=lambda pair: pair[1], reverse=True) codes = [loc[0] for loc in locales] @@ -1316,7 +1400,7 @@ def current_user(self) -> Any: and is cached for future access:: def get_current_user(self): - user_cookie = self.get_secure_cookie("user") + user_cookie = self.get_signed_cookie("user") if user_cookie: return json.loads(user_cookie) return None @@ -1326,7 +1410,7 @@ def get_current_user(self): @gen.coroutine def prepare(self): - user_id_cookie = self.get_secure_cookie("user_id") + user_id_cookie = self.get_signed_cookie("user_id") if user_id_cookie: self.current_user = yield load_user(user_id_cookie) @@ -1421,7 +1505,8 @@ def xsrf_token(self) -> bytes: if version is None: if self.current_user and "expires_days" not in cookie_kwargs: cookie_kwargs["expires_days"] = 30 - self.set_cookie("_xsrf", self._xsrf_token, **cookie_kwargs) + cookie_name = self.settings.get("xsrf_cookie_name", "_xsrf") + self.set_cookie(cookie_name, self._xsrf_token, **cookie_kwargs) return self._xsrf_token def _get_raw_xsrf_token(self) -> Tuple[Optional[int], bytes, float]: @@ -1436,7 +1521,8 @@ def _get_raw_xsrf_token(self) -> Tuple[Optional[int], bytes, float]: for version 1 cookies) """ if not hasattr(self, "_raw_xsrf_token"): - cookie = self.get_cookie("_xsrf") + cookie_name = self.settings.get("xsrf_cookie_name", "_xsrf") + cookie = self.get_cookie(cookie_name) if cookie: version, token, timestamp = self._decode_xsrf_token(cookie) else: @@ -1638,7 +1724,7 @@ def check_etag_header(self) -> bool: # Find all weak and strong etag values from If-None-Match header # because RFC 7232 allows multiple etag values in a single header. etags = re.findall( - br'\*|(?:W/)?"[^"]*"', utf8(self.request.headers.get("If-None-Match", "")) + rb'\*|(?:W/)?"[^"]*"', utf8(self.request.headers.get("If-None-Match", "")) ) if not computed_etag or not etags: return False @@ -1680,7 +1766,7 @@ async def _execute( result = self.prepare() if result is not None: - result = await result + result = await result # type: ignore if self._prepared_future is not None: # Tell the Application we've finished with prepare() # and are ready for the body to arrive. @@ -1818,7 +1904,10 @@ def _clear_representation_headers(self) -> None: self.clear_header(h) -def stream_request_body(cls: Type[RequestHandler]) -> Type[RequestHandler]: +_RequestHandlerType = TypeVar("_RequestHandlerType", bound=RequestHandler) + + +def stream_request_body(cls: Type[_RequestHandlerType]) -> Type[_RequestHandlerType]: """Apply to `RequestHandler` subclasses to enable streaming body support. This decorator implies the following changes: @@ -1836,7 +1925,7 @@ def stream_request_body(cls: Type[RequestHandler]) -> Type[RequestHandler]: * The regular HTTP method (``post``, ``put``, etc) will be called after the entire body has been read. - See the `file receiver demo `_ + See the `file receiver demo `_ for example usage. """ # noqa: E501 if not issubclass(cls, RequestHandler): @@ -1957,7 +2046,6 @@ class Application(ReversibleRouter): ]) http_server = httpserver.HTTPServer(application) http_server.listen(8080) - ioloop.IOLoop.current().start() The constructor for this class takes in a list of `~.routing.Rule` objects or tuples of values corresponding to the arguments of @@ -2035,7 +2123,7 @@ def __init__( handlers: Optional[_RuleList] = None, default_host: Optional[str] = None, transforms: Optional[List[Type["OutputTransform"]]] = None, - **settings: Any + **settings: Any, ) -> None: if transforms is None: self.transforms = [] # type: List[Type[OutputTransform]] @@ -2086,27 +2174,48 @@ def __init__( autoreload.start() - def listen(self, port: int, address: str = "", **kwargs: Any) -> HTTPServer: + def listen( + self, + port: int, + address: Optional[str] = None, + *, + family: socket.AddressFamily = socket.AF_UNSPEC, + backlog: int = tornado.netutil._DEFAULT_BACKLOG, + flags: Optional[int] = None, + reuse_port: bool = False, + **kwargs: Any, + ) -> HTTPServer: """Starts an HTTP server for this application on the given port. - This is a convenience alias for creating an `.HTTPServer` - object and calling its listen method. Keyword arguments not - supported by `HTTPServer.listen <.TCPServer.listen>` are passed to the - `.HTTPServer` constructor. For advanced uses - (e.g. multi-process mode), do not use this method; create an - `.HTTPServer` and call its + This is a convenience alias for creating an `.HTTPServer` object and + calling its listen method. Keyword arguments not supported by + `HTTPServer.listen <.TCPServer.listen>` are passed to the `.HTTPServer` + constructor. For advanced uses (e.g. multi-process mode), do not use + this method; create an `.HTTPServer` and call its `.TCPServer.bind`/`.TCPServer.start` methods directly. Note that after calling this method you still need to call - ``IOLoop.current().start()`` to start the server. + ``IOLoop.current().start()`` (or run within ``asyncio.run``) to start + the server. Returns the `.HTTPServer` object. .. versionchanged:: 4.3 Now returns the `.HTTPServer` object. + + .. versionchanged:: 6.2 + Added support for new keyword arguments in `.TCPServer.listen`, + including ``reuse_port``. """ server = HTTPServer(self, **kwargs) - server.listen(port, address) + server.listen( + port, + address=address, + family=family, + backlog=backlog, + flags=flags, + reuse_port=reuse_port, + ) return server def add_handlers(self, host_pattern: str, host_handlers: _RuleList) -> None: @@ -2304,7 +2413,10 @@ def execute(self) -> Optional[Awaitable[None]]: for loader in RequestHandler._template_loaders.values(): loader.reset() if not self.application.settings.get("static_hash_cache", True): - StaticFileHandler.reset() + static_handler_class = self.application.settings.get( + "static_handler_class", StaticFileHandler + ) + static_handler_class.reset() self.handler = self.handler_class( self.application, self.request, **self.handler_kwargs @@ -2358,7 +2470,7 @@ def __init__( status_code: int = 500, log_message: Optional[str] = None, *args: Any, - **kwargs: Any + **kwargs: Any, ) -> None: self.status_code = status_code self.log_message = log_message @@ -2685,7 +2797,8 @@ def set_headers(self) -> None: if cache_time > 0: self.set_header( "Expires", - datetime.datetime.utcnow() + datetime.timedelta(seconds=cache_time), + datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(seconds=cache_time), ) self.set_header("Cache-Control", "max-age=" + str(cache_time)) @@ -2704,12 +2817,12 @@ def should_return_304(self) -> bool: # content has not been modified ims_value = self.request.headers.get("If-Modified-Since") if ims_value is not None: - date_tuple = email.utils.parsedate(ims_value) - if date_tuple is not None: - if_since = datetime.datetime(*date_tuple[:6]) - assert self.modified is not None - if if_since >= self.modified: - return True + if_since = email.utils.parsedate_to_datetime(ims_value) + if if_since.tzinfo is None: + if_since = if_since.replace(tzinfo=datetime.timezone.utc) + assert self.modified is not None + if if_since >= self.modified: + return True return False @@ -2771,6 +2884,15 @@ def validate_absolute_path(self, root: str, absolute_path: str) -> Optional[str] # but there is some prefix to the path that was already # trimmed by the routing if not self.request.path.endswith("/"): + if self.request.path.startswith("//"): + # A redirect with two initial slashes is a "protocol-relative" URL. + # This means the next path segment is treated as a hostname instead + # of a part of the path, making this effectively an open redirect. + # Reject paths starting with two slashes to prevent this. + # This is only reachable under certain configurations. + raise HTTPError( + 403, "cannot redirect path with two initial slashes" + ) self.redirect(self.request.path + "/", permanent=True) return None absolute_path = os.path.join(absolute_path, self.default_filename) @@ -2864,6 +2986,10 @@ def get_modified_time(self) -> Optional[datetime.datetime]: object or None. .. versionadded:: 3.1 + + .. versionchanged:: 6.4 + Now returns an aware datetime object instead of a naive one. + Subclasses that override this method may return either kind. """ stat_result = self._stat() # NOTE: Historically, this used stat_result[stat.ST_MTIME], @@ -2874,7 +3000,9 @@ def get_modified_time(self) -> Optional[datetime.datetime]: # consistency with the past (and because we have a unit test # that relies on this), we truncate the float here, although # I'm not sure that's the right thing to do. - modified = datetime.datetime.utcfromtimestamp(int(stat_result.st_mtime)) + modified = datetime.datetime.fromtimestamp( + int(stat_result.st_mtime), datetime.timezone.utc + ) return modified def get_content_type(self) -> str: @@ -3008,7 +3136,7 @@ class FallbackHandler(RequestHandler): django.core.handlers.wsgi.WSGIHandler()) application = tornado.web.Application([ (r"/foo", FooHandler), - (r".*", FallbackHandler, dict(fallback=wsgi_app), + (r".*", FallbackHandler, dict(fallback=wsgi_app)), ]) """ @@ -3406,7 +3534,7 @@ def format_field(s: Union[str, bytes]) -> bytes: # A leading version number in decimal # with no leading zeros, followed by a pipe. -_signed_value_version_re = re.compile(br"^([1-9][0-9]*)\|(.*)$") +_signed_value_version_re = re.compile(rb"^([1-9][0-9]*)\|(.*)$") def _get_version(value: bytes) -> int: diff --git a/tornado/websocket.py b/tornado/websocket.py index eef49e7c99..8f0e0aefe8 100644 --- a/tornado/websocket.py +++ b/tornado/websocket.py @@ -1,16 +1,11 @@ """Implementation of the WebSocket protocol. `WebSockets `_ allow for bidirectional -communication between the browser and server. - -WebSockets are supported in the current versions of all major browsers, -although older versions that do not support WebSockets are still in use -(refer to http://caniuse.com/websockets for details). +communication between the browser and server. WebSockets are supported in the +current versions of all major browsers. This module implements the final version of the WebSocket protocol as -defined in `RFC 6455 `_. Certain -browser versions (notably Safari 5.x) implemented an earlier draft of -the protocol (known as "draft 76") and are not compatible with this module. +defined in `RFC 6455 `_. .. versionchanged:: 4.0 Removed support for the draft 76 protocol version. @@ -23,9 +18,9 @@ import os import sys import struct -import tornado.escape -import tornado.web +import tornado from urllib.parse import urlparse +import warnings import zlib from tornado.concurrent import Future, future_set_result_unless_cancelled @@ -34,6 +29,7 @@ from tornado.ioloop import IOLoop, PeriodicCallback from tornado.iostream import StreamClosedError, IOStream from tornado.log import gen_log, app_log +from tornado.netutil import Resolver from tornado import simple_httpclient from tornado.queues import Queue from tornado.tcpclient import TCPClient @@ -225,7 +221,6 @@ def __init__( self.ws_connection = None # type: Optional[WebSocketProtocol] self.close_code = None # type: Optional[int] self.close_reason = None # type: Optional[str] - self.stream = None # type: Optional[IOStream] self._on_close_called = False async def get(self, *args: Any, **kwargs: Any) -> None: @@ -584,16 +579,6 @@ def _break_cycles(self) -> None: if self.get_status() != 101 or self._on_close_called: super()._break_cycles() - def send_error(self, *args: Any, **kwargs: Any) -> None: - if self.stream is None: - super().send_error(*args, **kwargs) - else: - # If we get an uncaught exception during the handshake, - # we have no choice but to abruptly close the connection. - # TODO: for uncaught exceptions after the handshake, - # we can close the connection more gracefully. - self.stream.close() - def get_websocket_protocol(self) -> Optional["WebSocketProtocol"]: websocket_version = self.request.headers.get("Sec-WebSocket-Version") if websocket_version in ("7", "8", "13"): @@ -626,8 +611,7 @@ def _raise_not_supported_for_websockets(*args: Any, **kwargs: Any) -> None: class WebSocketProtocol(abc.ABC): - """Base class for WebSocket protocol versions. - """ + """Base class for WebSocket protocol versions.""" def __init__(self, handler: "_WebSocketDelegate") -> None: self.handler = handler @@ -681,7 +665,7 @@ async def accept_connection(self, handler: WebSocketHandler) -> None: @abc.abstractmethod def write_message( - self, message: Union[str, bytes], binary: bool = False + self, message: Union[str, bytes, Dict[str, Any]], binary: bool = False ) -> "Future[None]": raise NotImplementedError() @@ -834,7 +818,7 @@ def __init__( self._masked_frame = None self._frame_mask = None # type: Optional[bytes] self._frame_length = None - self._fragmented_message_buffer = None # type: Optional[bytes] + self._fragmented_message_buffer = None # type: Optional[bytearray] self._fragmented_message_opcode = None self._waiting = None # type: object self._compression_options = params.compression_options @@ -1073,13 +1057,15 @@ def _write_frame( return self.stream.write(frame) def write_message( - self, message: Union[str, bytes], binary: bool = False + self, message: Union[str, bytes, Dict[str, Any]], binary: bool = False ) -> "Future[None]": """Sends the given message to the client of this Web Socket.""" if binary: opcode = 0x2 else: opcode = 0x1 + if isinstance(message, dict): + message = tornado.escape.json_encode(message) message = tornado.escape.utf8(message) assert isinstance(message, bytes) self._message_bytes_out += len(message) @@ -1187,10 +1173,10 @@ async def _receive_frame(self) -> None: # nothing to continue self._abort() return - self._fragmented_message_buffer += data + self._fragmented_message_buffer.extend(data) if is_final_frame: opcode = self._fragmented_message_opcode - data = self._fragmented_message_buffer + data = bytes(self._fragmented_message_buffer) self._fragmented_message_buffer = None else: # start of new data message if self._fragmented_message_buffer is not None: @@ -1199,7 +1185,7 @@ async def _receive_frame(self) -> None: return if not is_final_frame: self._fragmented_message_opcode = opcode - self._fragmented_message_buffer = data + self._fragmented_message_buffer = bytearray(data) if is_final_frame: handled_future = self._handle_message(opcode, data) @@ -1371,7 +1357,8 @@ def __init__( ping_interval: Optional[float] = None, ping_timeout: Optional[float] = None, max_message_size: int = _default_max_message_size, - subprotocols: Optional[List[str]] = [], + subprotocols: Optional[List[str]] = None, + resolver: Optional[Resolver] = None, ) -> None: self.connect_future = Future() # type: Future[WebSocketClientConnection] self.read_queue = Queue(1) # type: Queue[Union[None, str, bytes]] @@ -1405,14 +1392,14 @@ def __init__( # from the server). # TODO: set server parameters for deflate extension # if requested in self.compression_options. - request.headers[ - "Sec-WebSocket-Extensions" - ] = "permessage-deflate; client_max_window_bits" + request.headers["Sec-WebSocket-Extensions"] = ( + "permessage-deflate; client_max_window_bits" + ) # Websocket connection is currently unable to follow redirects request.follow_redirects = False - self.tcp_client = TCPClient() + self.tcp_client = TCPClient(resolver=resolver) super().__init__( None, request, @@ -1424,6 +1411,15 @@ def __init__( 104857600, ) + def __del__(self) -> None: + if self.protocol is not None: + # Unclosed client connections can sometimes log "task was destroyed but + # was pending" warnings if shutdown strikes at the wrong time (such as + # while a ping is being processed due to ping_interval). Log our own + # warning to make it a little more deterministic (although it's still + # dependent on GC timing). + warnings.warn("Unclosed WebSocketClientConnection", ResourceWarning) + def close(self, code: Optional[int] = None, reason: Optional[str] = None) -> None: """Closes the websocket connection. @@ -1494,7 +1490,7 @@ async def headers_received( future_set_result_unless_cancelled(self.connect_future, self) def write_message( - self, message: Union[str, bytes], binary: bool = False + self, message: Union[str, bytes, Dict[str, Any]], binary: bool = False ) -> "Future[None]": """Sends a message to the WebSocket server. @@ -1505,6 +1501,8 @@ def write_message( Exception raised on a closed stream changed from `.StreamClosedError` to `WebSocketClosedError`. """ + if self.protocol is None: + raise WebSocketClosedError("Client connection has been closed") return self.protocol.write_message(message, binary=binary) def read_message( @@ -1596,6 +1594,7 @@ def websocket_connect( ping_timeout: Optional[float] = None, max_message_size: int = _default_max_message_size, subprotocols: Optional[List[str]] = None, + resolver: Optional[Resolver] = None, ) -> "Awaitable[WebSocketClientConnection]": """Client-side websocket support. @@ -1639,6 +1638,9 @@ def websocket_connect( .. versionchanged:: 5.1 Added the ``subprotocols`` argument. + + .. versionchanged:: 6.3 + Added the ``resolver`` argument. """ if isinstance(url, httpclient.HTTPRequest): assert connect_timeout is None @@ -1660,6 +1662,7 @@ def websocket_connect( ping_timeout=ping_timeout, max_message_size=max_message_size, subprotocols=subprotocols, + resolver=resolver, ) if callback is not None: IOLoop.current().add_future(conn.connect_future, callback) diff --git a/tornado/wsgi.py b/tornado/wsgi.py index 77124aaa30..32641be30f 100644 --- a/tornado/wsgi.py +++ b/tornado/wsgi.py @@ -27,12 +27,15 @@ """ -import sys +import concurrent.futures from io import BytesIO import tornado +import sys +from tornado.concurrent import dummy_executor from tornado import escape from tornado import httputil +from tornado.ioloop import IOLoop from tornado.log import access_log from typing import List, Tuple, Optional, Callable, Any, Dict, Text @@ -41,7 +44,7 @@ if typing.TYPE_CHECKING: from typing import Type # noqa: F401 - from wsgiref.types import WSGIApplication as WSGIAppType # noqa: F401 + from _typeshed.wsgi import WSGIApplication as WSGIAppType # noqa: F401 # PEP 3333 specifies that WSGI on python 3 generally deals with byte strings @@ -54,44 +57,83 @@ def to_wsgi_str(s: bytes) -> str: class WSGIContainer(object): - r"""Makes a WSGI-compatible function runnable on Tornado's HTTP server. + r"""Makes a WSGI-compatible application runnable on Tornado's HTTP server. .. warning:: WSGI is a *synchronous* interface, while Tornado's concurrency model - is based on single-threaded asynchronous execution. This means that - running a WSGI app with Tornado's `WSGIContainer` is *less scalable* - than running the same app in a multi-threaded WSGI server like - ``gunicorn`` or ``uwsgi``. Use `WSGIContainer` only when there are - benefits to combining Tornado and WSGI in the same process that - outweigh the reduced scalability. + is based on single-threaded *asynchronous* execution. Many of Tornado's + distinguishing features are not available in WSGI mode, including efficient + long-polling and websockets. The primary purpose of `WSGIContainer` is + to support both WSGI applications and native Tornado ``RequestHandlers`` in + a single process. WSGI-only applications are likely to be better off + with a dedicated WSGI server such as ``gunicorn`` or ``uwsgi``. + + Wrap a WSGI application in a `WSGIContainer` to make it implement the Tornado + `.HTTPServer` ``request_callback`` interface. The `WSGIContainer` object can + then be passed to classes from the `tornado.routing` module, + `tornado.web.FallbackHandler`, or to `.HTTPServer` directly. + + This class is intended to let other frameworks (Django, Flask, etc) + run on the Tornado HTTP server and I/O loop. - Wrap a WSGI function in a `WSGIContainer` and pass it to `.HTTPServer` to - run it. For example:: + Realistic usage will be more complicated, but the simplest possible example uses a + hand-written WSGI application with `.HTTPServer`:: def simple_app(environ, start_response): status = "200 OK" response_headers = [("Content-type", "text/plain")] start_response(status, response_headers) - return ["Hello world!\n"] + return [b"Hello world!\n"] - container = tornado.wsgi.WSGIContainer(simple_app) - http_server = tornado.httpserver.HTTPServer(container) - http_server.listen(8888) - tornado.ioloop.IOLoop.current().start() + async def main(): + container = tornado.wsgi.WSGIContainer(simple_app) + http_server = tornado.httpserver.HTTPServer(container) + http_server.listen(8888) + await asyncio.Event().wait() - This class is intended to let other frameworks (Django, web.py, etc) - run on the Tornado HTTP server and I/O loop. + asyncio.run(main()) + + The recommended pattern is to use the `tornado.routing` module to set up routing + rules between your WSGI application and, typically, a `tornado.web.Application`. + Alternatively, `tornado.web.Application` can be used as the top-level router + and `tornado.web.FallbackHandler` can embed a `WSGIContainer` within it. + + If the ``executor`` argument is provided, the WSGI application will be executed + on that executor. This must be an instance of `concurrent.futures.Executor`, + typically a ``ThreadPoolExecutor`` (``ProcessPoolExecutor`` is not supported). + If no ``executor`` is given, the application will run on the event loop thread in + Tornado 6.3; this will change to use an internal thread pool by default in + Tornado 7.0. - The `tornado.web.FallbackHandler` class is often useful for mixing - Tornado and WSGI apps in the same server. See - https://github.com/bdarnell/django-tornado-demo for a complete example. + .. warning:: + By default, the WSGI application is executed on the event loop's thread. This + limits the server to one request at a time (per process), making it less scalable + than most other WSGI servers. It is therefore highly recommended that you pass + a ``ThreadPoolExecutor`` when constructing the `WSGIContainer`, after verifying + that your application is thread-safe. The default will change to use a + ``ThreadPoolExecutor`` in Tornado 7.0. + + .. versionadded:: 6.3 + The ``executor`` parameter. + + .. deprecated:: 6.3 + The default behavior of running the WSGI application on the event loop thread + is deprecated and will change in Tornado 7.0 to use a thread pool by default. """ - def __init__(self, wsgi_application: "WSGIAppType") -> None: + def __init__( + self, + wsgi_application: "WSGIAppType", + executor: Optional[concurrent.futures.Executor] = None, + ) -> None: self.wsgi_application = wsgi_application + self.executor = dummy_executor if executor is None else executor def __call__(self, request: httputil.HTTPServerRequest) -> None: + IOLoop.current().spawn_callback(self.handle_request, request) + + async def handle_request(self, request: httputil.HTTPServerRequest) -> None: data = {} # type: Dict[str, Any] response = [] # type: List[bytes] @@ -110,15 +152,33 @@ def start_response( data["headers"] = headers return response.append - app_response = self.wsgi_application( - WSGIContainer.environ(request), start_response + loop = IOLoop.current() + app_response = await loop.run_in_executor( + self.executor, + self.wsgi_application, + self.environ(request), + start_response, ) try: - response.extend(app_response) - body = b"".join(response) + app_response_iter = iter(app_response) + + def next_chunk() -> Optional[bytes]: + try: + return next(app_response_iter) + except StopIteration: + # StopIteration is special and is not allowed to pass through + # coroutines normally. + return None + + while True: + chunk = await loop.run_in_executor(self.executor, next_chunk) + if chunk is None: + break + response.append(chunk) finally: if hasattr(app_response, "close"): app_response.close() # type: ignore + body = b"".join(response) if not data: raise Exception("WSGI app did not call start_response") @@ -144,9 +204,11 @@ def start_response( request.connection.finish() self._log(status_code, request) - @staticmethod - def environ(request: httputil.HTTPServerRequest) -> Dict[Text, Any]: + def environ(self, request: httputil.HTTPServerRequest) -> Dict[Text, Any]: """Converts a `tornado.httputil.HTTPServerRequest` to a WSGI environment. + + .. versionchanged:: 6.3 + No longer a static method. """ hostport = request.host.split(":") if len(hostport) == 2: @@ -170,7 +232,7 @@ def environ(request: httputil.HTTPServerRequest) -> Dict[Text, Any]: "wsgi.url_scheme": request.protocol, "wsgi.input": BytesIO(escape.utf8(request.body)), "wsgi.errors": sys.stderr, - "wsgi.multithread": False, + "wsgi.multithread": self.executor is not dummy_executor, "wsgi.multiprocess": True, "wsgi.run_once": False, } @@ -192,7 +254,14 @@ def _log(self, status_code: int, request: httputil.HTTPServerRequest) -> None: request_time = 1000.0 * request.request_time() assert request.method is not None assert request.uri is not None - summary = request.method + " " + request.uri + " (" + request.remote_ip + ")" + summary = ( + request.method # type: ignore[operator] + + " " + + request.uri + + " (" + + request.remote_ip + + ")" + ) log_method("%d %s %.2fms", status_code, summary, request_time) diff --git a/tox.ini b/tox.ini index 8976cab3d4..7eaa5bd838 100644 --- a/tox.ini +++ b/tox.ini @@ -13,7 +13,7 @@ [tox] envlist = # Basic configurations: Run the tests for each python version. - py35-full,py36-full,py37-full,py38-full,py39-full,pypy3-full + py38-full,py39-full,py310-full,py311-full,pypy3-full # Build and test the docs with sphinx. docs @@ -21,38 +21,34 @@ envlist = # Run the linters. lint -# Allow shell commands in tests -whitelist_externals = /bin/sh - [testenv] basepython = py3: python3 - py35: python3.5 - py36: python3.6 - py37: python3.7 py38: python3.8 py39: python3.9 + py310: python3.10 + py311: python3.11 + py312: python3.12 pypy3: pypy3 # In theory, it doesn't matter which python version is used here. # In practice, things like changes to the ast module can alter # the outputs of the tools (especially where exactly the # linter warning-suppression comments go), so we specify a # python version for these builds. - docs: python3.8 - lint: python3.8 + # These versions must be synced with the versions in .github/workflows/test.yml + docs: python3.11 + lint: python3.11 deps = full: pycurl full: twisted full: pycares - docs: -r{toxinidir}/docs/requirements.txt - lint: flake8 - lint: black==19.10b0 - lint: mypy==0.740 + docs: -r{toxinidir}/requirements.txt + lint: -r{toxinidir}/requirements.txt setenv = # Treat the extension as mandatory in testing (but not on pypy) - {py3,py36,py37,py38,py39}: TORNADO_EXTENSION=1 + {py3,py38,py39,py310,py311,py312}: TORNADO_EXTENSION=1 # CI workers are often overloaded and can cause our tests to exceed # the default timeout of 5s. ASYNC_TEST_TIMEOUT=25 @@ -64,9 +60,14 @@ setenv = # during sdist installation (and it doesn't seem to be # possible to set environment variables during that phase of # tox). - # ResourceWarnings are too noisy on py35 so don't enable - # warnings-as-errors there. - {py3,py36,py37,py38,py39,pypy3}: PYTHONWARNINGS=error:::tornado + {py3,py38,py39,py310,py311,pypy3}: PYTHONWARNINGS=error:::tornado + # Warn if we try to open a file with an unspecified encoding. + # (New in python 3.10, becomes obsolete when utf8 becomes the + # default in 3.15) + PYTHONWARNDEFAULTENCODING=1 + +# Allow shell commands in tests +allowlist_externals = sh # All non-comment lines but the last must end in a backslash. @@ -80,33 +81,17 @@ commands = # the trap of relying on an assertion's side effects or using # them for things that should be runtime errors. full: python -O -m tornado.test - # In python 3, opening files in text mode uses a - # system-dependent encoding by default. Run the tests with "C" - # (ascii) and "utf-8" locales to ensure we don't have hidden - # dependencies on this setting. - full: sh -c 'LANG=C python -m tornado.test' - full: sh -c 'LANG=en_US.utf-8 python -m tornado.test' # Note that httpclient_test is always run with both client # implementations; this flag controls which client all the # other tests use. full: python -m tornado.test --httpclient=tornado.curl_httpclient.CurlAsyncHTTPClient full: python -m tornado.test --resolver=tornado.platform.caresresolver.CaresResolver - # Run the tests once from the source directory to detect issues - # involving relative __file__ paths; see - # https://github.com/tornadoweb/tornado/issues/1780 - full: sh -c '(cd {toxinidir} && unset TORNADO_EXTENSION && python -m tornado.test)' - # python will import relative to the current working directory by default, # so cd into the tox working directory to avoid picking up the working # copy of the files (especially important for the speedups module). changedir = {toxworkdir} -# tox 1.6 passes --pre to pip by default, which currently has problems -# installing pycurl (https://github.com/pypa/pip/issues/1405). -# Remove it (it's not a part of {opts}) to only install real releases. -install_command = pip install {opts} {packages} - [testenv:docs] changedir = docs # For some reason the extension fails to load in this configuration, @@ -123,5 +108,14 @@ commands = commands = flake8 {posargs:} black --check --diff {posargs:tornado demos} - mypy {posargs:tornado} + # Many syscalls are defined differently on linux and windows, + # so we have to typecheck both. + mypy --platform linux {posargs:tornado} + mypy --platform windows {posargs:tornado} + # We mainly lint on the oldest version of Python we support, since + # we're more likely to catch problems of accidentally depending on + # something new than of depending on something old and deprecated. + # But sometimes something we depend on gets removed so we should also + # test the newest version. + mypy --platform linux --python-version 3.12 {posargs:tornado} changedir = {toxinidir}