Support sys.pycache_prefix on py38

Fix #4730
This commit is contained in:
Bruno Oliveira 2019-09-18 20:29:24 -03:00
parent b9df9a4761
commit f93f284356
3 changed files with 94 additions and 16 deletions

View File

@ -0,0 +1,3 @@
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.
This makes it easier to benefit of cached ``.pyc`` files even on file systems without permissions.

View File

@ -13,6 +13,7 @@ import struct
import sys import sys
import tokenize import tokenize
import types import types
from pathlib import Path
from typing import Dict from typing import Dict
from typing import List from typing import List
from typing import Optional from typing import Optional
@ -30,7 +31,7 @@ from _pytest.assertion.util import ( # noqa: F401
from _pytest.pathlib import fnmatch_ex from _pytest.pathlib import fnmatch_ex
from _pytest.pathlib import PurePath from _pytest.pathlib import PurePath
# pytest caches rewritten pycs in __pycache__. # pytest caches rewritten pycs in pycache dirs
PYTEST_TAG = "{}-pytest-{}".format(sys.implementation.cache_tag, version) PYTEST_TAG = "{}-pytest-{}".format(sys.implementation.cache_tag, version)
PYC_EXT = ".py" + (__debug__ and "c" or "o") PYC_EXT = ".py" + (__debug__ and "c" or "o")
PYC_TAIL = "." + PYTEST_TAG + PYC_EXT PYC_TAIL = "." + PYTEST_TAG + PYC_EXT
@ -103,7 +104,7 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder):
return None # default behaviour is fine return None # default behaviour is fine
def exec_module(self, module): def exec_module(self, module):
fn = module.__spec__.origin fn = Path(module.__spec__.origin)
state = self.config._assertstate state = self.config._assertstate
self._rewritten_names.add(module.__name__) self._rewritten_names.add(module.__name__)
@ -117,15 +118,15 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder):
# cached pyc is always a complete, valid pyc. Operations on it must be # cached pyc is always a complete, valid pyc. Operations on it must be
# atomic. POSIX's atomic rename comes in handy. # atomic. POSIX's atomic rename comes in handy.
write = not sys.dont_write_bytecode write = not sys.dont_write_bytecode
cache_dir = os.path.join(os.path.dirname(fn), "__pycache__") cache_dir = get_cache_dir(fn)
if write: if write:
ok = try_mkdir(cache_dir) ok = try_mkdir(cache_dir)
if not ok: if not ok:
write = False write = False
state.trace("read only directory: {}".format(os.path.dirname(fn))) state.trace("read only directory: {}".format(cache_dir))
cache_name = os.path.basename(fn)[:-3] + PYC_TAIL cache_name = fn.name[:-3] + PYC_TAIL
pyc = os.path.join(cache_dir, cache_name) pyc = cache_dir / cache_name
# Notice that even if we're in a read-only directory, I'm going # Notice that even if we're in a read-only directory, I'm going
# to check for a cached pyc. This may not be optimal... # to check for a cached pyc. This may not be optimal...
co = _read_pyc(fn, pyc, state.trace) co = _read_pyc(fn, pyc, state.trace)
@ -139,7 +140,7 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder):
finally: finally:
self._writing_pyc = False self._writing_pyc = False
else: else:
state.trace("found cached rewritten pyc for {!r}".format(fn)) state.trace("found cached rewritten pyc for {}".format(fn))
exec(co, module.__dict__) exec(co, module.__dict__)
def _early_rewrite_bailout(self, name, state): def _early_rewrite_bailout(self, name, state):
@ -258,7 +259,7 @@ def _write_pyc(state, co, source_stat, pyc):
# (C)Python, since these "pycs" should never be seen by builtin # (C)Python, since these "pycs" should never be seen by builtin
# import. However, there's little reason deviate. # import. However, there's little reason deviate.
try: try:
with atomicwrites.atomic_write(pyc, mode="wb", overwrite=True) as fp: with atomicwrites.atomic_write(str(pyc), mode="wb", overwrite=True) as fp:
fp.write(importlib.util.MAGIC_NUMBER) fp.write(importlib.util.MAGIC_NUMBER)
# as of now, bytecode header expects 32-bit numbers for size and mtime (#4903) # as of now, bytecode header expects 32-bit numbers for size and mtime (#4903)
mtime = int(source_stat.st_mtime) & 0xFFFFFFFF mtime = int(source_stat.st_mtime) & 0xFFFFFFFF
@ -269,7 +270,7 @@ def _write_pyc(state, co, source_stat, pyc):
except EnvironmentError as e: except EnvironmentError as e:
state.trace("error writing pyc file at {}: errno={}".format(pyc, e.errno)) state.trace("error writing pyc file at {}: errno={}".format(pyc, e.errno))
# we ignore any failure to write the cache file # we ignore any failure to write the cache file
# there are many reasons, permission-denied, __pycache__ being a # there are many reasons, permission-denied, pycache dir being a
# file etc. # file etc.
return False return False
return True return True
@ -277,6 +278,7 @@ def _write_pyc(state, co, source_stat, pyc):
def _rewrite_test(fn, config): def _rewrite_test(fn, config):
"""read and rewrite *fn* and return the code object.""" """read and rewrite *fn* and return the code object."""
fn = str(fn)
stat = os.stat(fn) stat = os.stat(fn)
with open(fn, "rb") as f: with open(fn, "rb") as f:
source = f.read() source = f.read()
@ -292,12 +294,12 @@ def _read_pyc(source, pyc, trace=lambda x: None):
Return rewritten code if successful or None if not. Return rewritten code if successful or None if not.
""" """
try: try:
fp = open(pyc, "rb") fp = open(str(pyc), "rb")
except IOError: except IOError:
return None return None
with fp: with fp:
try: try:
stat_result = os.stat(source) stat_result = os.stat(str(source))
mtime = int(stat_result.st_mtime) mtime = int(stat_result.st_mtime)
size = stat_result.st_size size = stat_result.st_size
data = fp.read(12) data = fp.read(12)
@ -749,7 +751,7 @@ class AssertionRewriter(ast.NodeVisitor):
"assertion is always true, perhaps remove parentheses?" "assertion is always true, perhaps remove parentheses?"
), ),
category=None, category=None,
filename=self.module_path, filename=str(self.module_path),
lineno=assert_.lineno, lineno=assert_.lineno,
) )
@ -872,7 +874,7 @@ warn_explicit(
lineno={lineno}, lineno={lineno},
) )
""".format( """.format(
filename=module_path, lineno=lineno filename=str(module_path), lineno=lineno
) )
).body ).body
return ast.If(val_is_none, send_warning, []) return ast.If(val_is_none, send_warning, [])
@ -1021,9 +1023,9 @@ warn_explicit(
def try_mkdir(cache_dir): def try_mkdir(cache_dir):
"""Attempts to create the given directory, returns True if successful""" """Attempts to create the given directory, returns True if successful"""
try: try:
os.mkdir(cache_dir) os.makedirs(str(cache_dir))
except FileExistsError: except FileExistsError:
# Either the __pycache__ directory already exists (the # Either the pycache directory already exists (the
# common case) or it's blocked by a non-dir node. In the # common case) or it's blocked by a non-dir node. In the
# latter case, we'll ignore it in _write_pyc. # latter case, we'll ignore it in _write_pyc.
return True return True
@ -1039,3 +1041,17 @@ def try_mkdir(cache_dir):
return False return False
raise raise
return True return True
def get_cache_dir(file_path: Path) -> Path:
"""Returns the cache directory to write .pyc files for the given .py file path"""
if sys.version_info >= (3, 8) and sys.pycache_prefix:
# given:
# prefix = '/tmp/pycs'
# path = '/home/user/proj/test_app.py'
# we want:
# '/tmp/pycs/home/user/proj'
return Path(sys.pycache_prefix) / Path(*file_path.parts[1:-1])
else:
# classic pycache directory
return file_path.parent / "__pycache__"

View File

@ -9,6 +9,7 @@ import sys
import textwrap import textwrap
import zipfile import zipfile
from functools import partial from functools import partial
from pathlib import Path
import py import py
@ -17,6 +18,8 @@ import pytest
from _pytest.assertion import util from _pytest.assertion import util
from _pytest.assertion.rewrite import _get_assertion_exprs from _pytest.assertion.rewrite import _get_assertion_exprs
from _pytest.assertion.rewrite import AssertionRewritingHook from _pytest.assertion.rewrite import AssertionRewritingHook
from _pytest.assertion.rewrite import get_cache_dir
from _pytest.assertion.rewrite import PYC_TAIL
from _pytest.assertion.rewrite import PYTEST_TAG from _pytest.assertion.rewrite import PYTEST_TAG
from _pytest.assertion.rewrite import rewrite_asserts from _pytest.assertion.rewrite import rewrite_asserts
from _pytest.main import ExitCode from _pytest.main import ExitCode
@ -1564,7 +1567,7 @@ def test_try_mkdir(monkeypatch, tmp_path):
assert try_mkdir(str(p)) assert try_mkdir(str(p))
# monkeypatch to simulate all error situations # monkeypatch to simulate all error situations
def fake_mkdir(p, *, exc): def fake_mkdir(p, mode, *, exc):
assert isinstance(p, str) assert isinstance(p, str)
raise exc raise exc
@ -1589,3 +1592,59 @@ def test_try_mkdir(monkeypatch, tmp_path):
with pytest.raises(OSError) as exc_info: with pytest.raises(OSError) as exc_info:
try_mkdir(str(p)) try_mkdir(str(p))
assert exc_info.value.errno == errno.ECHILD assert exc_info.value.errno == errno.ECHILD
class TestPyCacheDir:
@pytest.mark.parametrize(
"prefix, source, expected",
[
("c:/tmp/pycs", "d:/projects/src/foo.py", "c:/tmp/pycs/projects/src"),
(None, "d:/projects/src/foo.py", "d:/projects/src/__pycache__"),
("/tmp/pycs", "/home/projects/src/foo.py", "/tmp/pycs/home/projects/src"),
(None, "/home/projects/src/foo.py", "/home/projects/src/__pycache__"),
],
)
def test_get_cache_dir(self, monkeypatch, prefix, source, expected):
if prefix:
if sys.version_info < (3, 8):
pytest.skip("pycache_prefix not available in py<38")
monkeypatch.setattr(sys, "pycache_prefix", prefix)
assert get_cache_dir(Path(source)) == Path(expected)
@pytest.mark.skipif(
sys.version_info < (3, 8), reason="pycache_prefix not available in py<38"
)
def test_sys_pycache_prefix_integration(self, tmp_path, monkeypatch, testdir):
"""Integration test for sys.pycache_prefix (#4730)."""
pycache_prefix = tmp_path / "my/pycs"
monkeypatch.setattr(sys, "pycache_prefix", str(pycache_prefix))
monkeypatch.setattr(sys, "dont_write_bytecode", False)
testdir.makepyfile(
**{
"src/test_foo.py": """
import bar
def test_foo():
pass
""",
"src/bar/__init__.py": "",
}
)
result = testdir.runpytest()
assert result.ret == 0
test_foo = Path(testdir.tmpdir) / "src/test_foo.py"
bar_init = Path(testdir.tmpdir) / "src/bar/__init__.py"
assert test_foo.is_file()
assert bar_init.is_file()
# test file: rewritten, custom pytest cache tag
test_foo_pyc = get_cache_dir(test_foo) / ("test_foo" + PYC_TAIL)
assert test_foo_pyc.is_file()
# normal file: not touched by pytest, normal cache tag
bar_init_pyc = get_cache_dir(bar_init) / "__init__.{cache_tag}.pyc".format(
cache_tag=sys.implementation.cache_tag
)
assert bar_init_pyc.is_file()