Skip to content

Commit 1ad4ca6

Browse files
authored
Support sys.pycache_prefix on py38 (#5864)
Support sys.pycache_prefix on py38
2 parents b9df9a4 + 6f20b4b commit 1ad4ca6

File tree

4 files changed

+130
-40
lines changed

4 files changed

+130
-40
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: 39 additions & 25 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
@@ -27,10 +28,11 @@
2728
from _pytest.assertion.util import ( # noqa: F401
2829
format_explanation as _format_explanation,
2930
)
31+
from _pytest.compat import fspath
3032
from _pytest.pathlib import fnmatch_ex
3133
from _pytest.pathlib import PurePath
3234

33-
# pytest caches rewritten pycs in __pycache__.
35+
# pytest caches rewritten pycs in pycache dirs
3436
PYTEST_TAG = "{}-pytest-{}".format(sys.implementation.cache_tag, version)
3537
PYC_EXT = ".py" + (__debug__ and "c" or "o")
3638
PYC_TAIL = "." + PYTEST_TAG + PYC_EXT
@@ -103,7 +105,7 @@ def create_module(self, spec):
103105
return None # default behaviour is fine
104106

105107
def exec_module(self, module):
106-
fn = module.__spec__.origin
108+
fn = Path(module.__spec__.origin)
107109
state = self.config._assertstate
108110

109111
self._rewritten_names.add(module.__name__)
@@ -117,15 +119,15 @@ def exec_module(self, module):
117119
# cached pyc is always a complete, valid pyc. Operations on it must be
118120
# atomic. POSIX's atomic rename comes in handy.
119121
write = not sys.dont_write_bytecode
120-
cache_dir = os.path.join(os.path.dirname(fn), "__pycache__")
122+
cache_dir = get_cache_dir(fn)
121123
if write:
122-
ok = try_mkdir(cache_dir)
124+
ok = try_makedirs(cache_dir)
123125
if not ok:
124126
write = False
125-
state.trace("read only directory: {}".format(os.path.dirname(fn)))
127+
state.trace("read only directory: {}".format(cache_dir))
126128

127-
cache_name = os.path.basename(fn)[:-3] + PYC_TAIL
128-
pyc = os.path.join(cache_dir, cache_name)
129+
cache_name = fn.name[:-3] + PYC_TAIL
130+
pyc = cache_dir / cache_name
129131
# Notice that even if we're in a read-only directory, I'm going
130132
# to check for a cached pyc. This may not be optimal...
131133
co = _read_pyc(fn, pyc, state.trace)
@@ -139,7 +141,7 @@ def exec_module(self, module):
139141
finally:
140142
self._writing_pyc = False
141143
else:
142-
state.trace("found cached rewritten pyc for {!r}".format(fn))
144+
state.trace("found cached rewritten pyc for {}".format(fn))
143145
exec(co, module.__dict__)
144146

145147
def _early_rewrite_bailout(self, name, state):
@@ -258,7 +260,7 @@ def _write_pyc(state, co, source_stat, pyc):
258260
# (C)Python, since these "pycs" should never be seen by builtin
259261
# import. However, there's little reason deviate.
260262
try:
261-
with atomicwrites.atomic_write(pyc, mode="wb", overwrite=True) as fp:
263+
with atomicwrites.atomic_write(fspath(pyc), mode="wb", overwrite=True) as fp:
262264
fp.write(importlib.util.MAGIC_NUMBER)
263265
# as of now, bytecode header expects 32-bit numbers for size and mtime (#4903)
264266
mtime = int(source_stat.st_mtime) & 0xFFFFFFFF
@@ -269,14 +271,15 @@ def _write_pyc(state, co, source_stat, pyc):
269271
except EnvironmentError as e:
270272
state.trace("error writing pyc file at {}: errno={}".format(pyc, e.errno))
271273
# we ignore any failure to write the cache file
272-
# there are many reasons, permission-denied, __pycache__ being a
274+
# there are many reasons, permission-denied, pycache dir being a
273275
# file etc.
274276
return False
275277
return True
276278

277279

278280
def _rewrite_test(fn, config):
279281
"""read and rewrite *fn* and return the code object."""
282+
fn = fspath(fn)
280283
stat = os.stat(fn)
281284
with open(fn, "rb") as f:
282285
source = f.read()
@@ -292,12 +295,12 @@ def _read_pyc(source, pyc, trace=lambda x: None):
292295
Return rewritten code if successful or None if not.
293296
"""
294297
try:
295-
fp = open(pyc, "rb")
298+
fp = open(fspath(pyc), "rb")
296299
except IOError:
297300
return None
298301
with fp:
299302
try:
300-
stat_result = os.stat(source)
303+
stat_result = os.stat(fspath(source))
301304
mtime = int(stat_result.st_mtime)
302305
size = stat_result.st_size
303306
data = fp.read(12)
@@ -749,7 +752,7 @@ def visit_Assert(self, assert_):
749752
"assertion is always true, perhaps remove parentheses?"
750753
),
751754
category=None,
752-
filename=self.module_path,
755+
filename=fspath(self.module_path),
753756
lineno=assert_.lineno,
754757
)
755758

@@ -872,7 +875,7 @@ def warn_about_none_ast(self, node, module_path, lineno):
872875
lineno={lineno},
873876
)
874877
""".format(
875-
filename=module_path, lineno=lineno
878+
filename=fspath(module_path), lineno=lineno
876879
)
877880
).body
878881
return ast.If(val_is_none, send_warning, [])
@@ -1018,18 +1021,15 @@ def visit_Compare(self, comp: ast.Compare):
10181021
return res, self.explanation_param(self.pop_format_context(expl_call))
10191022

