Repository navigation
Expand file tree
/
Copy pathconftest.py
More file actions
292 lines (235 loc) · 12.4 KB
/
Copy pathconftest.py
File metadata and controls
292 lines (235 loc) · 12.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
"""Repo-root pytest configuration, shared by tests/ and extensions/*/tests.
It cuts the suite off from two things a test must never reach: any parent
git process, and the developer's own Pyrite config and index.
Under the pre-commit pytest hook the suite runs as a child of `git commit`,
which exports GIT_DIR, GIT_INDEX_FILE and GIT_AUTHOR_* pointing at the real
repository. GIT_DIR beats `cwd`, so a fixture that runs `git config user.name
Test` inside its tmp_path writes to the developer's own .git/config. That is
how this repo authored 39 commits as "Test <test@test.com>", leaked a
`test/alice` branch and worktree, and failed test_kb_commit only under the
hook. Guarding per file (test_worktree_service._clean_git_env) did not hold,
because the next git-using test file did not know to copy it.
This runs at conftest import, before collection, so module- and session-scoped
fixtures are covered too. tests/test_git_env_isolation.py is the canary.
"""
import atexit
import os
import shutil
import sys
import tempfile
import warnings
from pathlib import Path
import pytest
# Identity and repository-location variables git exports to hook subprocesses.
# Everything GIT_* is dropped except the few that only tune behaviour.
_KEEP = frozenset({"GIT_TERMINAL_PROMPT", "GIT_PAGER", "GIT_EDITOR", "GIT_SSH_COMMAND"})
def _isolate_git_environment() -> None:
for var in [v for v in os.environ if v.startswith("GIT_") and v not in _KEEP]:
del os.environ[var]
# Point global/system config at a private file so tests neither read the
# developer's ~/.gitconfig (signing keys, hooksPath, templates) nor depend
# on it for an identity. init.defaultBranch is pinned because GitService
# hardcodes "main" and bare `git init` follows the host's setting.
config_dir = Path(tempfile.mkdtemp(prefix="pyrite-test-gitconfig-"))
atexit.register(shutil.rmtree, config_dir, ignore_errors=True)
global_config = config_dir / "gitconfig"
global_config.write_text(
"[user]\n\tname = Pyrite Test Suite\n\temail = test-suite@pyrite.invalid\n"
"[init]\n\tdefaultBranch = main\n"
"[commit]\n\tgpgsign = false\n"
"[tag]\n\tgpgsign = false\n"
)
os.environ["GIT_CONFIG_GLOBAL"] = str(global_config)
os.environ["GIT_CONFIG_SYSTEM"] = os.devnull
os.environ["GIT_TERMINAL_PROMPT"] = "0"
_isolate_git_environment()
def _isolate_pyrite_config_environment() -> None:
"""Point every Pyrite default path at a session temp dir, for children too.
tests/conftest.py's autouse `_isolate_global_config` patches CONFIG_DIR
in-process only. A `pyrite`, `pyrite-admin` or `python -c` child process
does not inherit a monkeypatch, so it read and wrote the real ~/.pyrite --
and on 2026-09-23 a test-shaped write (`knowledge_bases: []`, an index in a
TemporaryDirectory) emptied a user's ~50-KB registry through a symlinked
~/.pyrite/config.yaml (#377). Environment variables are inherited, so set
them here, at conftest import: before pyrite.config computes CONFIG_DIR,
before any fixture, for tests/ and extensions/*/tests alike. Overwrites
whatever the developer's shell exported, deliberately.
tests/test_config_isolation.py is the canary.
"""
session_dir = Path(tempfile.mkdtemp(prefix="pyrite-test-config-")).resolve()
atexit.register(shutil.rmtree, session_dir, ignore_errors=True)
os.environ["PYRITE_CONFIG_DIR"] = str(session_dir)
# Not PYRITE_DATA_DIR: it overrides an explicit `settings.index_path` in
# every in-process load_config and wins over PYRITE_CONFIG_DIR when the
# config dir is resolved, which would hide precedence bugs. The index and
# repos default beside config.yaml, so PYRITE_CONFIG_DIR alone isolates
# them. A value from the developer's shell would escape it: clear that.
os.environ.pop("PYRITE_DATA_DIR", None)
_isolate_pyrite_config_environment()
def _isolate_hypothesis_storage() -> None:
"""Keep Hypothesis's cache out of the checkout.
Hypothesis writes ``<cwd>/.hypothesis/constants`` even when a test sets
``database=None``, and a test that lists the repository root while other
xdist workers run then sees it appear (#708's CI, 2026-10-03).
tests/test_suite_writes_outside_checkout.py is the canary.
"""
from hypothesis.configuration import set_hypothesis_home_dir
home = Path(tempfile.mkdtemp(prefix="pyrite-test-hypothesis-"))
atexit.register(shutil.rmtree, home, ignore_errors=True)
set_hypothesis_home_dir(home)
_isolate_hypothesis_storage()
@pytest.hookimpl(tryfirst=True)
def pytest_collection_modifyitems(config, items):
"""Mark experimental tests, from the one mapping (#657).
tests/experimental_surface.py decides; this only applies it. tryfirst so
the marker is on every item before ``-m`` deselects (the mark plugin's own
collection_modifyitems), whichever order plugins registered in.
"""
from tests.experimental_surface import is_experimental
marker = pytest.mark.experimental
for item in items:
if is_experimental(item.nodeid):
item.add_marker(marker)
def _deselects_experimental(config) -> bool:
"""Does this run's -m leave experimental tests out (the core run)?"""
from _pytest.mark.expression import Expression
expr = config.getoption("markexpr", "") or ""
if not expr:
return False
try:
compiled = Expression.compile(expr)
except Exception: # noqa: BLE001 - pytest reports a bad -m itself
return False
# A test whose only marker is `experimental`: is it selected?
return not compiled.evaluate(lambda name, **_: name == "experimental")
@pytest.hookimpl(wrapper=True)
def pytest_make_collect_report(collector):
"""A module that fails to import cannot be marked, so it would stop the
core run for a broken experimental feature (#657). When the run leaves
experimental tests out and the mapping says EVERY test in the file is
experimental (`wholly_experimental_file`), the error is a warning here;
the `experimental` job still reports it as a failure of that file. A file
with any core or security case still stops the run."""
report = yield
if not (report.failed and isinstance(collector, pytest.Module)):
return report
if not _deselects_experimental(collector.config):
return report
from tests.experimental_surface import wholly_experimental_file
rel = Path(collector.path).relative_to(collector.config.rootpath).as_posix()
if not wholly_experimental_file(rel):
return report
# Not collector.warn: that re-imports the module to locate the warning.
warnings.warn_explicit(
f"{rel} failed to collect; every test in it is experimental, so the core "
'run continues. See the error with -m "experimental".',
pytest.PytestWarning,
str(collector.path),
1,
)
return pytest.CollectReport(report.nodeid, "passed", None, [])
@pytest.fixture(autouse=True)
def _no_auto_embed_unless_marked(request, monkeypatch):
"""Write-time embedding is off for the suite (see tests/test_auto_embed_setting.py).
Loading the sentence-transformers model costs ~3 s of torch import plus
network calls to the Hugging Face hub, per process; under `-n auto` every
worker paid it and the suite thrashed. Tests that exercise embeddings say so:
`@pytest.mark.embeddings`. Subprocesses spawned by a test inherit the env var.
"""
if request.node.get_closest_marker("embeddings"):
return
from pyrite.services.kb_service import KBService
def _no_model(self):
# A test that installed its own (mock) service keeps it; nothing else
# gets one. Dataclass defaults are frozen into __init__ at class
# creation, so patching Settings.auto_embed would not reach Settings().
if getattr(self, "_embedding_checked", False):
return self._embedding_svc
self._embedding_checked = True
return None
monkeypatch.setattr(KBService, "_get_embedding_svc", _no_model)
monkeypatch.setenv("PYRITE_AUTO_EMBED", "0")
@pytest.fixture(autouse=True)
def _allow_testclient_host(monkeypatch):
"""Admit TestClient's default Host, ``testserver``, on an auth-disabled app.
A server with auth disabled answers only the hosts in
``pyrite.server.request_guard.LOCAL_HOSTS`` (plus configured names), and
TestClient addresses every request to ``http://testserver``. Hundreds of
tests build such an app and say nothing about hosts; this adds that one
name for them, in tests only. The production default never contains it:
``tests/test_request_guard.py`` overrides this fixture with a no-op and
asserts ``testserver`` gets 421.
"""
try:
from pyrite.server import request_guard
except ImportError: # server extras not installed
return
monkeypatch.setattr(request_guard, "LOCAL_HOSTS", request_guard.LOCAL_HOSTS | {"testserver"})
_TEST_BCRYPT_ROUNDS = 4 # bcrypt's minimum; production uses gensalt()'s default, 12
_cheap_dummy_hash: str | None = None
@pytest.fixture(autouse=True)
def _cheap_password_hashing(request, monkeypatch):
"""Hash passwords at bcrypt cost 4 in tests, not the production 12 (tests-t2a).
At cost 12 bcrypt was ~75% of the time in the big scoping files: every
``create_user`` / ``register`` pays one hash and it is pure CPU, so it
degrades worst under load. This is the one place the test cost is set
(tests/test_test_rules.py pins that); production code reads no switch.
It swaps the module's ``_bcrypt`` for a shim whose ``gensalt`` defaults to
cost 4, so only ``pyrite.services.auth_service`` is affected (the real
bcrypt module, and anything else importing it, is untouched), and swaps
the login timing-equalizer's dummy hash for a cost-4 one so a miss costs
what a hit costs. ``checkpw`` reads the cost out of the stored hash, so
verification is unchanged.
A test that asserts the hash format or cost says so:
``@pytest.mark.production_bcrypt_cost`` (tests/test_auth_service.py).
"""
if request.node.get_closest_marker("production_bcrypt_cost"):
return
try:
from pyrite.services import auth_service
except ImportError: # server extras (bcrypt) not installed
return
global _cheap_dummy_hash
real = auth_service._bcrypt
class _CheapBcrypt:
def gensalt(self, rounds: int = _TEST_BCRYPT_ROUNDS, prefix: bytes = b"2b"):
return real.gensalt(rounds, prefix)
def __getattr__(self, name):
return getattr(real, name)
shim = _CheapBcrypt()
if _cheap_dummy_hash is None:
_cheap_dummy_hash = real.hashpw(b"dummy-password", shim.gensalt()).decode()
monkeypatch.setattr(auth_service, "_bcrypt", shim)
monkeypatch.setattr(auth_service, "_DUMMY_PASSWORD_HASH", _cheap_dummy_hash)
def clear_fastapi_caches() -> int:
"""Empty every ``functools`` cache in the loaded ``fastapi`` modules (#1098).
FastAPI caches each dependency and endpoint callable it classifies in
module-level ``lru_cache``s (``fastapi/dependencies/models.py``), holding
the callable strongly. ``create_app()`` defines its dependencies as
closures over the app, and fixtures add ``lambda: db`` overrides, so every
test-built app, with its routes, schemas and ``PyriteDB``s, stayed alive
until 4,096 entries were evicted: 2.5 GB over 885 tests, and a 4-5 GB
xdist worker killed by the OOM killer. Clearing only costs FastAPI a
reclassification. The caches are found by walking the modules, not named,
so a FastAPI release that renames one is still covered; the number cleared
is returned so tests/test_test_process_memory_is_bounded.py can fail when
a release moves them out of reach.
"""
cleared = 0
for name, module in list(sys.modules.items()):
if module is None or not (name == "fastapi" or name.startswith("fastapi.")):
continue
for value in list(vars(module).values()):
if callable(getattr(value, "cache_clear", None)) and hasattr(value, "cache_info"):
value.cache_clear()
cleared += 1
return cleared
@pytest.hookimpl(trylast=True)
def pytest_runtest_teardown(item, nextitem):
"""Free the apps this test built, after every fixture has finalized (#1098).
trylast, so a fixture that still reads an app while it tears down is done
first. Module- and session-scoped app fixtures keep their apps: FastAPI
reclassifies on the next request. A process without fastapi has nothing to
clear.
"""
clear_fastapi_caches()