Skip to content

Commit f93f284

Browse files
committed
Support sys.pycache_prefix on py38
Fix #4730
1 parent b9df9a4 commit f93f284

File tree

3 files changed

+94
-16
lines changed

3 files changed

+94
-16
lines changed

changelog/4730.feature.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
When ``sys.pycache_prefix`` (Python 3.8+) is set, it will be used by pytest to cache test files changed by the assertion rewriting mechanism.
2+
3+
This makes it easier to benefit of cached ``.pyc`` files even on file systems without permissions.

src/_pytest/assertion/rewrite.py

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import sys
1414
import tokenize
1515
import types
16+
from pathlib import Path
1617
from typing import Dict
1718
from typing import List
1819
from typing import Optional
@@ -30,7 +31,7 @@
3031
from _pytest.pathlib import fnmatch_ex
3132
from _pytest.pathlib import PurePath
3233

33-
# pytest caches rewritten pycs in __pycache__.
34+
# pytest caches rewritten pycs in pycache dirs
3435
PYTEST_TAG = "{}-pytest-{}".format(sys.implementation.cache_tag, version)
3536
PYC_EXT = ".py" + (__debug__ and "c" or "o")
3637
PYC_TAIL = "." + PYTEST_TAG + PYC_EXT
@@ -103,7 +104,7 @@ def create_module(self, spec):
103104
return None # default behaviour is fine
104105

105106
def exec_module(self, module):
106-
fn = module.__spec__.origin
107+
fn = Path(module.__spec__.origin)
107108
state = self.config._assertstate
108109

109110
self._rewritten_names.add(module.__name__)
@@ -117,15 +118,15 @@ def exec_module(self, module):
117118
# cached pyc is always a complete, valid pyc. Operations on it must be
118119
# atomic. POSIX's atomic rename comes in handy.
119120
write = not sys.dont_write_bytecode
120-
cache_dir = os.path.join(os.path.dirname(fn), "__pycache__")
121+
cache_dir = get_cache_dir(fn)
121122
if write:
122123
ok = try_mkdir(cache_dir)
123124
if not ok:
124125
write = False
125-
state.trace("read only directory: {}".format(os.path.dirname(fn)))
126+
state.trace("read only directory: {}".format(cache_dir))
126127

127-
cache_name = os.path.basename(fn)[:-3] + PYC_TAIL
128-
pyc = os.path.join(cache_dir, cache_name)
128+
cache_name = fn.name[:-3] + PYC_TAIL
129+
pyc = cache_dir / cache_name
129130
# Notice that even if we're in a read-only directory, I'm going
130131
# to check for a cached pyc. This may not be optimal...
131132
co = _read_pyc(fn, pyc, state.trace)
@@ -139,7 +140,7 @@ def exec_module(self, module):
139140
finally:
140141
self._writing_pyc = False
141142
else:
142-
state.trace("found cached rewritten pyc for {!r}".format(fn))
143+
state.trace("found cached rewritten pyc for {}".format(fn))
143144
exec(co, module.__dict__)
144145

145146
def _early_rewrite_bailout(self, name, state):
@@ -258,7 +259,7 @@ def _write_pyc(state, co, source_stat, pyc):
258259
# (C)Python, since these "pycs" should never be seen by builtin
259260
# import. However, there's little reason deviate.
260261
try:
261-
with atomicwrites.atomic_write(pyc, mode="wb", overwrite=True) as fp:
262+
with atomicwrites.atomic_write(str(pyc), mode="wb", overwrite=True) as fp:
262263
fp.write(importlib.util.MAGIC_NUMBER)
263264
# as of now, bytecode header expects 32-bit numbers for size and mtime (#4903)
264265
mtime = int(source_stat.st_mtime) & 0xFFFFFFFF
@@ -269,14 +270,15 @@ def _write_pyc(state, co, source_stat, pyc):
269270
except EnvironmentError as e:
270271
state.trace("error writing pyc file at {}: errno={}".format(pyc, e.errno))
271272
# we ignore any failure to write the cache file
272-
# there are many reasons, permission-denied, __pycache__ being a
273+
# there are many reasons, permission-denied, pycache dir being a
273274
# file etc.
274275
return False
275276
return True
276277

277278