10201023

1021-
def try_mkdir(cache_dir):
1022-
"""Attempts to create the given directory, returns True if successful"""
1024+
def try_makedirs(cache_dir) -> bool:
1025+
"""Attempts to create the given directory and sub-directories exist, returns True if
1026+
successful or it already exists"""
10231027
try:
1024-
os.mkdir(cache_dir)
1025-
except FileExistsError:
1026-
# Either the __pycache__ directory already exists (the
1027-
# common case) or it's blocked by a non-dir node. In the
1028-
# latter case, we'll ignore it in _write_pyc.
1029-
return True
1030-
except (FileNotFoundError, NotADirectoryError):
1031-
# One of the path components was not a directory, likely
1032-
# because we're in a zip file.
1028+
os.makedirs(fspath(cache_dir), exist_ok=True)
1029+
except (FileNotFoundError, NotADirectoryError, FileExistsError):
1030+
# One of the path components was not a directory:
1031+
# - we're in a zip file
1032+
# - it is a file
10331033
return False
10341034
except PermissionError:
10351035
return False
@@ -1039,3 +1039,17 @@ def try_mkdir(cache_dir):
10391039
return False
10401040
raise
10411041
return True
1042+
1043+
1044+
def get_cache_dir(file_path: Path) -> Path:
1045+
"""Returns the cache directory to write .pyc files for the given .py file path"""
1046+
if sys.version_info >= (3, 8) and sys.pycache_prefix:
1047+
# given:
1048+
# prefix = '/tmp/pycs'
1049+
# path = '/home/user/proj/test_app.py'
1050+
# we want:
1051+
# '/tmp/pycs/home/user/proj'
1052+
return Path(sys.pycache_prefix) / Path(*file_path.parts[1:-1])
1053+
else:
1054+
# classic pycache directory
1055+
return file_path.parent / "__pycache__"

src/_pytest/compat.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import functools
55
import inspect
66
import io
7+
import os
78
import re
89
import sys
910
from contextlib import contextmanager
@@ -41,6 +42,19 @@ def _format_args(func):
4142
REGEX_TYPE = type(re.compile(""))
4243

4344

45+
if sys.version_info < (3, 6):
46+
47+
def fspath(p):
48+
"""os.fspath replacement, useful to point out when we should replace it by the
49+
real function once we drop py35.
50+
"""
51+
return str(p)
52+
53+
54+
else:
55+
fspath = os.fspath
56+
57+
4458
def is_generator(func):
4559
genfunc = inspect.isgeneratorfunction(func)
4660
return genfunc and not iscoroutinefunction(func)

testing/test_assertrewrite.py

Lines changed: 74 additions & 15 deletions
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
@@ -1551,41 +1554,97 @@ def test_get_assertion_exprs(src, expected):
15511554
assert _get_assertion_exprs(src) == expected
15521555

15531556

1554-
def test_try_mkdir(monkeypatch, tmp_path):
1555-
from _pytest.assertion.rewrite import try_mkdir
1557+
def test_try_makedirs(monkeypatch, tmp_path):
1558+
from _pytest.assertion.rewrite import try_makedirs
15561559

15571560
p = tmp_path / "foo"
15581561

15591562
# create
1560-
assert try_mkdir(str(p))
1563+
assert try_makedirs(str(p))
15611564
assert p.is_dir()
15621565

15631566
# already exist
1564-
assert try_mkdir(str(p))
1567+
assert try_makedirs(str(p))
15651568

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

1571-
monkeypatch.setattr(os, "mkdir", partial(fake_mkdir, exc=FileNotFoundError()))
1572-
assert not try_mkdir(str(p))
1574+
monkeypatch.setattr(os, "makedirs", partial(fake_mkdir, exc=FileNotFoundError()))
1575+
assert not try_makedirs(str(p))
15731576

1574-
monkeypatch.setattr(os, "mkdir", partial(fake_mkdir, exc=NotADirectoryError()))
1575-
assert not try_mkdir(str(p))
1577+
monkeypatch.setattr(os, "makedirs", partial(fake_mkdir, exc=NotADirectoryError()))
1578+
assert not try_makedirs(str(p))
15761579

1577-
monkeypatch.setattr(os, "mkdir", partial(fake_mkdir, exc=PermissionError()))
1578-
assert not try_mkdir(str(p))
1580+
monkeypatch.setattr(os, "makedirs", partial(fake_mkdir, exc=PermissionError()))
1581+
assert not try_makedirs(str(p))
15791582

15801583
err = OSError()
15811584
err.errno = errno.EROFS
1582-
monkeypatch.setattr(os, "mkdir", partial(fake_mkdir, exc=err))
1583-
assert not try_mkdir(str(p))
1585+
monkeypatch.setattr(os, "makedirs", partial(fake_mkdir, exc=err))
1586+
assert not try_makedirs(str(p))
15841587

15851588
# unhandled OSError should raise
15861589
err = OSError()
15871590
err.errno = errno.ECHILD
1588-
monkeypatch.setattr(os, "mkdir", partial(fake_mkdir, exc=err))
1591+
monkeypatch.setattr(os, "makedirs", partial(fake_mkdir, exc=err))
15891592
with pytest.raises(OSError) as exc_info:
1590-
try_mkdir(str(p))
1593+
try_makedirs(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)