diff --git a/.gitignore b/.gitignore index cd5362e..a8e2994 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ build sdist .vscode +*.swp __pycache__/ # virtualenv diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7d6cfc3..aa5cd4c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.2.0 + rev: v3.4.0 hooks: - id: check-byte-order-marker - id: trailing-whitespace @@ -9,7 +9,7 @@ repos: args: [--remove] - id: check-yaml - repo: https://github.com/asottile/reorder_python_imports - rev: v2.3.5 + rev: v2.4.0 hooks: - id: reorder-python-imports args: ['--application-directories=.:src', --py3-plus] @@ -18,7 +18,7 @@ repos: hooks: - id: black - repo: https://gitlab.com/pycqa/flake8 - rev: 3.8.3 + rev: 3.8.4 hooks: - id: flake8 additional_dependencies: [flake8-bugbear] @@ -30,3 +30,7 @@ repos: files: ^(HOWTORELEASE.rst|README.rst)$ language: python additional_dependencies: [pygments, restructuredtext_lint] +- repo: https://github.com/myint/autoflake.git + rev: v1.4 + hooks: + - id: autoflake diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index f31a115..170d1af 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -102,8 +102,8 @@ To get a complete report of code sections not being touched by the test suite run ``pytest`` using ``coverage``. .. code-block:: text - - $ coverage run -m pytest + $ coverage run --concurrency=multiprocessing -m pytest + $ coverage combine $ coverage html Open ``htmlcov/index.html`` in your browser. diff --git a/docs/changelog.rst b/docs/changelog.rst index 21e36d2..d82f640 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -3,6 +3,13 @@ Changelog ========= +1.2.0 (UNRELEASED) +------------------ + +- Remove deprecated ``:meth:live_server.url`` +- fixture ``request_ctx is now deprecated`` + and will be removed in the future + 1.1.0 (2020-11-08) ------------------ diff --git a/docs/features.rst b/docs/features.rst index cc2677c..8253f54 100644 --- a/docs/features.rst +++ b/docs/features.rst @@ -27,8 +27,8 @@ Extension provides some sugar for your tests, such as: .. note:: - User-defined ``json`` attribute/method in application response class does - not overrides. So you can define your own response deserialization method: + User-defined ``json`` attribute/method in application response class will + not be overwritten. So you can define your own response deserialization method: .. code:: python @@ -83,9 +83,9 @@ An instance of ``app.test_client``. Typically refers to .. hint:: - During tests execution the request context has been pushed, e.g. - ``url_for``, ``session`` and other context bound objects are available - without context managers. + During test execution a request context will be automatically pushed + for you, so context-bound methods can be conveniently called (e.g. + ``url_for``, ``session``. Example: @@ -145,7 +145,7 @@ other headless browsers). ``--no-start-live-server`` - don’t start live server automatically `````````````````````````````````````````````````````````````````` -By default the server is starting automatically whenever you reference +By default the server will start automatically whenever you reference ``live_server`` fixture in your tests. But starting live server imposes some high costs on tests that need it when they may not be ready yet. To prevent that behaviour pass ``--no-start-live-server`` into your default options (for @@ -188,9 +188,11 @@ in your project's ``pytest.ini`` file):: addopts = --live-server-port=5000 -``request_ctx`` - request context +``request_ctx`` - request context (Deprecated) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +**This fixture is deprecated and will be removed in the future.** + The request context which contains all request relevant information. .. hint:: @@ -228,7 +230,7 @@ Common request methods are available through the internals of the `Flask API`_. Specifically, the API creates the default `flask.Flask.test_client`_ instance, which works like a regular `Werkzeug test client`_. -Example: +Examples: .. code:: python @@ -247,8 +249,6 @@ Example: assert res.status_code == 200 -Example: - .. code:: python def test_get_request(client, live_server): diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 315bde1..f66b4f0 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -31,9 +31,9 @@ Define your application fixture in ``conftest.py``: Step 3. Run your test suite --------------------------- -Use the ``py.test`` command to run your test suite:: +Use the ``pytest`` command to run your test suite:: - py.test + pytest .. note:: Test discovery. diff --git a/pytest_flask/_internal.py b/pytest_flask/_internal.py new file mode 100644 index 0000000..4b44fb9 --- /dev/null +++ b/pytest_flask/_internal.py @@ -0,0 +1,35 @@ +import functools +import warnings + + +def deprecated(reason): + """Decorator which can be used to mark function or method as deprecated. + It will result a warning being emmitted when the function is called.""" + + def decorator(func): + @functools.wraps(func) + def deprecated_call(*args, **kwargs): + warnings.simplefilter("always", DeprecationWarning) + warnings.warn(reason, DeprecationWarning, stacklevel=2) + warnings.simplefilter("default", DeprecationWarning) + return func(*args, **kwargs) + + return deprecated_call + + return decorator + + +def _rewrite_server_name(server_name, new_port): + """Rewrite server port in ``server_name`` with ``new_port`` value.""" + sep = ":" + if sep in server_name: + server_name, _ = server_name.split(sep, 1) + return sep.join((server_name, new_port)) + + +def _determine_scope(*, fixture_name, config): + return config.getini("live_server_scope") + + +def _make_accept_header(mimetype): + return [("Accept", mimetype)] diff --git a/pytest_flask/fixtures.py b/pytest_flask/fixtures.py index fc3326a..eb25861 100755 --- a/pytest_flask/fixtures.py +++ b/pytest_flask/fixtures.py @@ -1,33 +1,15 @@ #!/usr/bin/env python -import functools -import logging -import multiprocessing -import os -import signal import socket -import time import warnings import pytest from flask import _request_ctx_stack - -def deprecated(reason): - """Decorator which can be used to mark function or method as deprecated. - It will result a warning being emmitted when the function is called. - """ - - def decorator(func): - @functools.wraps(func) - def deprecated_call(*args, **kwargs): - warnings.simplefilter("always", DeprecationWarning) - warnings.warn(reason, DeprecationWarning, stacklevel=2) - warnings.simplefilter("default", DeprecationWarning) - return func(*args, **kwargs) - - return deprecated_call - - return decorator +from ._internal import _determine_scope +from ._internal import _make_accept_header +from ._internal import _rewrite_server_name +from ._internal import deprecated +from .live_server import LiveServer @pytest.fixture @@ -58,114 +40,7 @@ def test_login(self): request.cls.client = client -class LiveServer: - """The helper class used to manage a live server. Handles creation and - stopping application in a separate process. - - :param app: The application to run. - :param host: The host where to listen (default localhost). - :param port: The port to run application. - :param wait: The timeout after which test case is aborted if - application is not started. - """ - - def __init__(self, app, host, port, wait, clean_stop=False): - self.app = app - self.port = port - self.host = host - self.wait = wait - self.clean_stop = clean_stop - self._process = None - - def start(self): - """Start application in a separate process.""" - - def worker(app, host, port): - app.run(host=host, port=port, use_reloader=False, threaded=True) - - self._process = multiprocessing.Process( - target=worker, args=(self.app, self.host, self.port) - ) - self._process.daemon = True - self._process.start() - - keep_trying = True - start_time = time.time() - while keep_trying: - elapsed_time = time.time() - start_time - if elapsed_time > self.wait: - pytest.fail( - "Failed to start the server after {!s} " - "seconds.".format(self.wait) - ) - if self._is_ready(): - keep_trying = False - - def _is_ready(self): - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - try: - sock.connect((self.host, self.port)) - except socket.error: - ret = False - else: - ret = True - finally: - sock.close() - return ret - - @deprecated( - reason=( - 'The "live_server.url" method is deprecated and will ' - "be removed in the future. Please use " - 'the "flask.url_for" function instead.', - ) - ) - def url(self, url=""): - """Returns the complete url based on server options.""" - return "http://{host!s}:{port!s}{url!s}".format( - host=self.host, port=self.port, url=url - ) - - def stop(self): - """Stop application process.""" - if self._process: - if self.clean_stop and self._stop_cleanly(): - return - if self._process.is_alive(): - # If it's still alive, kill it - self._process.terminate() - - def _stop_cleanly(self, timeout=5): - """Attempts to stop the server cleanly by sending a SIGINT signal and waiting for - ``timeout`` seconds. - - :return: True if the server was cleanly stopped, False otherwise. - """ - try: - os.kill(self._process.pid, signal.SIGINT) - self._process.join(timeout) - return True - except Exception as ex: - logging.error("Failed to join the live server process: %r", ex) - return False - - def __repr__(self): - return "" % self.url() - - -def _rewrite_server_name(server_name, new_port): - """Rewrite server port in ``server_name`` with ``new_port`` value.""" - sep = ":" - if sep in server_name: - server_name, port = server_name.split(sep, 1) - return sep.join((server_name, new_port)) - - -def determine_scope(*, fixture_name, config): - return config.getini("live_server_scope") - - -@pytest.fixture(scope=determine_scope) +@pytest.fixture(scope=_determine_scope) def live_server(request, app, pytestconfig): """Run application in a separate process. @@ -223,6 +98,15 @@ def request_ctx(app): """The request context which contains all request relevant information, e.g. `session`, `g`, `flashes`, etc. """ + warnings.warn( + "In Werzeug 2.0.0, the Client request methods " + "(client.get, client.post) always return an instance of TestResponse. This " + "class provides a reference to the request object through 'response.request' " + "The fixture 'request_ctx' is deprecated and will be removed in the future, using TestResponse.request " + "is the prefered way.", + DeprecationWarning, + stacklevel=2, + ) return _request_ctx_stack.top @@ -231,10 +115,6 @@ def mimetype(request): return request.param -def _make_accept_header(mimetype): - return [("Accept", mimetype)] - - @pytest.fixture def accept_mimetype(mimetype): return _make_accept_header(mimetype) diff --git a/pytest_flask/live_server.py b/pytest_flask/live_server.py new file mode 100644 index 0000000..23a0371 --- /dev/null +++ b/pytest_flask/live_server.py @@ -0,0 +1,92 @@ +import logging +import multiprocessing +import os +import signal +import socket +import time + +import pytest + +from ._internal import deprecated + + +class LiveServer: + """The helper class used to manage a live server. Handles creation and + stopping application in a separate process. + + :param app: The application to run. + :param host: The host where to listen (default localhost). + :param port: The port to run application. + :param wait: The timeout after which test case is aborted if + application is not started. + """ + + def __init__(self, app, host, port, wait, clean_stop=False): + self.app = app + self.port = port + self.host = host + self.wait = wait + self.clean_stop = clean_stop + self._process = None + + def start(self): + """Start application in a separate process.""" + + def worker(app, host, port): + app.run(host=host, port=port, use_reloader=False, threaded=True) + + self._process = multiprocessing.Process( + target=worker, args=(self.app, self.host, self.port) + ) + self._process.daemon = True + self._process.start() + + keep_trying = True + start_time = time.time() + while keep_trying: + elapsed_time = time.time() - start_time + if elapsed_time > self.wait: + pytest.fail( + "Failed to start the server after {!s} " + "seconds.".format(self.wait) + ) + if self._is_ready(): + keep_trying = False + + def _is_ready(self): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.connect((self.host, self.port)) + except socket.error: + ret = False + else: + ret = True + finally: + sock.close() + return ret + + def stop(self): + """Stop application process.""" + if self._process: + if self.clean_stop and self._stop_cleanly(): + return + if self._process.is_alive(): + # If it's still alive, kill it + self._process.terminate() + + def _stop_cleanly(self, timeout=5): + """Attempts to stop the server cleanly by sending a SIGINT signal and waiting for + ``timeout`` seconds. + + :return: True if the server was cleanly stopped, False otherwise. + """ + try: + os.kill(self._process.pid, signal.SIGINT) + self._process.join(timeout) + return True + except Exception as ex: + logging.error("Failed to join the live server process: %r", ex) + return False + + def __repr__(self): + return "" % self.url() diff --git a/pytest_flask/plugin.py b/pytest_flask/plugin.py index 1b9c8b6..bb4bf59 100755 --- a/pytest_flask/plugin.py +++ b/pytest_flask/plugin.py @@ -26,28 +26,10 @@ class JSONResponse: """Mixin with testing helper methods for JSON responses.""" - @cached_property - def json(self): - """Try to deserialize response data (a string containing a valid JSON - document) to a Python object by passing it to the underlying - :mod:`flask.json` module. - """ - return json.loads(self.data) - def __eq__(self, other): if isinstance(other, int): return self.status_code == other - # even though the Python 2-specific code works on Python 3, keep the two versions - # separate so we can simplify the code once Python 2 support is dropped - if sys.version_info[0] == 2: - try: - super_eq = super().__eq__ - except AttributeError: - return NotImplemented - else: - return super_eq(other) - else: - return super().__eq__(other) + return super().__eq__(other) def __ne__(self, other): return not self == other @@ -91,6 +73,7 @@ def test_json(client): assert res.json == {'ping': 'pong'} """ + if "app" not in request.fixturenames: return diff --git a/pytest_flask/pytest_compat.py b/pytest_flask/pytest_compat.py index 315b4f1..1e2585c 100644 --- a/pytest_flask/pytest_compat.py +++ b/pytest_flask/pytest_compat.py @@ -2,4 +2,4 @@ def getfixturevalue(request, value): if hasattr(request, "getfixturevalue"): return request.getfixturevalue(value) - return request.getfuncargvalue(value) + return request.getfuncargvalue(value) # pragma: no cover diff --git a/requirements/test.txt b/requirements/test.txt index c8834c6..d5339bb 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,4 +1,4 @@ mock pylint -pytest-cov +coverage pytest-pep8 diff --git a/tests/conftest.py b/tests/conftest.py index 595f386..bc2a66c 100755 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,7 @@ from flask import Flask from flask import jsonify +from pytest_flask.fixtures import mimetype pytest_plugins = "pytester" diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py index 2d86c0c..a55fd98 100755 --- a/tests/test_fixtures.py +++ b/tests/test_fixtures.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python import pytest from flask import request from flask import url_for @@ -21,40 +20,24 @@ def test_request_ctx(self, app, request_ctx): assert request_ctx.app is app def test_request_ctx_is_kept_around(self, client): - client.get(url_for("index"), headers=[("X-Something", "42")]) - assert request.headers["X-Something"] == "42" - - -class TestJSONResponse: - def test_json_response(self, client, accept_json): - res = client.get(url_for("ping"), headers=accept_json) - assert res.json == {"ping": "pong"} - - def test_json_response_compare_to_status_code(self, client, accept_json): - assert client.get(url_for("ping"), headers=accept_json) == 200 - assert client.get("fake-route", headers=accept_json) == 404 - assert client.get("fake-route", headers=accept_json) != "404" - res = client.get(url_for("ping"), headers=accept_json) - assert res == res - - def test_mismatching_eq_comparison(self, client, accept_json): - with pytest.raises(AssertionError, match=r"Mismatch in status code"): - assert client.get("fake-route", headers=accept_json) == 200 - with pytest.raises(AssertionError, match=r"404 NOT FOUND"): - assert client.get("fake-route", headers=accept_json) == "200" - - def test_dont_rewrite_existing_implementation(self, app, accept_json): - class MyResponse(app.response_class): - @property - def json(self): - """What is the meaning of life, the universe and everything?""" - return 42 - - app.response_class = MyResponse - client = app.test_client() - - res = client.get(url_for("ping"), headers=accept_json) - assert res.json == 42 + res = client.get(url_for("index"), headers=[("X-Something", "42")]) + """In werkzeug 2.0.0 the test Client provides a new attribute 'request' + in the response class wich holds a reference to the request object that + produced the respective response, making instrospection easier""" + try: + assert res.request.headers["X-Something"] == "42" + except AttributeError: + """This is the conventional (pre 2.0.0) way of reaching the + request object, using flask.request global.""" + assert request.headers["X-Something"] == "42" + + def test_accept_mimetype(self, accept_mimetype): + mimestrings = [[("Accept", "application/json")], [("Accept", "text/html")]] + assert accept_mimetype in mimestrings + + def test_accept_any(self, accept_any): + mimestrings = [[("Accept", "*")], [("Accept", "*/*")]] + assert accept_any in mimestrings @pytest.mark.usefixtures("client_class") diff --git a/tests/test_internal.py b/tests/test_internal.py new file mode 100644 index 0000000..603d39c --- /dev/null +++ b/tests/test_internal.py @@ -0,0 +1,15 @@ +import pytest + +from pytest_flask._internal import deprecated + + +class TestInternal: + def test_deprecation_decorator(self, appdir): + @deprecated(reason="testing decorator") + def deprecated_fun(): + pass + + with pytest.warns(DeprecationWarning) as record: + deprecated_fun() + assert len(record) == 1 + assert record[0].message.args[0] == "testing decorator" diff --git a/tests/test_json_response.py b/tests/test_json_response.py new file mode 100644 index 0000000..370b1b7 --- /dev/null +++ b/tests/test_json_response.py @@ -0,0 +1,22 @@ +import pytest +from flask import Flask +from flask import url_for + + +class TestJSONResponse: + def test_json_response(self, client, accept_json): + res = client.get(url_for("ping"), headers=accept_json) + assert res.json == {"ping": "pong"} + + def test_json_response_compare_to_status_code(self, client, accept_json): + assert client.get(url_for("ping"), headers=accept_json) == 200 + assert client.get("fake-route", headers=accept_json) == 404 + assert client.get("fake-route", headers=accept_json) != "404" + res = client.get(url_for("ping"), headers=accept_json) + assert res == res + + def test_mismatching_eq_comparison(self, client, accept_json): + with pytest.raises(AssertionError, match=r"Mismatch in status code"): + assert client.get("fake-route", headers=accept_json) == 200 + with pytest.raises(AssertionError, match=r"404 NOT FOUND"): + assert client.get("fake-route", headers=accept_json) == "200" diff --git a/tests/test_live_server.py b/tests/test_live_server.py index 7d5d6d5..c0decab 100755 --- a/tests/test_live_server.py +++ b/tests/test_live_server.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python import os from urllib.request import urlopen @@ -18,17 +17,8 @@ def test_server_is_alive(self, live_server): assert live_server._process assert live_server._process.is_alive() - def test_server_url(self, live_server): - assert live_server.url() == "http://localhost:%d" % live_server.port - assert live_server.url("/ping") == "http://localhost:%d/ping" % live_server.port - - def test_server_url_is_deprecated(self, live_server): - assert pytest.deprecated_call(live_server.url) - def test_server_listening(self, live_server): - # need to test both external and external? why external here? - # res = urlopen(url_for('ping', _external=True)) - res = urlopen(live_server.url("/ping")) + res = urlopen(url_for("ping", _external=True)) assert res.code == 200 assert b"pong" in res.read() @@ -86,6 +76,12 @@ def test_a(live_server): result.stdout.fnmatch_lines(["*passed*"]) assert result.ret == 0 + def test_stop_cleanly_join_exception(self, appdir, live_server, caplog): + # timeout = 'a' here to force an exception when + # attempting to self._process.join() + assert not live_server._stop_cleanly(timeout="a") + assert "Failed to join" in caplog.text + @pytest.mark.parametrize("clean_stop", [True, False]) def test_clean_stop_live_server(self, appdir, monkeypatch, clean_stop): """Ensure the fixture is trying to cleanly stop the server. diff --git a/tests/test_markers.py b/tests/test_markers.py index 87dcc57..1186a1e 100755 --- a/tests/test_markers.py +++ b/tests/test_markers.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python import pytest from flask import Flask diff --git a/tests/test_response_overwriting.py b/tests/test_response_overwriting.py new file mode 100644 index 0000000..2e0dbb2 --- /dev/null +++ b/tests/test_response_overwriting.py @@ -0,0 +1,35 @@ +import pytest +from flask import Flask +from flask import jsonify +from flask import url_for + + +class TestResponseOverwriting: + """ + we overwrite the app fixture here so we can test + _monkeypatch_response_class (an autouse fixture) + will return the original response_class since a + json @property is already present in response_class + """ + + @pytest.fixture + def app(self): + app = Flask(__name__) + app.config["SECRET_KEY"] = "42" + + class MyResponse(app.response_class): + @property + def json(self): + return 49 + + @app.route("/ping") + def ping(): + return jsonify(ping="pong") + + app.response_class = MyResponse + + return app + + def test_dont_rewrite_existing_implementation(self, accept_json, client): + res = client.get(url_for("ping"), headers=accept_json) + assert res.json == 49 diff --git a/tox.ini b/tox.ini index abe2314..3a572f0 100644 --- a/tox.ini +++ b/tox.ini @@ -2,13 +2,11 @@ envlist = py{35,36,37,38,linting} - [pytest] norecursedirs = .git .tox env coverage docs pep8ignore = docs/conf.py ALL pep8maxlinelength = 119 -junit_family=xunit2 [testenv:dev] commands = @@ -31,13 +29,22 @@ deps = passenv = HOME LANG LC_ALL commands = - pytest --basetemp={envtmpdir} --confcutdir=tests \ - --junitxml=tests/junit.xml \ - --cov-report xml --cov pytest_flask \ - --cov-report=html \ - --cov-report term-missing \ - -ra \ - {posargs:tests} + coverage run -m pytest {posargs:tests} + coverage combine + coverage report + +[coverage:report] +fail_under=90 + +[coverage:run] +source=pytest_flask +concurrency=multiprocessing + +[tool:pytest] +addopts = + -v + --basetemp={envtmpdir} + --confcutdir=tests [testenv:pre] pip_pre=true @@ -47,7 +54,7 @@ commands = {[testenv]commands} [testenv:linting] skip_install = True -basepython = python3 +basepython = python3.7 deps = pre-commit>=1.11.0 commands = pre-commit run --all-files --show-diff-on-failure {posargs:}