278279
def _rewrite_test(fn, config):
279280
"""read and rewrite *fn* and return the code object."""
281+
fn = str(fn)
280282
stat = os.stat(fn)
281283
with open(fn, "rb") as f:
282284
source = f.read()
@@ -292,12 +294,12 @@ def _read_pyc(source, pyc, trace=lambda x: None):
292294
Return rewritten code if successful or None if not.
293295
"""
294296
try:
295-
fp = open(pyc, "rb")
297+
fp = open(str(pyc), "rb")
296298
except IOError:
297299
return None
298300
with fp:
299301
try:
300-
stat_result = os.stat(source)
302+
stat_result = os.stat(str(source))
301303
mtime = int(stat_result.st_mtime)
302304
size = stat_result.st_size
303305
data = fp.read(12)
@@ -749,7 +751,7 @@ def visit_Assert(self, assert_):
749751
"assertion is always true, perhaps remove parentheses?"
750752
),
751753
category=None,
752-
filename=self.module_path,
754+
filename=str(self.module_path),
753755
lineno=assert_.lineno,
754756
)
755757

@@ -872,7 +874,7 @@ def warn_about_none_ast(self, node, module_path, lineno):
872874
lineno={lineno},
873875
)
874876
""".format(
875-
filename=module_path, lineno=lineno
877+
filename=str(module_path), lineno=lineno
876878
)
877879
).body
878880
return ast.If(val_is_none, send_warning, [])
@@ -1021,9 +1023,9 @@ def visit_Compare(self, comp: ast.Compare):
10211023
def try_mkdir(cache_dir):
10221024
"""Attempts to create the given directory, returns True if successful"""
10231025
try:
1024-
os.mkdir(cache_dir)
1026+
os.makedirs(str(cache_dir))
10251027
except FileExistsError:
1026-
# Either the __pycache__ directory already exists (the
1028+
# Either the pycache directory already exists (the
10271029
# common case) or it's blocked by a non-dir node. In the
10281030
# latter case, we'll ignore it in _write_pyc.
10291031
return True
@@ -1039,3 +1041,17 @@ def try_mkdir(cache_dir):
10391041
return False
10401042
raise
10411043
return True
1044+
1045+
1046+
def get_cache_dir(file_path: Path) -> Path:
1047+
"""Returns the cache directory to write .pyc files for the given .py file path"""
1048+
if sys.version_info >= (3, 8) and sys.pycache_prefix:
1049+
# given:
1050+
# prefix = '/tmp/pycs'
1051+
# path = '/home/user/proj/test_app.py'
1052+
# we want:
1053+
# '/tmp/pycs/home/user/proj'
1054+
return Path(sys.pycache_prefix) / Path(*file_path.parts[1:-1])
1055+
else:
1056+
# classic pycache directory
1057+
return file_path.parent / "__pycache__"

testing/test_assertrewrite.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import textwrap
1010
import zipfile
1111
from functools import partial
12+
from pathlib import Path
1213

1314
import py
1415

@@ -17,6 +18,8 @@
1718
from _pytest.assertion import util
1819
from _pytest.assertion.rewrite import _get_assertion_exprs
1920
from _pytest.assertion.rewrite import AssertionRewritingHook
21+
from _pytest.assertion.rewrite import get_cache_dir
22+
from _pytest.assertion.rewrite import PYC_TAIL
2023
from _pytest.assertion.rewrite import PYTEST_TAG
2124
from _pytest.assertion.rewrite import rewrite_asserts
2225
from _pytest.main import ExitCode
@@ -1564,7 +1567,7 @@ def test_try_mkdir(monkeypatch, tmp_path):
15641567
assert try_mkdir(str(p))
15651568

15661569
# monkeypatch to simulate all error situations
1567-
def fake_mkdir(p, *, exc):
1570+
def fake_mkdir(p, mode, *, exc):
15681571
assert isinstance(p, str)
15691572
raise exc
15701573

@@ -1589,3 +1592,59 @@ def fake_mkdir(p, *, exc):
15891592
with pytest.raises(OSError) as exc_info:
15901593
try_mkdir(str(p))
15911594
assert exc_info.value.errno == errno.ECHILD
1595+
1596+
1597+
class TestPyCacheDir:
1598+
@pytest.mark.parametrize(
1599+
"prefix, source, expected",
1600+
[
1601+
("c:/tmp/pycs", "d:/projects/src/foo.py", "c:/tmp/pycs/projects/src"),
1602+
(None, "d:/projects/src/foo.py", "d:/projects/src/__pycache__"),
1603+
("/tmp/pycs", "/home/projects/src/foo.py", "/tmp/pycs/home/projects/src"),
1604+
(None, "/home/projects/src/foo.py", "/home/projects/src/__pycache__"),
1605+
],
1606+
)
1607+
def test_get_cache_dir(self, monkeypatch, prefix, source, expected):
1608+
if prefix:
1609+
if sys.version_info < (3, 8):
1610+
pytest.skip("pycache_prefix not available in py<38")
1611+
monkeypatch.setattr(sys, "pycache_prefix", prefix)
1612+
1613+
assert get_cache_dir(Path(source)) == Path(expected)
1614+
1615+
@pytest.mark.skipif(
1616+
sys.version_info < (3, 8), reason="pycache_prefix not available in py<38"
1617+
)
1618+
def test_sys_pycache_prefix_integration(self, tmp_path, monkeypatch, testdir):
1619+
"""Integration test for sys.pycache_prefix (#4730)."""
1620+
pycache_prefix = tmp_path / "my/pycs"
1621+
monkeypatch.setattr(sys, "pycache_prefix", str(pycache_prefix))
1622+
monkeypatch.setattr(sys, "dont_write_bytecode", False)
1623+
1624+
testdir.makepyfile(
1625+
**{
1626+
"src/test_foo.py": """
1627+
import bar
1628+
def test_foo():
1629+
pass
1630+
""",
1631+
"src/bar/__init__.py": "",
1632+
}
1633+
)
1634+
result = testdir.runpytest()
1635+
assert result.ret == 0
1636+
1637+
test_foo = Path(testdir.tmpdir) / "src/test_foo.py"
1638+
bar_init = Path(testdir.tmpdir) / "src/bar/__init__.py"
1639+
assert test_foo.is_file()
1640+
assert bar_init.is_file()
1641+
1642+
# test file: rewritten, custom pytest cache tag
1643+
test_foo_pyc = get_cache_dir(test_foo) / ("test_foo" + PYC_TAIL)
1644+
assert test_foo_pyc.is_file()
1645+
1646+
# normal file: not touched by pytest, normal cache tag
1647+
bar_init_pyc = get_cache_dir(bar_init) / "__init__.{cache_tag}.pyc".format(
1648+
cache_tag=sys.implementation.cache_tag
1649+
)
1650+
assert bar_init_pyc.is_file()

0 commit comments

Comments
 (0)