plain.pytest 0.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plain_pytest-0.0.0/LICENSE +58 -0
- plain_pytest-0.0.0/PKG-INFO +41 -0
- plain_pytest-0.0.0/README.md +12 -0
- plain_pytest-0.0.0/plain/pytest/README.md +10 -0
- plain_pytest-0.0.0/plain/pytest/__init__.py +3 -0
- plain_pytest-0.0.0/plain/pytest/cli.py +48 -0
- plain_pytest-0.0.0/plain/pytest/pytest/__init__.py +0 -0
- plain_pytest-0.0.0/plain/pytest/pytest/fixtures.py +335 -0
- plain_pytest-0.0.0/plain/pytest/pytest/plugin.py +339 -0
- plain_pytest-0.0.0/plain/pytest/pytest/py.typed +0 -0
- plain_pytest-0.0.0/plain/pytest/testcases.py +473 -0
- plain_pytest-0.0.0/pyproject.toml +44 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
## Plain is released under the BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
BSD 3-Clause License
|
|
4
|
+
|
|
5
|
+
Copyright (c) 2023, Dropseed, LLC
|
|
6
|
+
|
|
7
|
+
Redistribution and use in source and binary forms, with or without
|
|
8
|
+
modification, are permitted provided that the following conditions are met:
|
|
9
|
+
|
|
10
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
11
|
+
list of conditions and the following disclaimer.
|
|
12
|
+
|
|
13
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
14
|
+
this list of conditions and the following disclaimer in the documentation
|
|
15
|
+
and/or other materials provided with the distribution.
|
|
16
|
+
|
|
17
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
18
|
+
contributors may be used to endorse or promote products derived from
|
|
19
|
+
this software without specific prior written permission.
|
|
20
|
+
|
|
21
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
22
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
23
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
24
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
25
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
26
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
27
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
28
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
29
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
30
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
## This package contains code forked from github.com/pytest-dev/pytest-django
|
|
34
|
+
|
|
35
|
+
Copyright (c) 2015-2018, pytest-django authors (see AUTHORS file)
|
|
36
|
+
All rights reserved.
|
|
37
|
+
|
|
38
|
+
Redistribution and use in source and binary forms, with or without
|
|
39
|
+
modification, are permitted provided that the following conditions are met:
|
|
40
|
+
|
|
41
|
+
* Redistributions of source code must retain the above copyright notice, this
|
|
42
|
+
list of conditions and the following disclaimer.
|
|
43
|
+
* Redistributions in binary form must reproduce the above copyright notice,
|
|
44
|
+
this list of conditions and the following disclaimer in the documentation
|
|
45
|
+
and/or other materials provided with the distribution.
|
|
46
|
+
* The names of its contributors may not be used to endorse or promote products
|
|
47
|
+
derived from this software without specific prior written permission.
|
|
48
|
+
|
|
49
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
50
|
+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
51
|
+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
52
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
|
53
|
+
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
|
54
|
+
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
|
55
|
+
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
|
56
|
+
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
57
|
+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
58
|
+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: plain.pytest
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary: Testing for Plain
|
|
5
|
+
Home-page: https://www.plainpackages.com/
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: django,saas,plain,framework
|
|
8
|
+
Author: Dave Gaeddert
|
|
9
|
+
Author-email: dave.gaeddert@dropseed.dev
|
|
10
|
+
Requires-Python: >=3.8,<4.0
|
|
11
|
+
Classifier: Environment :: Web Environment
|
|
12
|
+
Classifier: Framework :: Django
|
|
13
|
+
Classifier: Framework :: Django :: 4
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Requires-Dist: click (>=8.0.0)
|
|
24
|
+
Requires-Dist: pytest (>=7.0.0,<8.0.0)
|
|
25
|
+
Project-URL: Documentation, https://www.plainpackages.com/docs/
|
|
26
|
+
Project-URL: Repository, https://github.com/plainpackages/plain-pytest
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
<!-- This file is compiled from plain-pytest/plain/pytest/README.md. Do not edit this file directly. -->
|
|
30
|
+
|
|
31
|
+
## Testing - pytest
|
|
32
|
+
|
|
33
|
+
Write and run tests with pytest.
|
|
34
|
+
|
|
35
|
+
Django includes its own test runner and [unittest](https://docs.python.org/3/library/unittest.html#module-unittest) classes.
|
|
36
|
+
But a lot of people (myself included) prefer [pytest](https://docs.pytest.org/en/latest/contents.html).
|
|
37
|
+
|
|
38
|
+
In Plain I've removed the Django test runner and a lot of the implications that come with it.
|
|
39
|
+
There are a few utilities that remain to make testing easier,
|
|
40
|
+
and `plain-test` is a wrapper around `pytest`.
|
|
41
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!-- This file is compiled from plain-pytest/plain/pytest/README.md. Do not edit this file directly. -->
|
|
2
|
+
|
|
3
|
+
## Testing - pytest
|
|
4
|
+
|
|
5
|
+
Write and run tests with pytest.
|
|
6
|
+
|
|
7
|
+
Django includes its own test runner and [unittest](https://docs.python.org/3/library/unittest.html#module-unittest) classes.
|
|
8
|
+
But a lot of people (myself included) prefer [pytest](https://docs.pytest.org/en/latest/contents.html).
|
|
9
|
+
|
|
10
|
+
In Plain I've removed the Django test runner and a lot of the implications that come with it.
|
|
11
|
+
There are a few utilities that remain to make testing easier,
|
|
12
|
+
and `plain-test` is a wrapper around `pytest`.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
## Testing - pytest
|
|
2
|
+
|
|
3
|
+
Write and run tests with pytest.
|
|
4
|
+
|
|
5
|
+
Django includes its own test runner and [unittest](https://docs.python.org/3/library/unittest.html#module-unittest) classes.
|
|
6
|
+
But a lot of people (myself included) prefer [pytest](https://docs.pytest.org/en/latest/contents.html).
|
|
7
|
+
|
|
8
|
+
In Plain I've removed the Django test runner and a lot of the implications that come with it.
|
|
9
|
+
There are a few utilities that remain to make testing easier,
|
|
10
|
+
and `plain-test` is a wrapper around `pytest`.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from plain.runtime import settings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.command(
|
|
11
|
+
context_settings={
|
|
12
|
+
"ignore_unknown_options": True,
|
|
13
|
+
}
|
|
14
|
+
)
|
|
15
|
+
@click.argument("pytest_args", nargs=-1, type=click.UNPROCESSED)
|
|
16
|
+
def cli(pytest_args):
|
|
17
|
+
"""Run tests with pytest"""
|
|
18
|
+
|
|
19
|
+
plain_tmp_dir = str(settings.PLAIN_TEMP_PATH)
|
|
20
|
+
|
|
21
|
+
if not os.path.exists(os.path.join(plain_tmp_dir, ".gitignore")):
|
|
22
|
+
os.makedirs(plain_tmp_dir, exist_ok=True)
|
|
23
|
+
with open(os.path.join(plain_tmp_dir, ".gitignore"), "w") as f:
|
|
24
|
+
f.write("*\n")
|
|
25
|
+
|
|
26
|
+
# Turn deprecation warnings into errors
|
|
27
|
+
# if "-W" not in pytest_args:
|
|
28
|
+
# pytest_args = list(pytest_args) # Make sure it's a list instead of tuple
|
|
29
|
+
# pytest_args.append("-W")
|
|
30
|
+
# pytest_args.append("error::DeprecationWarning")
|
|
31
|
+
|
|
32
|
+
os.environ.setdefault("PLAIN_ENV", "test")
|
|
33
|
+
|
|
34
|
+
click.secho(f"Running pytest with PLAIN_ENV={os.environ['PLAIN_ENV']}", bold=True)
|
|
35
|
+
|
|
36
|
+
result = subprocess.run(
|
|
37
|
+
[
|
|
38
|
+
"pytest",
|
|
39
|
+
*pytest_args,
|
|
40
|
+
],
|
|
41
|
+
env={
|
|
42
|
+
**os.environ,
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if result.returncode:
|
|
47
|
+
# Can be invoked by pre-commit, so only exit if it fails
|
|
48
|
+
sys.exit(result.returncode)
|
|
File without changes
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""All pytest-plain fixtures"""
|
|
2
|
+
from collections.abc import Generator, Iterable
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from functools import partial
|
|
5
|
+
from typing import Any, Optional, Union
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from ..testcases import TestCase, TransactionTestCase
|
|
10
|
+
|
|
11
|
+
TYPE_CHECKING = False
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
import plain.runtime
|
|
16
|
+
|
|
17
|
+
_DjangoDbDatabases = Optional[Union["Literal['__all__']", Iterable[str]]]
|
|
18
|
+
# transaction, reset_sequences, databases, serialized_rollback
|
|
19
|
+
_DjangoDb = tuple[bool, bool, _DjangoDbDatabases, bool]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"plain_db_setup",
|
|
24
|
+
"db",
|
|
25
|
+
"transactional_db",
|
|
26
|
+
"plain_db_reset_sequences",
|
|
27
|
+
"plain_db_serialized_rollback",
|
|
28
|
+
"client",
|
|
29
|
+
"rf",
|
|
30
|
+
"settings",
|
|
31
|
+
"plain_assert_num_queries",
|
|
32
|
+
"plain_assert_max_num_queries",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.fixture(scope="session")
|
|
37
|
+
def plain_db_setup(
|
|
38
|
+
request,
|
|
39
|
+
plain_test_environment: None,
|
|
40
|
+
plain_db_blocker,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Top level fixture to ensure test databases are available"""
|
|
43
|
+
from plain.test.utils import setup_databases, teardown_databases
|
|
44
|
+
|
|
45
|
+
setup_databases_args = {}
|
|
46
|
+
|
|
47
|
+
if request.config.getvalue("reuse_db") and not request.config.getvalue("create_db"):
|
|
48
|
+
setup_databases_args["keepdb"] = True
|
|
49
|
+
|
|
50
|
+
with plain_db_blocker.unblock():
|
|
51
|
+
db_cfg = setup_databases(
|
|
52
|
+
verbosity=request.config.option.verbose,
|
|
53
|
+
interactive=False,
|
|
54
|
+
**setup_databases_args,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def teardown_database() -> None:
|
|
58
|
+
with plain_db_blocker.unblock():
|
|
59
|
+
try:
|
|
60
|
+
teardown_databases(db_cfg, verbosity=request.config.option.verbose)
|
|
61
|
+
except Exception as exc:
|
|
62
|
+
request.node.warn(
|
|
63
|
+
pytest.PytestWarning(
|
|
64
|
+
f"Error when trying to teardown test databases: {exc!r}"
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if not request.config.getvalue("reuse_db"):
|
|
69
|
+
request.addfinalizer(teardown_database)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.fixture()
|
|
73
|
+
def _plain_db_helper(
|
|
74
|
+
request,
|
|
75
|
+
plain_db_setup: None,
|
|
76
|
+
plain_db_blocker,
|
|
77
|
+
) -> None:
|
|
78
|
+
marker = request.node.get_closest_marker("plain_db")
|
|
79
|
+
if marker:
|
|
80
|
+
(
|
|
81
|
+
transactional,
|
|
82
|
+
reset_sequences,
|
|
83
|
+
databases,
|
|
84
|
+
serialized_rollback,
|
|
85
|
+
) = validate_plain_db(marker)
|
|
86
|
+
else:
|
|
87
|
+
(
|
|
88
|
+
transactional,
|
|
89
|
+
reset_sequences,
|
|
90
|
+
databases,
|
|
91
|
+
serialized_rollback,
|
|
92
|
+
) = (
|
|
93
|
+
False,
|
|
94
|
+
False,
|
|
95
|
+
None,
|
|
96
|
+
False,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
transactional = (
|
|
100
|
+
transactional or reset_sequences or ("transactional_db" in request.fixturenames)
|
|
101
|
+
)
|
|
102
|
+
reset_sequences = reset_sequences or (
|
|
103
|
+
"plain_db_reset_sequences" in request.fixturenames
|
|
104
|
+
)
|
|
105
|
+
serialized_rollback = serialized_rollback or (
|
|
106
|
+
"plain_db_serialized_rollback" in request.fixturenames
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
plain_db_blocker.unblock()
|
|
110
|
+
request.addfinalizer(plain_db_blocker.restore)
|
|
111
|
+
|
|
112
|
+
if transactional:
|
|
113
|
+
test_case_class = TransactionTestCase
|
|
114
|
+
else:
|
|
115
|
+
test_case_class = TestCase
|
|
116
|
+
|
|
117
|
+
_reset_sequences = reset_sequences
|
|
118
|
+
_serialized_rollback = serialized_rollback
|
|
119
|
+
_databases = databases
|
|
120
|
+
|
|
121
|
+
class PytestDjangoTestCase(test_case_class): # type: ignore[misc,valid-type]
|
|
122
|
+
reset_sequences = _reset_sequences
|
|
123
|
+
serialized_rollback = _serialized_rollback
|
|
124
|
+
if _databases is not None:
|
|
125
|
+
databases = _databases
|
|
126
|
+
|
|
127
|
+
PytestDjangoTestCase.setUpClass()
|
|
128
|
+
|
|
129
|
+
request.addfinalizer(PytestDjangoTestCase.doClassCleanups)
|
|
130
|
+
request.addfinalizer(PytestDjangoTestCase.tearDownClass)
|
|
131
|
+
|
|
132
|
+
test_case = PytestDjangoTestCase(methodName="__init__")
|
|
133
|
+
test_case._pre_setup()
|
|
134
|
+
request.addfinalizer(test_case._post_teardown)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def validate_plain_db(marker) -> "_DjangoDb":
|
|
138
|
+
"""Validate the plain_db marker.
|
|
139
|
+
|
|
140
|
+
It checks the signature and creates the ``transaction``,
|
|
141
|
+
``reset_sequences``, ``databases`` and ``serialized_rollback`` attributes on
|
|
142
|
+
the marker which will have the correct values.
|
|
143
|
+
|
|
144
|
+
Sequence reset and serialized_rollback are only allowed when combined with
|
|
145
|
+
transaction.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
def apifun(
|
|
149
|
+
transaction: bool = False,
|
|
150
|
+
reset_sequences: bool = False,
|
|
151
|
+
databases: "_DjangoDbDatabases" = None,
|
|
152
|
+
serialized_rollback: bool = False,
|
|
153
|
+
) -> "_DjangoDb":
|
|
154
|
+
return transaction, reset_sequences, databases, serialized_rollback
|
|
155
|
+
|
|
156
|
+
return apifun(*marker.args, **marker.kwargs)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ############### User visible fixtures ################
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@pytest.fixture()
|
|
163
|
+
def db(_plain_db_helper: None) -> None:
|
|
164
|
+
"""Require a plain test database.
|
|
165
|
+
|
|
166
|
+
This database will be setup with the default fixtures and will have
|
|
167
|
+
the transaction management disabled. At the end of the test the outer
|
|
168
|
+
transaction that wraps the test itself will be rolled back to undo any
|
|
169
|
+
changes to the database (in case the backend supports transactions).
|
|
170
|
+
This is more limited than the ``transactional_db`` fixture but
|
|
171
|
+
faster.
|
|
172
|
+
|
|
173
|
+
If both ``db`` and ``transactional_db`` are requested,
|
|
174
|
+
``transactional_db`` takes precedence.
|
|
175
|
+
"""
|
|
176
|
+
# The `_plain_db_helper` fixture checks if `db` is requested.
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@pytest.fixture()
|
|
180
|
+
def transactional_db(_plain_db_helper: None) -> None:
|
|
181
|
+
"""Require a plain test database with transaction support.
|
|
182
|
+
|
|
183
|
+
This will re-initialise the plain database for each test and is
|
|
184
|
+
thus slower than the normal ``db`` fixture.
|
|
185
|
+
|
|
186
|
+
If you want to use the database with transactions you must request
|
|
187
|
+
this resource.
|
|
188
|
+
|
|
189
|
+
If both ``db`` and ``transactional_db`` are requested,
|
|
190
|
+
``transactional_db`` takes precedence.
|
|
191
|
+
"""
|
|
192
|
+
# The `_plain_db_helper` fixture checks if `transactional_db` is requested.
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@pytest.fixture()
|
|
196
|
+
def plain_db_reset_sequences(
|
|
197
|
+
_plain_db_helper: None,
|
|
198
|
+
transactional_db: None,
|
|
199
|
+
) -> None:
|
|
200
|
+
"""Require a transactional test database with sequence reset support.
|
|
201
|
+
|
|
202
|
+
This requests the ``transactional_db`` fixture, and additionally
|
|
203
|
+
enforces a reset of all auto increment sequences. If the enquiring
|
|
204
|
+
test relies on such values (e.g. ids as primary keys), you should
|
|
205
|
+
request this resource to ensure they are consistent across tests.
|
|
206
|
+
"""
|
|
207
|
+
# The `_plain_db_helper` fixture checks if `plain_db_reset_sequences`
|
|
208
|
+
# is requested.
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@pytest.fixture()
|
|
212
|
+
def plain_db_serialized_rollback(
|
|
213
|
+
_plain_db_helper: None,
|
|
214
|
+
db: None,
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Require a test database with serialized rollbacks.
|
|
217
|
+
|
|
218
|
+
This requests the ``db`` fixture, and additionally performs rollback
|
|
219
|
+
emulation - serializes the database contents during setup and restores
|
|
220
|
+
it during teardown.
|
|
221
|
+
|
|
222
|
+
This fixture may be useful for transactional tests, so is usually combined
|
|
223
|
+
with ``transactional_db``, but can also be useful on databases which do not
|
|
224
|
+
support transactions.
|
|
225
|
+
|
|
226
|
+
Note that this will slow down that test suite by approximately 3x.
|
|
227
|
+
"""
|
|
228
|
+
# The `_plain_db_helper` fixture checks if `plain_db_serialized_rollback`
|
|
229
|
+
# is requested.
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@pytest.fixture()
|
|
233
|
+
def client() -> "plain.test.client.Client":
|
|
234
|
+
"""A Plain test client instance."""
|
|
235
|
+
from plain.test.client import Client
|
|
236
|
+
|
|
237
|
+
return Client()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@pytest.fixture()
|
|
241
|
+
def rf() -> "plain.test.client.RequestFactory":
|
|
242
|
+
"""RequestFactory instance"""
|
|
243
|
+
from plain.test.client import RequestFactory
|
|
244
|
+
|
|
245
|
+
return RequestFactory()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class SettingsWrapper:
|
|
249
|
+
_to_restore: list[Any] = []
|
|
250
|
+
|
|
251
|
+
def __delattr__(self, attr: str) -> None:
|
|
252
|
+
from plain.test import override_settings
|
|
253
|
+
|
|
254
|
+
override = override_settings()
|
|
255
|
+
override.enable()
|
|
256
|
+
from plain.runtime import settings
|
|
257
|
+
|
|
258
|
+
delattr(settings, attr)
|
|
259
|
+
|
|
260
|
+
self._to_restore.append(override)
|
|
261
|
+
|
|
262
|
+
def __setattr__(self, attr: str, value) -> None:
|
|
263
|
+
from plain.test import override_settings
|
|
264
|
+
|
|
265
|
+
override = override_settings(**{attr: value})
|
|
266
|
+
override.enable()
|
|
267
|
+
self._to_restore.append(override)
|
|
268
|
+
|
|
269
|
+
def __getattr__(self, attr: str):
|
|
270
|
+
from plain.runtime import settings
|
|
271
|
+
|
|
272
|
+
return getattr(settings, attr)
|
|
273
|
+
|
|
274
|
+
def finalize(self) -> None:
|
|
275
|
+
for override in reversed(self._to_restore):
|
|
276
|
+
override.disable()
|
|
277
|
+
|
|
278
|
+
del self._to_restore[:]
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@pytest.fixture()
|
|
282
|
+
def settings():
|
|
283
|
+
"""A Plain settings object which restores changes after the testrun"""
|
|
284
|
+
wrapper = SettingsWrapper()
|
|
285
|
+
yield wrapper
|
|
286
|
+
wrapper.finalize()
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@contextmanager
|
|
290
|
+
def _assert_num_queries(
|
|
291
|
+
config,
|
|
292
|
+
num: int,
|
|
293
|
+
exact: bool = True,
|
|
294
|
+
connection=None,
|
|
295
|
+
info=None,
|
|
296
|
+
) -> Generator["plain.test.utils.CaptureQueriesContext", None, None]:
|
|
297
|
+
from plain.test.utils import CaptureQueriesContext
|
|
298
|
+
|
|
299
|
+
if connection is None:
|
|
300
|
+
from plain.models import connection as conn
|
|
301
|
+
else:
|
|
302
|
+
conn = connection
|
|
303
|
+
|
|
304
|
+
verbose = config.getoption("verbose") > 0
|
|
305
|
+
with CaptureQueriesContext(conn) as context:
|
|
306
|
+
yield context
|
|
307
|
+
num_performed = len(context)
|
|
308
|
+
if exact:
|
|
309
|
+
failed = num != num_performed
|
|
310
|
+
else:
|
|
311
|
+
failed = num_performed > num
|
|
312
|
+
if failed:
|
|
313
|
+
msg = f"Expected to perform {num} queries "
|
|
314
|
+
if not exact:
|
|
315
|
+
msg += "or less "
|
|
316
|
+
verb = "was" if num_performed == 1 else "were"
|
|
317
|
+
msg += f"but {num_performed} {verb} done"
|
|
318
|
+
if info:
|
|
319
|
+
msg += f"\n{info}"
|
|
320
|
+
if verbose:
|
|
321
|
+
sqls = (q["sql"] for q in context.captured_queries)
|
|
322
|
+
msg += "\n\nQueries:\n========\n\n" + "\n\n".join(sqls)
|
|
323
|
+
else:
|
|
324
|
+
msg += " (add -v option to show queries)"
|
|
325
|
+
pytest.fail(msg)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@pytest.fixture()
|
|
329
|
+
def plain_assert_num_queries(pytestconfig):
|
|
330
|
+
return partial(_assert_num_queries, pytestconfig)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@pytest.fixture()
|
|
334
|
+
def plain_assert_max_num_queries(pytestconfig):
|
|
335
|
+
return partial(_assert_num_queries, pytestconfig, exact=False)
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""A pytest plugin which helps testing Plain applications
|
|
2
|
+
|
|
3
|
+
This plugin handles creating and destroying the test environment and
|
|
4
|
+
test database and provides some useful text fixtures.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from .fixtures import (
|
|
11
|
+
_plain_db_helper, # noqa
|
|
12
|
+
plain_assert_max_num_queries, # noqa
|
|
13
|
+
plain_assert_num_queries, # noqa
|
|
14
|
+
plain_db_reset_sequences, # noqa
|
|
15
|
+
plain_db_serialized_rollback, # noqa
|
|
16
|
+
plain_db_setup, # noqa
|
|
17
|
+
client, # noqa
|
|
18
|
+
db, # noqa
|
|
19
|
+
rf, # noqa
|
|
20
|
+
settings, # noqa
|
|
21
|
+
transactional_db, # noqa
|
|
22
|
+
validate_plain_db,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
TYPE_CHECKING = False
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from typing import ContextManager, NoReturn
|
|
28
|
+
|
|
29
|
+
import plain.runtime
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ############### pytest hooks ################
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.hookimpl()
|
|
36
|
+
def pytest_addoption(parser) -> None:
|
|
37
|
+
group = parser.getgroup("plain")
|
|
38
|
+
group.addoption(
|
|
39
|
+
"--reuse-db",
|
|
40
|
+
action="store_true",
|
|
41
|
+
dest="reuse_db",
|
|
42
|
+
default=False,
|
|
43
|
+
help="Re-use the testing database if it already exists, "
|
|
44
|
+
"and do not remove it when the test finishes.",
|
|
45
|
+
)
|
|
46
|
+
group.addoption(
|
|
47
|
+
"--create-db",
|
|
48
|
+
action="store_true",
|
|
49
|
+
dest="create_db",
|
|
50
|
+
default=False,
|
|
51
|
+
help="Re-create the database, even if it exists. This "
|
|
52
|
+
"option can be used to override --reuse-db.",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
parser.addini(
|
|
56
|
+
"plain_debug_mode",
|
|
57
|
+
"How to set the Plain DEBUG setting (default `False`). "
|
|
58
|
+
"Use `keep` to not override.",
|
|
59
|
+
default="False",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _setup_plain() -> None:
|
|
64
|
+
import plain.runtime
|
|
65
|
+
|
|
66
|
+
plain.runtime.setup()
|
|
67
|
+
|
|
68
|
+
_blocking_manager.block()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _get_boolean_value(
|
|
72
|
+
x: None | bool | str,
|
|
73
|
+
name: str,
|
|
74
|
+
default: bool | None = None,
|
|
75
|
+
) -> bool:
|
|
76
|
+
if x is None:
|
|
77
|
+
return bool(default)
|
|
78
|
+
if isinstance(x, bool):
|
|
79
|
+
return x
|
|
80
|
+
possible_values = {"true": True, "false": False, "1": True, "0": False}
|
|
81
|
+
try:
|
|
82
|
+
return possible_values[x.lower()]
|
|
83
|
+
except KeyError:
|
|
84
|
+
possible = ", ".join(possible_values)
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"{x} is not a valid value for {name}. It must be one of {possible}."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@pytest.hookimpl()
|
|
91
|
+
def pytest_load_initial_conftests(
|
|
92
|
+
early_config,
|
|
93
|
+
parser,
|
|
94
|
+
args: list[str],
|
|
95
|
+
) -> None:
|
|
96
|
+
# Register the marks
|
|
97
|
+
early_config.addinivalue_line(
|
|
98
|
+
"markers",
|
|
99
|
+
"plain_db(transaction=False, reset_sequences=False, databases=None, "
|
|
100
|
+
"serialized_rollback=False): "
|
|
101
|
+
"Mark the test as using the Plain test database. "
|
|
102
|
+
"The *transaction* argument allows you to use real transactions "
|
|
103
|
+
"in the test like Plain's TransactionTestCase. "
|
|
104
|
+
"The *reset_sequences* argument resets database sequences before "
|
|
105
|
+
"the test. "
|
|
106
|
+
"The *databases* argument sets which database aliases the test "
|
|
107
|
+
"uses (by default, only 'default'). Use '__all__' for all databases. "
|
|
108
|
+
"The *serialized_rollback* argument enables rollback emulation for "
|
|
109
|
+
"the test.",
|
|
110
|
+
)
|
|
111
|
+
early_config.addinivalue_line(
|
|
112
|
+
"markers",
|
|
113
|
+
"urls(modstr): Use a different URLconf for this test, similar to "
|
|
114
|
+
"the `urls` attribute of Plain's `TestCase` objects. *modstr* is "
|
|
115
|
+
"a string specifying the module of a URL config, e.g. "
|
|
116
|
+
'"my_app.test_urls".',
|
|
117
|
+
)
|
|
118
|
+
early_config.addinivalue_line(
|
|
119
|
+
"markers",
|
|
120
|
+
"ignore_template_errors(): ignore errors from invalid template "
|
|
121
|
+
"variables (if --fail-on-template-vars is used).",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
options = parser.parse_known_args(args)
|
|
125
|
+
|
|
126
|
+
if options.version or options.help:
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
_setup_plain()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@pytest.hookimpl(trylast=True)
|
|
133
|
+
def pytest_configure() -> None:
|
|
134
|
+
# Allow Plain settings to be configured in a user pytest_configure call,
|
|
135
|
+
# but make sure we call plain.setup()
|
|
136
|
+
_setup_plain()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@pytest.hookimpl(tryfirst=True)
|
|
140
|
+
def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
|
|
141
|
+
def get_order_number(test: pytest.Item) -> int:
|
|
142
|
+
marker_db = test.get_closest_marker("plain_db")
|
|
143
|
+
if marker_db:
|
|
144
|
+
(
|
|
145
|
+
transaction,
|
|
146
|
+
reset_sequences,
|
|
147
|
+
databases,
|
|
148
|
+
serialized_rollback,
|
|
149
|
+
) = validate_plain_db(marker_db)
|
|
150
|
+
uses_db = True
|
|
151
|
+
transactional = transaction or reset_sequences
|
|
152
|
+
else:
|
|
153
|
+
uses_db = False
|
|
154
|
+
transactional = False
|
|
155
|
+
fixtures = getattr(test, "fixturenames", [])
|
|
156
|
+
transactional = transactional or "transactional_db" in fixtures
|
|
157
|
+
uses_db = uses_db or "db" in fixtures
|
|
158
|
+
|
|
159
|
+
if transactional:
|
|
160
|
+
return 1
|
|
161
|
+
elif uses_db:
|
|
162
|
+
return 0
|
|
163
|
+
else:
|
|
164
|
+
return 2
|
|
165
|
+
|
|
166
|
+
items.sort(key=get_order_number)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@pytest.fixture(autouse=True, scope="session")
|
|
170
|
+
def plain_test_environment(request) -> None:
|
|
171
|
+
"""
|
|
172
|
+
Ensure that Plain is loaded and has its testing environment setup.
|
|
173
|
+
|
|
174
|
+
XXX It is a little dodgy that this is an autouse fixture. Perhaps
|
|
175
|
+
an email fixture should be requested in order to be able to
|
|
176
|
+
use the Plain email machinery just like you need to request a
|
|
177
|
+
db fixture for access to the Plain database, etc. But
|
|
178
|
+
without duplicating a lot more of Plain's test support code
|
|
179
|
+
we need to follow this model.
|
|
180
|
+
"""
|
|
181
|
+
_setup_plain()
|
|
182
|
+
from plain.test.utils import (
|
|
183
|
+
setup_test_environment,
|
|
184
|
+
teardown_test_environment,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
debug_ini = request.config.getini("plain_debug_mode")
|
|
188
|
+
if debug_ini == "keep":
|
|
189
|
+
debug = None
|
|
190
|
+
else:
|
|
191
|
+
debug = _get_boolean_value(debug_ini, "plain_debug_mode", False)
|
|
192
|
+
|
|
193
|
+
setup_test_environment(debug=debug)
|
|
194
|
+
request.addfinalizer(teardown_test_environment)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@pytest.fixture(scope="session")
|
|
198
|
+
def plain_db_blocker() -> "_DatabaseBlocker | None":
|
|
199
|
+
"""Wrapper around Plain's database access.
|
|
200
|
+
|
|
201
|
+
This object can be used to re-enable database access. This fixture is used
|
|
202
|
+
internally in pytest-plain to build the other fixtures and can be used for
|
|
203
|
+
special database handling.
|
|
204
|
+
|
|
205
|
+
The object is a context manager and provides the methods
|
|
206
|
+
.unblock()/.block() and .restore() to temporarily enable database access.
|
|
207
|
+
|
|
208
|
+
This is an advanced feature that is meant to be used to implement database
|
|
209
|
+
fixtures.
|
|
210
|
+
"""
|
|
211
|
+
return _blocking_manager
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@pytest.fixture(autouse=True)
|
|
215
|
+
def _plain_db_marker(request) -> None:
|
|
216
|
+
"""Implement the plain_db marker, internal to pytest-plain."""
|
|
217
|
+
marker = request.node.get_closest_marker("plain_db")
|
|
218
|
+
if marker:
|
|
219
|
+
request.getfixturevalue("_plain_db_helper")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@pytest.fixture(autouse=True)
|
|
223
|
+
def _dj_autoclear_mailbox() -> None:
|
|
224
|
+
try:
|
|
225
|
+
from plain import mail
|
|
226
|
+
|
|
227
|
+
del mail.outbox[:]
|
|
228
|
+
except ImportError:
|
|
229
|
+
pass
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@pytest.fixture()
|
|
233
|
+
def mailoutbox(
|
|
234
|
+
plain_mail_patch_dns: None,
|
|
235
|
+
_dj_autoclear_mailbox: None,
|
|
236
|
+
) -> "list[plain.mail.EmailMessage] | None":
|
|
237
|
+
try:
|
|
238
|
+
from plain import mail
|
|
239
|
+
|
|
240
|
+
return mail.outbox
|
|
241
|
+
except ImportError:
|
|
242
|
+
pass
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@pytest.fixture()
|
|
246
|
+
def plain_mail_patch_dns(
|
|
247
|
+
monkeypatch,
|
|
248
|
+
plain_mail_dnsname: str,
|
|
249
|
+
) -> None:
|
|
250
|
+
try:
|
|
251
|
+
from plain import mail
|
|
252
|
+
|
|
253
|
+
monkeypatch.setattr(mail.message, "DNS_NAME", plain_mail_dnsname)
|
|
254
|
+
except ImportError:
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@pytest.fixture()
|
|
259
|
+
def plain_mail_dnsname() -> str:
|
|
260
|
+
return "fake-tests.example.com"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ############### Helper Functions ################
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class _DatabaseBlockerContextManager:
|
|
267
|
+
def __init__(self, db_blocker) -> None:
|
|
268
|
+
self._db_blocker = db_blocker
|
|
269
|
+
|
|
270
|
+
def __enter__(self) -> None:
|
|
271
|
+
pass
|
|
272
|
+
|
|
273
|
+
def __exit__(self, exc_type, exc_value, traceback) -> None:
|
|
274
|
+
self._db_blocker.restore()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class _DatabaseBlocker:
|
|
278
|
+
"""Manager for plain.models.backends.base.base.BaseDatabaseWrapper.
|
|
279
|
+
|
|
280
|
+
This is the object returned by plain_db_blocker.
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
def __init__(self):
|
|
284
|
+
self._history = []
|
|
285
|
+
self._real_ensure_connection = None
|
|
286
|
+
|
|
287
|
+
@property
|
|
288
|
+
def _dj_db_wrapper(self) -> "plain.models.backends.base.base.BaseDatabaseWrapper":
|
|
289
|
+
from plain.models.backends.base.base import BaseDatabaseWrapper
|
|
290
|
+
|
|
291
|
+
# The first time the _dj_db_wrapper is accessed, we will save a
|
|
292
|
+
# reference to the real implementation.
|
|
293
|
+
if self._real_ensure_connection is None:
|
|
294
|
+
self._real_ensure_connection = BaseDatabaseWrapper.ensure_connection
|
|
295
|
+
|
|
296
|
+
return BaseDatabaseWrapper
|
|
297
|
+
|
|
298
|
+
def _save_active_wrapper(self) -> None:
|
|
299
|
+
self._history.append(self._dj_db_wrapper.ensure_connection)
|
|
300
|
+
|
|
301
|
+
def _blocking_wrapper(*args, **kwargs) -> "NoReturn":
|
|
302
|
+
__tracebackhide__ = True
|
|
303
|
+
__tracebackhide__ # Silence pyflakes
|
|
304
|
+
raise RuntimeError(
|
|
305
|
+
"Database access not allowed, "
|
|
306
|
+
'use the "plain_db" mark, or the '
|
|
307
|
+
'"db" or "transactional_db" fixtures to enable it.'
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
def unblock(self) -> "ContextManager[None]":
|
|
311
|
+
"""Enable access to the Plain database."""
|
|
312
|
+
self._save_active_wrapper()
|
|
313
|
+
self._dj_db_wrapper.ensure_connection = self._real_ensure_connection
|
|
314
|
+
return _DatabaseBlockerContextManager(self)
|
|
315
|
+
|
|
316
|
+
def block(self) -> "ContextManager[None]":
|
|
317
|
+
"""Disable access to the Plain database."""
|
|
318
|
+
self._save_active_wrapper()
|
|
319
|
+
self._dj_db_wrapper.ensure_connection = self._blocking_wrapper
|
|
320
|
+
return _DatabaseBlockerContextManager(self)
|
|
321
|
+
|
|
322
|
+
def restore(self) -> None:
|
|
323
|
+
self._dj_db_wrapper.ensure_connection = self._history.pop()
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
_blocking_manager = _DatabaseBlocker()
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def validate_urls(marker) -> list[str]:
|
|
330
|
+
"""Validate the urls marker.
|
|
331
|
+
|
|
332
|
+
It checks the signature and creates the `urls` attribute on the
|
|
333
|
+
marker which will have the correct value.
|
|
334
|
+
"""
|
|
335
|
+
|
|
336
|
+
def apifun(urls: list[str]) -> list[str]:
|
|
337
|
+
return urls
|
|
338
|
+
|
|
339
|
+
return apifun(*marker.args, **marker.kwargs)
|
|
File without changes
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
import unittest
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from difflib import get_close_matches
|
|
6
|
+
from unittest.suite import _DebugResult
|
|
7
|
+
|
|
8
|
+
from plain.exceptions import ImproperlyConfigured
|
|
9
|
+
from plain.internal.legacy.management import call_command
|
|
10
|
+
from plain.internal.legacy.management.color import no_style
|
|
11
|
+
from plain.internal.legacy.management.sql import emit_post_migrate_signal
|
|
12
|
+
from plain.models import DEFAULT_DB_ALIAS, connections, transaction
|
|
13
|
+
from plain.packages import packages
|
|
14
|
+
from plain.test.client import Client
|
|
15
|
+
from plain.test.utils import (
|
|
16
|
+
modify_settings,
|
|
17
|
+
override_settings,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("plain.test")
|
|
21
|
+
|
|
22
|
+
__all__ = (
|
|
23
|
+
"TestCase",
|
|
24
|
+
"TransactionTestCase",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DatabaseOperationForbidden(AssertionError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _DatabaseFailure:
|
|
33
|
+
def __init__(self, wrapped, message):
|
|
34
|
+
self.wrapped = wrapped
|
|
35
|
+
self.message = message
|
|
36
|
+
|
|
37
|
+
def __call__(self):
|
|
38
|
+
raise DatabaseOperationForbidden(self.message)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TransactionTestCase(unittest.TestCase):
|
|
42
|
+
# The class we'll use for the test client self.client.
|
|
43
|
+
# Can be overridden in derived classes.
|
|
44
|
+
client_class = Client
|
|
45
|
+
_overridden_settings = None
|
|
46
|
+
_modified_settings = None
|
|
47
|
+
|
|
48
|
+
databases = set()
|
|
49
|
+
_disallowed_database_msg = (
|
|
50
|
+
"Database %(operation)s to %(alias)r are not allowed in SimpleTestCase "
|
|
51
|
+
"subclasses. Either subclass TestCase or TransactionTestCase to ensure "
|
|
52
|
+
"proper test isolation or add %(alias)r to %(test)s.databases to silence "
|
|
53
|
+
"this failure."
|
|
54
|
+
)
|
|
55
|
+
_disallowed_connection_methods = [
|
|
56
|
+
("connect", "connections"),
|
|
57
|
+
("temporary_connection", "connections"),
|
|
58
|
+
("cursor", "queries"),
|
|
59
|
+
("chunked_cursor", "queries"),
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
# Subclasses can ask for resetting of auto increment sequence before each
|
|
63
|
+
# test case
|
|
64
|
+
reset_sequences = False
|
|
65
|
+
|
|
66
|
+
# Subclasses can enable only a subset of packages for faster tests
|
|
67
|
+
available_packages = None
|
|
68
|
+
|
|
69
|
+
# Subclasses can define fixtures which will be automatically installed.
|
|
70
|
+
fixtures = None
|
|
71
|
+
|
|
72
|
+
databases = {DEFAULT_DB_ALIAS}
|
|
73
|
+
_disallowed_database_msg = (
|
|
74
|
+
"Database %(operation)s to %(alias)r are not allowed in this test. "
|
|
75
|
+
"Add %(alias)r to %(test)s.databases to ensure proper test isolation "
|
|
76
|
+
"and silence this failure."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# If transactions aren't available, Plain will serialize the database
|
|
80
|
+
# contents into a fixture during setup and flush and reload them
|
|
81
|
+
# during teardown (as flush does not restore data from migrations).
|
|
82
|
+
# This can be slow; this flag allows enabling on a per-case basis.
|
|
83
|
+
serialized_rollback = False
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def setUpClass(cls):
|
|
87
|
+
super().setUpClass()
|
|
88
|
+
if cls._overridden_settings:
|
|
89
|
+
cls._cls_overridden_context = override_settings(**cls._overridden_settings)
|
|
90
|
+
cls._cls_overridden_context.enable()
|
|
91
|
+
cls.addClassCleanup(cls._cls_overridden_context.disable)
|
|
92
|
+
if cls._modified_settings:
|
|
93
|
+
cls._cls_modified_context = modify_settings(cls._modified_settings)
|
|
94
|
+
cls._cls_modified_context.enable()
|
|
95
|
+
cls.addClassCleanup(cls._cls_modified_context.disable)
|
|
96
|
+
cls._add_databases_failures()
|
|
97
|
+
cls.addClassCleanup(cls._remove_databases_failures)
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def _validate_databases(cls):
|
|
101
|
+
if cls.databases == "__all__":
|
|
102
|
+
return frozenset(connections)
|
|
103
|
+
for alias in cls.databases:
|
|
104
|
+
if alias not in connections:
|
|
105
|
+
message = (
|
|
106
|
+
"{}.{}.databases refers to {!r} which is not defined in "
|
|
107
|
+
"settings.DATABASES.".format(
|
|
108
|
+
cls.__module__,
|
|
109
|
+
cls.__qualname__,
|
|
110
|
+
alias,
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
close_matches = get_close_matches(alias, list(connections))
|
|
114
|
+
if close_matches:
|
|
115
|
+
message += " Did you mean %r?" % close_matches[0]
|
|
116
|
+
raise ImproperlyConfigured(message)
|
|
117
|
+
return frozenset(cls.databases)
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def _add_databases_failures(cls):
|
|
121
|
+
cls.databases = cls._validate_databases()
|
|
122
|
+
for alias in connections:
|
|
123
|
+
if alias in cls.databases:
|
|
124
|
+
continue
|
|
125
|
+
connection = connections[alias]
|
|
126
|
+
for name, operation in cls._disallowed_connection_methods:
|
|
127
|
+
message = cls._disallowed_database_msg % {
|
|
128
|
+
"test": f"{cls.__module__}.{cls.__qualname__}",
|
|
129
|
+
"alias": alias,
|
|
130
|
+
"operation": operation,
|
|
131
|
+
}
|
|
132
|
+
method = getattr(connection, name)
|
|
133
|
+
setattr(connection, name, _DatabaseFailure(method, message))
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def _remove_databases_failures(cls):
|
|
137
|
+
for alias in connections:
|
|
138
|
+
if alias in cls.databases:
|
|
139
|
+
continue
|
|
140
|
+
connection = connections[alias]
|
|
141
|
+
for name, _ in cls._disallowed_connection_methods:
|
|
142
|
+
method = getattr(connection, name)
|
|
143
|
+
setattr(connection, name, method.wrapped)
|
|
144
|
+
|
|
145
|
+
def __call__(self, result=None):
|
|
146
|
+
"""
|
|
147
|
+
Wrapper around default __call__ method to perform common Plain test
|
|
148
|
+
set up. This means that user-defined Test Cases aren't required to
|
|
149
|
+
include a call to super().setUp().
|
|
150
|
+
"""
|
|
151
|
+
self._setup_and_call(result)
|
|
152
|
+
|
|
153
|
+
def debug(self):
|
|
154
|
+
"""Perform the same as __call__(), without catching the exception."""
|
|
155
|
+
debug_result = _DebugResult()
|
|
156
|
+
self._setup_and_call(debug_result, debug=True)
|
|
157
|
+
|
|
158
|
+
def _setup_and_call(self, result, debug=False):
|
|
159
|
+
"""
|
|
160
|
+
Perform the following in order: pre-setup, run test, post-teardown,
|
|
161
|
+
skipping pre/post hooks if test is set to be skipped.
|
|
162
|
+
|
|
163
|
+
If debug=True, reraise any errors in setup and use super().debug()
|
|
164
|
+
instead of __call__() to run the test.
|
|
165
|
+
"""
|
|
166
|
+
testMethod = getattr(self, self._testMethodName)
|
|
167
|
+
skipped = getattr(self.__class__, "__unittest_skip__", False) or getattr(
|
|
168
|
+
testMethod, "__unittest_skip__", False
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if not skipped:
|
|
172
|
+
try:
|
|
173
|
+
self._pre_setup()
|
|
174
|
+
except Exception:
|
|
175
|
+
if debug:
|
|
176
|
+
raise
|
|
177
|
+
result.addError(self, sys.exc_info())
|
|
178
|
+
return
|
|
179
|
+
if debug:
|
|
180
|
+
super().debug()
|
|
181
|
+
else:
|
|
182
|
+
super().__call__(result)
|
|
183
|
+
if not skipped:
|
|
184
|
+
try:
|
|
185
|
+
self._post_teardown()
|
|
186
|
+
except Exception:
|
|
187
|
+
if debug:
|
|
188
|
+
raise
|
|
189
|
+
result.addError(self, sys.exc_info())
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
def _pre_setup(self):
|
|
193
|
+
"""
|
|
194
|
+
Perform pre-test setup:
|
|
195
|
+
* Create a test client.
|
|
196
|
+
* Clear the mail test outbox.
|
|
197
|
+
"""
|
|
198
|
+
self.client = self.client_class()
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
from plain import mail
|
|
202
|
+
|
|
203
|
+
mail.outbox = []
|
|
204
|
+
except ImportError:
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
if self.available_packages is not None:
|
|
208
|
+
packages.set_available_packages(self.available_packages)
|
|
209
|
+
for db_name in self._databases_names(include_mirrors=False):
|
|
210
|
+
emit_post_migrate_signal(verbosity=0, interactive=False, db=db_name)
|
|
211
|
+
try:
|
|
212
|
+
self._fixture_setup()
|
|
213
|
+
except Exception:
|
|
214
|
+
if self.available_packages is not None:
|
|
215
|
+
packages.unset_available_packages()
|
|
216
|
+
raise
|
|
217
|
+
# Clear the queries_log so that it's less likely to overflow (a single
|
|
218
|
+
# test probably won't execute 9K queries). If queries_log overflows,
|
|
219
|
+
# then assertNumQueries() doesn't work.
|
|
220
|
+
for db_name in self._databases_names(include_mirrors=False):
|
|
221
|
+
connections[db_name].queries_log.clear()
|
|
222
|
+
|
|
223
|
+
def _post_teardown(self):
|
|
224
|
+
"""
|
|
225
|
+
Perform post-test things:
|
|
226
|
+
* Flush the contents of the database to leave a clean slate. If the
|
|
227
|
+
class has an 'available_packages' attribute, don't fire post_migrate.
|
|
228
|
+
* Force-close the connection so the next test gets a clean cursor.
|
|
229
|
+
"""
|
|
230
|
+
try:
|
|
231
|
+
self._fixture_teardown()
|
|
232
|
+
if self._should_reload_connections():
|
|
233
|
+
# Some DB cursors include SQL statements as part of cursor
|
|
234
|
+
# creation. If you have a test that does a rollback, the effect
|
|
235
|
+
# of these statements is lost, which can affect the operation of
|
|
236
|
+
# tests (e.g., losing a timezone setting causing objects to be
|
|
237
|
+
# created with the wrong time). To make sure this doesn't
|
|
238
|
+
# happen, get a clean connection at the start of every test.
|
|
239
|
+
for conn in connections.all(initialized_only=True):
|
|
240
|
+
conn.close()
|
|
241
|
+
finally:
|
|
242
|
+
if self.available_packages is not None:
|
|
243
|
+
packages.unset_available_packages()
|
|
244
|
+
|
|
245
|
+
def settings(self, **kwargs):
|
|
246
|
+
"""
|
|
247
|
+
A context manager that temporarily sets a setting and reverts to the
|
|
248
|
+
original value when exiting the context.
|
|
249
|
+
"""
|
|
250
|
+
return override_settings(**kwargs)
|
|
251
|
+
|
|
252
|
+
def modify_settings(self, **kwargs):
|
|
253
|
+
"""
|
|
254
|
+
A context manager that temporarily applies changes a list setting and
|
|
255
|
+
reverts back to the original value when exiting the context.
|
|
256
|
+
"""
|
|
257
|
+
return modify_settings(**kwargs)
|
|
258
|
+
|
|
259
|
+
@classmethod
|
|
260
|
+
def _databases_names(cls, include_mirrors=True):
|
|
261
|
+
# Only consider allowed database aliases, including mirrors or not.
|
|
262
|
+
return [
|
|
263
|
+
alias
|
|
264
|
+
for alias in connections
|
|
265
|
+
if alias in cls.databases
|
|
266
|
+
and (
|
|
267
|
+
include_mirrors
|
|
268
|
+
or not connections[alias].settings_dict["TEST"]["MIRROR"]
|
|
269
|
+
)
|
|
270
|
+
]
|
|
271
|
+
|
|
272
|
+
def _reset_sequences(self, db_name):
|
|
273
|
+
conn = connections[db_name]
|
|
274
|
+
if conn.features.supports_sequence_reset:
|
|
275
|
+
sql_list = conn.ops.sequence_reset_by_name_sql(
|
|
276
|
+
no_style(), conn.introspection.sequence_list()
|
|
277
|
+
)
|
|
278
|
+
if sql_list:
|
|
279
|
+
with transaction.atomic(using=db_name):
|
|
280
|
+
with conn.cursor() as cursor:
|
|
281
|
+
for sql in sql_list:
|
|
282
|
+
cursor.execute(sql)
|
|
283
|
+
|
|
284
|
+
def _fixture_setup(self):
|
|
285
|
+
for db_name in self._databases_names(include_mirrors=False):
|
|
286
|
+
# Reset sequences
|
|
287
|
+
if self.reset_sequences:
|
|
288
|
+
self._reset_sequences(db_name)
|
|
289
|
+
|
|
290
|
+
# Provide replica initial data from migrated packages, if needed.
|
|
291
|
+
# if self.serialized_rollback and hasattr(
|
|
292
|
+
# connections[db_name], "_test_serialized_contents"
|
|
293
|
+
# ):
|
|
294
|
+
# if self.available_packages is not None:
|
|
295
|
+
# packages.unset_available_packages()
|
|
296
|
+
# connections[db_name].creation.deserialize_db_from_string(
|
|
297
|
+
# connections[db_name]._test_serialized_contents
|
|
298
|
+
# )
|
|
299
|
+
# if self.available_packages is not None:
|
|
300
|
+
# packages.set_available_packages(self.available_packages)
|
|
301
|
+
|
|
302
|
+
if self.fixtures:
|
|
303
|
+
# We have to use this slightly awkward syntax due to the fact
|
|
304
|
+
# that we're using *args and **kwargs together.
|
|
305
|
+
call_command(
|
|
306
|
+
"loaddata", *self.fixtures, **{"verbosity": 0, "database": db_name}
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
def _should_reload_connections(self):
|
|
310
|
+
return True
|
|
311
|
+
|
|
312
|
+
def _fixture_teardown(self):
|
|
313
|
+
# Allow TRUNCATE ... CASCADE and don't emit the post_migrate signal
|
|
314
|
+
# when flushing only a subset of the packages
|
|
315
|
+
for db_name in self._databases_names(include_mirrors=False):
|
|
316
|
+
# Flush the database
|
|
317
|
+
inhibit_post_migrate = (
|
|
318
|
+
self.available_packages is not None
|
|
319
|
+
or ( # Inhibit the post_migrate signal when using serialized
|
|
320
|
+
# rollback to avoid trying to recreate the serialized data.
|
|
321
|
+
self.serialized_rollback
|
|
322
|
+
# and hasattr(connections[db_name], "_test_serialized_contents")
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
call_command(
|
|
326
|
+
"flush",
|
|
327
|
+
verbosity=0,
|
|
328
|
+
interactive=False,
|
|
329
|
+
database=db_name,
|
|
330
|
+
reset_sequences=False,
|
|
331
|
+
allow_cascade=self.available_packages is not None,
|
|
332
|
+
inhibit_post_migrate=inhibit_post_migrate,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def connections_support_transactions(aliases=None):
|
|
337
|
+
"""
|
|
338
|
+
Return whether or not all (or specified) connections support
|
|
339
|
+
transactions.
|
|
340
|
+
"""
|
|
341
|
+
conns = (
|
|
342
|
+
connections.all()
|
|
343
|
+
if aliases is None
|
|
344
|
+
else (connections[alias] for alias in aliases)
|
|
345
|
+
)
|
|
346
|
+
return all(conn.features.supports_transactions for conn in conns)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class TestCase(TransactionTestCase):
|
|
350
|
+
"""
|
|
351
|
+
Similar to TransactionTestCase, but use `transaction.atomic()` to achieve
|
|
352
|
+
test isolation.
|
|
353
|
+
|
|
354
|
+
In most situations, TestCase should be preferred to TransactionTestCase as
|
|
355
|
+
it allows faster execution. However, there are some situations where using
|
|
356
|
+
TransactionTestCase might be necessary (e.g. testing some transactional
|
|
357
|
+
behavior).
|
|
358
|
+
|
|
359
|
+
On database backends with no transaction support, TestCase behaves as
|
|
360
|
+
TransactionTestCase.
|
|
361
|
+
"""
|
|
362
|
+
|
|
363
|
+
@classmethod
|
|
364
|
+
def _enter_atomics(cls):
|
|
365
|
+
"""Open atomic blocks for multiple databases."""
|
|
366
|
+
atomics = {}
|
|
367
|
+
for db_name in cls._databases_names():
|
|
368
|
+
atomic = transaction.atomic(using=db_name)
|
|
369
|
+
atomic._from_testcase = True
|
|
370
|
+
atomic.__enter__()
|
|
371
|
+
atomics[db_name] = atomic
|
|
372
|
+
return atomics
|
|
373
|
+
|
|
374
|
+
@classmethod
|
|
375
|
+
def _rollback_atomics(cls, atomics):
|
|
376
|
+
"""Rollback atomic blocks opened by the previous method."""
|
|
377
|
+
for db_name in reversed(cls._databases_names()):
|
|
378
|
+
transaction.set_rollback(True, using=db_name)
|
|
379
|
+
atomics[db_name].__exit__(None, None, None)
|
|
380
|
+
|
|
381
|
+
@classmethod
|
|
382
|
+
def _databases_support_transactions(cls):
|
|
383
|
+
return connections_support_transactions(cls.databases)
|
|
384
|
+
|
|
385
|
+
@classmethod
|
|
386
|
+
def setUpClass(cls):
|
|
387
|
+
super().setUpClass()
|
|
388
|
+
if not cls._databases_support_transactions():
|
|
389
|
+
return
|
|
390
|
+
cls.cls_atomics = cls._enter_atomics()
|
|
391
|
+
|
|
392
|
+
if cls.fixtures:
|
|
393
|
+
for db_name in cls._databases_names(include_mirrors=False):
|
|
394
|
+
try:
|
|
395
|
+
call_command(
|
|
396
|
+
"loaddata",
|
|
397
|
+
*cls.fixtures,
|
|
398
|
+
**{"verbosity": 0, "database": db_name},
|
|
399
|
+
)
|
|
400
|
+
except Exception:
|
|
401
|
+
cls._rollback_atomics(cls.cls_atomics)
|
|
402
|
+
raise
|
|
403
|
+
|
|
404
|
+
@classmethod
|
|
405
|
+
def tearDownClass(cls):
|
|
406
|
+
if cls._databases_support_transactions():
|
|
407
|
+
cls._rollback_atomics(cls.cls_atomics)
|
|
408
|
+
for conn in connections.all(initialized_only=True):
|
|
409
|
+
conn.close()
|
|
410
|
+
super().tearDownClass()
|
|
411
|
+
|
|
412
|
+
def _should_reload_connections(self):
|
|
413
|
+
if self._databases_support_transactions():
|
|
414
|
+
return False
|
|
415
|
+
return super()._should_reload_connections()
|
|
416
|
+
|
|
417
|
+
def _fixture_setup(self):
|
|
418
|
+
if not self._databases_support_transactions():
|
|
419
|
+
return super()._fixture_setup()
|
|
420
|
+
|
|
421
|
+
if self.reset_sequences:
|
|
422
|
+
raise TypeError("reset_sequences cannot be used on TestCase instances")
|
|
423
|
+
self.atomics = self._enter_atomics()
|
|
424
|
+
|
|
425
|
+
def _fixture_teardown(self):
|
|
426
|
+
if not self._databases_support_transactions():
|
|
427
|
+
return super()._fixture_teardown()
|
|
428
|
+
try:
|
|
429
|
+
for db_name in reversed(self._databases_names()):
|
|
430
|
+
if self._should_check_constraints(connections[db_name]):
|
|
431
|
+
connections[db_name].check_constraints()
|
|
432
|
+
finally:
|
|
433
|
+
self._rollback_atomics(self.atomics)
|
|
434
|
+
|
|
435
|
+
def _should_check_constraints(self, connection):
|
|
436
|
+
return (
|
|
437
|
+
connection.features.can_defer_constraint_checks
|
|
438
|
+
and not connection.needs_rollback
|
|
439
|
+
and connection.is_usable()
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
@classmethod
|
|
443
|
+
@contextmanager
|
|
444
|
+
def captureOnCommitCallbacks(cls, *, using=DEFAULT_DB_ALIAS, execute=False):
|
|
445
|
+
"""Context manager to capture transaction.on_commit() callbacks."""
|
|
446
|
+
callbacks = []
|
|
447
|
+
start_count = len(connections[using].run_on_commit)
|
|
448
|
+
try:
|
|
449
|
+
yield callbacks
|
|
450
|
+
finally:
|
|
451
|
+
while True:
|
|
452
|
+
callback_count = len(connections[using].run_on_commit)
|
|
453
|
+
for _, callback, robust in connections[using].run_on_commit[
|
|
454
|
+
start_count:
|
|
455
|
+
]:
|
|
456
|
+
callbacks.append(callback)
|
|
457
|
+
if execute:
|
|
458
|
+
if robust:
|
|
459
|
+
try:
|
|
460
|
+
callback()
|
|
461
|
+
except Exception as e:
|
|
462
|
+
logger.error(
|
|
463
|
+
f"Error calling {callback.__qualname__} in "
|
|
464
|
+
f"on_commit() (%s).",
|
|
465
|
+
e,
|
|
466
|
+
exc_info=True,
|
|
467
|
+
)
|
|
468
|
+
else:
|
|
469
|
+
callback()
|
|
470
|
+
|
|
471
|
+
if callback_count == len(connections[using].run_on_commit):
|
|
472
|
+
break
|
|
473
|
+
start_count = callback_count
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
|
|
3
|
+
name = "plain.pytest"
|
|
4
|
+
packages = [
|
|
5
|
+
{ include = "plain" },
|
|
6
|
+
]
|
|
7
|
+
|
|
8
|
+
version = "0.0.0"
|
|
9
|
+
description = "Testing for Plain"
|
|
10
|
+
authors = ["Dave Gaeddert <dave.gaeddert@dropseed.dev>"]
|
|
11
|
+
license = "MIT"
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
homepage = "https://www.plainpackages.com/"
|
|
14
|
+
documentation = "https://www.plainpackages.com/docs/"
|
|
15
|
+
repository = "https://github.com/plainpackages/plain-pytest"
|
|
16
|
+
keywords = ["django", "saas", "plain", "framework"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Environment :: Web Environment",
|
|
19
|
+
"Framework :: Django",
|
|
20
|
+
"Framework :: Django :: 4",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
# Make the CLI available without adding to INSTALLED_APPS
|
|
26
|
+
[tool.poetry.plugins."plain.cli"]
|
|
27
|
+
"test" = "plain.pytest:cli"
|
|
28
|
+
|
|
29
|
+
# Automatically sets this up with pytest
|
|
30
|
+
[tool.poetry.plugins."pytest11"]
|
|
31
|
+
"plain" = "plain.pytest.pytest.plugin"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
[tool.poetry.dependencies]
|
|
35
|
+
python = "^3.8"
|
|
36
|
+
click = ">=8.0.0"
|
|
37
|
+
pytest = "^7.0.0"
|
|
38
|
+
|
|
39
|
+
[tool.poetry.dev-dependencies]
|
|
40
|
+
ipdb = "^0.13.9"
|
|
41
|
+
|
|
42
|
+
[build-system]
|
|
43
|
+
requires = ["poetry-core>=1.0.0"]
|
|
44
|
+
build-backend = "poetry.core.masonry.api"
|