pytest-aiohutils 0.22.4__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.
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pytest-aiohutils
|
|
3
|
+
Version: 0.22.4
|
|
4
|
+
Summary: Pytest plugin providing fixtures and configuration for aiohutils projects (offline, record, cleanup modes).
|
|
5
|
+
Author: 5j9
|
|
6
|
+
Author-email: 5j9 <5j9@users.noreply.github.com>
|
|
7
|
+
License: GNU General Public License v3 (GPLv3)
|
|
8
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Requires-Dist: aiohutils
|
|
11
|
+
Requires-Dist: pytest
|
|
12
|
+
Requires-Dist: pytest-asyncio
|
|
13
|
+
Requires-Dist: pydantic
|
|
14
|
+
Requires-Python: >=3.12
|
|
15
|
+
Project-URL: Homepage, https://github.com/5j9/pytest-aiohutils
|
|
16
|
+
Description-Content-Type: text/x-rst
|
|
17
|
+
|
|
18
|
+
This library is not intended for direct public use.
|
|
19
|
+
It is used as development dependency for the following projects:
|
|
20
|
+
|
|
21
|
+
* https://github.com/5j9/tsetmc
|
|
22
|
+
* https://github.com/5j9/fipiran
|
|
23
|
+
* https://github.com/5j9/iranetf
|
|
24
|
+
* https://github.com/5j9/aggregator
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ['uv_build>=0.8.3,<0.9.0']
|
|
3
|
+
build-backend = 'uv_build'
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pytest-aiohutils"
|
|
7
|
+
authors = [{ name = "5j9", email = "5j9@users.noreply.github.com" }]
|
|
8
|
+
license = { text = "GNU General Public License v3 (GPLv3)" }
|
|
9
|
+
description = "Pytest plugin providing fixtures and configuration for aiohutils projects (offline, record, cleanup modes)."
|
|
10
|
+
classifiers = [
|
|
11
|
+
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
|
12
|
+
"Programming Language :: Python :: 3.10",
|
|
13
|
+
]
|
|
14
|
+
requires-python = ">=3.12"
|
|
15
|
+
dependencies = [
|
|
16
|
+
'aiohutils',
|
|
17
|
+
'pytest',
|
|
18
|
+
'pytest-asyncio',
|
|
19
|
+
'pydantic',
|
|
20
|
+
]
|
|
21
|
+
version = "0.22.4"
|
|
22
|
+
|
|
23
|
+
[project.entry-points.pytest11]
|
|
24
|
+
aiohutils_plugin = "pytest_aiohutils"
|
|
25
|
+
|
|
26
|
+
[tool.uv.sources]
|
|
27
|
+
# The package name must match the project name
|
|
28
|
+
pytest-aiohutils = { workspace = true }
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
[tool.uv.build-backend]
|
|
32
|
+
module-root = ""
|
|
33
|
+
module-name = "pytest_aiohutils"
|
|
34
|
+
|
|
35
|
+
[project.readme]
|
|
36
|
+
file = "README.rst"
|
|
37
|
+
content-type = "text/x-rst"
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://github.com/5j9/pytest-aiohutils"
|
|
41
|
+
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
line-length = 79
|
|
44
|
+
format.quote-style = 'single'
|
|
45
|
+
lint.isort.combine-as-imports = true
|
|
46
|
+
lint.extend-select = [
|
|
47
|
+
'W605', # invalid-escape-sequence
|
|
48
|
+
'FA', # flake8-future-annotations
|
|
49
|
+
'I', # isort
|
|
50
|
+
'UP', # pyupgrade
|
|
51
|
+
'RUF', # Ruff-specific rules (RUF)
|
|
52
|
+
]
|
|
53
|
+
lint.ignore = [
|
|
54
|
+
'E721', # Do not compare types, use `isinstance()`
|
|
55
|
+
'RUF001', # ambiguous-unicode-character-string
|
|
56
|
+
'RUF002', # ambiguous-unicode-character-docstring
|
|
57
|
+
'RUF003', # ambiguous-unicode-character-comment
|
|
58
|
+
'RUF012', # mutable-class-default
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
[tool.pytest.ini_options]
|
|
62
|
+
addopts = '--quiet --tb=short'
|
|
63
|
+
|
|
64
|
+
[tool.pyright]
|
|
65
|
+
typeCheckingMode = 'standard'
|
|
66
|
+
reportDeprecated = "warning"
|
|
67
|
+
reportPropertyTypeMismatch = "warning"
|
|
68
|
+
reportUnnecessaryCast = "warning"
|
|
69
|
+
reportUnnecessaryContains = "warning"
|
|
70
|
+
reportUnnecessaryIsInstance = "warning"
|
|
71
|
+
reportUnnecessaryTypeIgnoreComment = true
|
|
72
|
+
reportInvalidStringEscapeSequence = false
|
|
73
|
+
reportConstantRedefinition = 'error'
|
|
74
|
+
reportTypeCommentUsage = 'warning'
|
|
75
|
+
reportUnnecessaryComparison = 'warning'
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
__version__ = '0.22.4'
|
|
2
|
+
import atexit
|
|
3
|
+
from collections.abc import Iterator, Mapping
|
|
4
|
+
from inspect import iscoroutinefunction
|
|
5
|
+
from itertools import cycle
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TypedDict
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
from aiohutils.session import ClientSession, SessionManager
|
|
11
|
+
from pydantic import ConfigDict
|
|
12
|
+
from pydantic.type_adapter import TypeAdapter
|
|
13
|
+
from pytest import Config, Function, Parser, StashKey, fixture
|
|
14
|
+
|
|
15
|
+
# Define a Stash Key for storing calculated configuration (Pytest idiomatic way)
|
|
16
|
+
CONFIG_KEY = StashKey[dict]()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def pytest_addoption(parser: Parser):
|
|
20
|
+
"""Registers the command line and ini-file options."""
|
|
21
|
+
group = parser.getgroup('testconfig')
|
|
22
|
+
group.addoption(
|
|
23
|
+
'--record',
|
|
24
|
+
action='store_true',
|
|
25
|
+
default=False,
|
|
26
|
+
dest='RECORD_MODE',
|
|
27
|
+
help='Enable record mode for tests (saves new responses).',
|
|
28
|
+
)
|
|
29
|
+
group.addoption(
|
|
30
|
+
'--offline',
|
|
31
|
+
action='store_true',
|
|
32
|
+
default=True,
|
|
33
|
+
dest='OFFLINE_MODE',
|
|
34
|
+
help='Enable offline mode (only uses saved data).',
|
|
35
|
+
)
|
|
36
|
+
group.addoption(
|
|
37
|
+
'--remove-unused-data',
|
|
38
|
+
action='store_true',
|
|
39
|
+
default=False,
|
|
40
|
+
dest='REMOVE_UNUSED_TESTDATA',
|
|
41
|
+
help='Remove test data files not used during the run.',
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_remove_unused_testdata: bool
|
|
46
|
+
testdata: Path
|
|
47
|
+
tests_path: Path
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def pytest_configure(config: Config):
|
|
51
|
+
"""Called after command line options are parsed and configuration is loaded."""
|
|
52
|
+
global tests_path, _remove_unused_testdata, testdata
|
|
53
|
+
|
|
54
|
+
RECORD_MODE = config.getoption('RECORD_MODE')
|
|
55
|
+
# Note: If RECORD_MODE is true, we always disable OFFLINE_MODE
|
|
56
|
+
OFFLINE_MODE = config.getoption('OFFLINE_MODE') and not RECORD_MODE
|
|
57
|
+
REMOVE_UNUSED_TESTDATA = _remove_unused_testdata = (
|
|
58
|
+
config.getoption('REMOVE_UNUSED_TESTDATA') and OFFLINE_MODE
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Store calculated config in the Pytest config stash
|
|
62
|
+
config.stash[CONFIG_KEY] = {
|
|
63
|
+
'RECORD_MODE': RECORD_MODE,
|
|
64
|
+
'OFFLINE_MODE': OFFLINE_MODE,
|
|
65
|
+
'REMOVE_UNUSED_TESTDATA': REMOVE_UNUSED_TESTDATA,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# Set the tests_path to the root of the test directory (e.g., /project_root/tests),
|
|
69
|
+
# using Path objects for clean, cross-platform path construction.
|
|
70
|
+
tests_path = Path(config.rootpath) / 'tests'
|
|
71
|
+
testdata = tests_path / 'testdata'
|
|
72
|
+
|
|
73
|
+
if _remove_unused_testdata:
|
|
74
|
+
atexit.register(remove_unused_testdata)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class TestConfig(TypedDict):
|
|
78
|
+
RECORD_MODE: bool
|
|
79
|
+
OFFLINE_MODE: bool
|
|
80
|
+
REMOVE_UNUSED_TESTDATA: bool
|
|
81
|
+
TESTS_PATH: Path
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@fixture(scope='session')
|
|
85
|
+
def test_config(request) -> TestConfig:
|
|
86
|
+
"""Provides test configuration variables to test functions."""
|
|
87
|
+
# Retrieve configuration from the Pytest Stash using the standard 'request' fixture
|
|
88
|
+
config_data = request.config.stash.get(CONFIG_KEY, {})
|
|
89
|
+
|
|
90
|
+
# Fallback in case fixture is called before pytest_configure for some reason
|
|
91
|
+
return {
|
|
92
|
+
'RECORD_MODE': config_data.get('RECORD_MODE', False),
|
|
93
|
+
'OFFLINE_MODE': config_data.get('OFFLINE_MODE', True),
|
|
94
|
+
'REMOVE_UNUSED_TESTDATA': config_data.get(
|
|
95
|
+
'REMOVE_UNUSED_TESTDATA', False
|
|
96
|
+
),
|
|
97
|
+
'TESTS_PATH': tests_path,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class EqualToEverything:
|
|
102
|
+
"""A placeholder object that always evaluates as equal."""
|
|
103
|
+
|
|
104
|
+
def __eq__(self, other):
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class FakeResponse:
|
|
109
|
+
"""A mock response object for offline mode."""
|
|
110
|
+
|
|
111
|
+
__slots__ = 'files'
|
|
112
|
+
files: Iterator
|
|
113
|
+
url = EqualToEverything()
|
|
114
|
+
history = ()
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def file(self) -> Path:
|
|
118
|
+
return next(self.files)
|
|
119
|
+
|
|
120
|
+
async def read(self) -> bytes:
|
|
121
|
+
return self.file.read_bytes()
|
|
122
|
+
|
|
123
|
+
def raise_for_status(self):
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
async def text(self) -> str:
|
|
127
|
+
return (await self.read()).decode()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@fixture(scope='session')
|
|
131
|
+
# DEPENDENCY INJECTION: session now depends on test_config to get its mode
|
|
132
|
+
async def session(test_config: TestConfig):
|
|
133
|
+
"""Pytest fixture to mock or record HTTP sessions."""
|
|
134
|
+
# Use injected config variables instead of globals
|
|
135
|
+
RECORD_MODE = test_config['RECORD_MODE']
|
|
136
|
+
OFFLINE_MODE = test_config['OFFLINE_MODE']
|
|
137
|
+
|
|
138
|
+
if OFFLINE_MODE:
|
|
139
|
+
|
|
140
|
+
class FakeSession:
|
|
141
|
+
@staticmethod
|
|
142
|
+
async def get(*_, **__):
|
|
143
|
+
return FakeResponse()
|
|
144
|
+
|
|
145
|
+
orig_session = SessionManager.session
|
|
146
|
+
SessionManager.session = FakeSession() # type: ignore
|
|
147
|
+
yield
|
|
148
|
+
SessionManager.session = orig_session # type: ignore
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
if RECORD_MODE:
|
|
152
|
+
original_get = ClientSession.get
|
|
153
|
+
|
|
154
|
+
async def recording_get(*args, **kwargs):
|
|
155
|
+
resp = await original_get(*args, **kwargs)
|
|
156
|
+
content = await resp.read()
|
|
157
|
+
FakeResponse().file.write_bytes(content)
|
|
158
|
+
return resp
|
|
159
|
+
|
|
160
|
+
ClientSession.get = recording_get # type: ignore
|
|
161
|
+
|
|
162
|
+
yield
|
|
163
|
+
ClientSession.get = original_get
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
yield
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def pytest_collection_modifyitems(items: list[Function]):
|
|
171
|
+
"""Automatically apply the 'session' fixture to all async tests."""
|
|
172
|
+
for item in items:
|
|
173
|
+
if iscoroutinefunction(item.obj):
|
|
174
|
+
item.fixturenames.append('session')
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def remove_unused_testdata():
|
|
178
|
+
"""Removes test data files that were not used during the test run."""
|
|
179
|
+
unused_testdata = {f.name for f in testdata.iterdir()} - USED_FILENAMES
|
|
180
|
+
|
|
181
|
+
if not unused_testdata:
|
|
182
|
+
print('REMOVE_UNUSED_TESTDATA: no action required')
|
|
183
|
+
return
|
|
184
|
+
for filename in unused_testdata:
|
|
185
|
+
(testdata / filename).unlink()
|
|
186
|
+
print(f'REMOVE_UNUSED_TESTDATA: removed {filename}')
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
USED_FILENAMES = set()
|
|
190
|
+
# atexit.register is now called conditionally in pytest_configure
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def file(filename: str):
|
|
194
|
+
"""Mocks the response files for a single file test case."""
|
|
195
|
+
# Checks if cleanup is active using the minimal global flag set in pytest_configure
|
|
196
|
+
if _remove_unused_testdata:
|
|
197
|
+
USED_FILENAMES.add(filename)
|
|
198
|
+
|
|
199
|
+
return patch.object(
|
|
200
|
+
FakeResponse,
|
|
201
|
+
'files',
|
|
202
|
+
cycle([testdata / filename]),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def files(*filenames: str):
|
|
207
|
+
"""Mocks the response files for sequential file test cases."""
|
|
208
|
+
# Checks if cleanup is active using the minimal global flag set in pytest_configure
|
|
209
|
+
if _remove_unused_testdata:
|
|
210
|
+
for filename in filenames:
|
|
211
|
+
USED_FILENAMES.add(filename)
|
|
212
|
+
|
|
213
|
+
return patch.object(
|
|
214
|
+
FakeResponse,
|
|
215
|
+
'files',
|
|
216
|
+
(testdata / filename for filename in filenames),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
strict_config = ConfigDict(strict=True)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def validate_dict(dct: Mapping, typed_dct: type[TypedDict]): # type: ignore
|
|
224
|
+
# A trick to disallow extra keys. See
|
|
225
|
+
# https://stackoverflow.com/questions/77165374/runtime-checking-for-extra-keys-in-typeddict
|
|
226
|
+
# https://docs.pydantic.dev/2.4/concepts/strict_mode/#dataclasses-and-typeddict
|
|
227
|
+
typed_dct.__pydantic_config__ = strict_config # type: ignore
|
|
228
|
+
TypeAdapter(typed_dct).validate_python(dct, strict=True)
|