Skip to content

Commit b193fa9

Browse files
carljmncoghlan
authored andcommitted
bpo-33499: Add PYTHONPYCACHEPREFIX env var for alt bytecode cache location. (GH-6834)
In some development setups it is inconvenient or impossible to write bytecode caches to the code tree, but the bytecode caches are still useful. The PYTHONPYCACHEPREFIX environment variable allows specifying an alternate location for cached bytecode files, within which a directory tree mirroring the code tree will be created. This cache tree is then used (for both reading and writing) instead of the local `__pycache__` subdirectory within each source directory. Exposed at runtime as sys.pycache_prefix (defaulting to None), and can be set from the CLI as "-X pycache_prefix=path". Patch by Carl Meyer.
1 parent 6868003 commit b193fa9

File tree

12 files changed

+1385
-1086
lines changed

12 files changed

+1385
-1086
lines changed

Doc/library/compileall.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,11 @@ There is no command-line option to control the optimization level used by the
109109
:func:`compile` function, because the Python interpreter itself already
110110
provides the option: :program:`python -O -m compileall`.
111111

112+
Similarly, the :func:`compile` function respects the :attr:`sys.pycache_prefix`
113+
setting. The generated bytecode cache will only be useful if :func:`compile` is
114+
run with the same :attr:`sys.pycache_prefix` (if any) that will be used at
115+
runtime.
116+
112117
Public functions
113118
----------------
114119

Doc/library/sys.rst

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,26 @@ always available.
209209
yourself to control bytecode file generation.
210210

211211

212+
.. data:: pycache_prefix
213+
214+
If this is set (not ``None``), Python will write bytecode-cache ``.pyc``
215+
files to (and read them from) a parallel directory tree rooted at this
216+
directory, rather than from ``__pycache__`` directories in the source code
217+
tree. Any ``__pycache__`` directories in the source code tree will be ignored
218+
and new `.pyc` files written within the pycache prefix. Thus if you use
219+
:mod:`compileall` as a pre-build step, you must ensure you run it with the
220+
same pycache prefix (if any) that you will use at runtime.
221+
222+
A relative path is interpreted relative to the current working directory.
223+
224+
This value is initially set based on the value of the :option:`-X`
225+
``pycache_prefix=PATH`` command-line option or the
226+
:envvar:`PYTHONPYCACHEPREFIX` environment variable (command-line takes
227+
precedence). If neither are set, it is ``None``.
228+
229+
.. versionadded:: 3.8
230+
231+
212232
.. function:: excepthook(type, value, traceback)
213233

214234
This function prints out a given traceback and exception to ``sys.stderr``.

Doc/using/cmdline.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,9 @@ Miscellaneous options
442442
the default locale-aware mode. ``-X utf8=0`` explicitly disables UTF-8
443443
mode (even when it would otherwise activate automatically).
444444
See :envvar:`PYTHONUTF8` for more details.
445+
* ``-X pycache_prefix=PATH`` enables writing ``.pyc`` files to a parallel
446+
tree rooted at the given directory instead of to the code tree. See also
447+
:envvar:`PYTHONPYCACHEPREFIX`.
445448

446449
It also allows passing arbitrary values and retrieving them through the
447450
:data:`sys._xoptions` dictionary.
@@ -461,6 +464,9 @@ Miscellaneous options
461464
.. versionadded:: 3.7
462465
The ``-X importtime``, ``-X dev`` and ``-X utf8`` options.
463466

467+
.. versionadded:: 3.8
468+
The ``-X pycache_prefix`` option.
469+
464470

465471
Options you shouldn't use
466472
~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -587,6 +593,16 @@ conflict.
587593
specifying the :option:`-B` option.
588594

589595

596+
.. envvar:: PYTHONPYCACHEPREFIX
597+
598+
If this is set, Python will write ``.pyc`` files in a mirror directory tree
599+
at this path, instead of in ``__pycache__`` directories within the source
600+
tree. This is equivalent to specifying the :option:`-X`
601+
``pycache_prefix=PATH`` option.
602+
603+
.. versionadded:: 3.8
604+
605+
590606
.. envvar:: PYTHONHASHSEED
591607

592608
If this variable is not set or set to ``random``, a random value is used

Include/pystate.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
/* Thread and interpreter state structures and their interfaces */
32

43

@@ -44,6 +43,7 @@ typedef struct {
4443
int coerce_c_locale; /* PYTHONCOERCECLOCALE, -1 means unknown */
4544
int coerce_c_locale_warn; /* PYTHONCOERCECLOCALE=warn */
4645
int utf8_mode; /* PYTHONUTF8, -X utf8; -1 means unknown */
46+
wchar_t *pycache_prefix; /* PYTHONPYCACHEPREFIX, -X pycache_prefix=PATH */
4747

4848
wchar_t *program_name; /* Program name, see also Py_GetProgramName() */
4949
int argc; /* Number of command line arguments,
@@ -101,6 +101,7 @@ typedef struct {
101101
PyObject *warnoptions; /* sys.warnoptions list, can be NULL */
102102
PyObject *xoptions; /* sys._xoptions dict, can be NULL */
103103
PyObject *module_search_path; /* sys.path list */
104+
PyObject *pycache_prefix; /* sys.pycache_prefix str, can be NULL */
104105
} _PyMainInterpreterConfig;
105106

106107
#define _PyMainInterpreterConfig_INIT \

Lib/importlib/_bootstrap_external.py

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,15 @@ def _path_isdir(path):
102102
return _path_is_mode_type(path, 0o040000)
103103

104104

105+
def _path_isabs(path):
106+
"""Replacement for os.path.isabs.
107+
108+
Considers a Windows drive-relative path (no drive, but starts with slash) to
109+
still be "absolute".
110+
"""
111+
return path.startswith(path_separators) or path[1:3] in _pathseps_with_colon
112+
113+
105114
def _write_atomic(path, data, mode=0o666):
106115
"""Best-effort function to write data to a path atomically.
107116
Be prepared to handle a FileExistsError if concurrent writing of the
@@ -312,7 +321,33 @@ def cache_from_source(path, debug_override=None, *, optimization=None):
312321
if not optimization.isalnum():
313322
raise ValueError('{!r} is not alphanumeric'.format(optimization))
314323
almost_filename = '{}.{}{}'.format(almost_filename, _OPT, optimization)
315-
return _path_join(head, _PYCACHE, almost_filename + BYTECODE_SUFFIXES[0])
324+
filename = almost_filename + BYTECODE_SUFFIXES[0]
325+
if sys.pycache_prefix is not None:
326+
# We need an absolute path to the py file to avoid the possibility of
327+
# collisions within sys.pycache_prefix, if someone has two different
328+
# `foo/bar.py` on their system and they import both of them using the
329+
# same sys.pycache_prefix. Let's say sys.pycache_prefix is
330+
# `C:\Bytecode`; the idea here is that if we get `Foo\Bar`, we first
331+
# make it absolute (`C:\Somewhere\Foo\Bar`), then make it root-relative
332+
# (`Somewhere\Foo\Bar`), so we end up placing the bytecode file in an
333+
# unambiguous `C:\Bytecode\Somewhere\Foo\Bar\`.
334+
if not _path_isabs(head):
335+
head = _path_join(_os.getcwd(), head)
336+
337+
# Strip initial drive from a Windows path. We know we have an absolute
338+
# path here, so the second part of the check rules out a POSIX path that
339+
# happens to contain a colon at the second character.
340+
if head[1] == ':' and head[0] not in path_separators:
341+
head = head[2:]
342+
343+
# Strip initial path separator from `head` to complete the conversion
344+
# back to a root-relative path before joining.
345+
return _path_join(
346+
sys.pycache_prefix,
347+
head.lstrip(path_separators),
348+
filename,
349+
)
350+
return _path_join(head, _PYCACHE, filename)
316351

317352

318353
def source_from_cache(path):
@@ -328,23 +363,29 @@ def source_from_cache(path):
328363
raise NotImplementedError('sys.implementation.cache_tag is None')
329364
path = _os.fspath(path)
330365
head, pycache_filename = _path_split(path)
331-
head, pycache = _path_split(head)
332-
if pycache != _PYCACHE:
333-
raise ValueError('{} not bottom-level directory in '
334-
'{!r}'.format(_PYCACHE, path))
366+
found_in_pycache_prefix = False
367+
if sys.pycache_prefix is not None:
368+
stripped_path = sys.pycache_prefix.rstrip(path_separators)
369+
if head.startswith(stripped_path + path_sep):
370+
head = head[len(stripped_path):]
371+
found_in_pycache_prefix = True
372+
if not found_in_pycache_prefix:
373+
head, pycache = _path_split(head)
374+
if pycache != _PYCACHE:
375+
raise ValueError(f'{_PYCACHE} not bottom-level directory in '
376+
f'{path!r}')
335377
dot_count = pycache_filename.count('.')
336378
if dot_count not in {2, 3}:
337-
raise ValueError('expected only 2 or 3 dots in '
338-
'{!r}'.format(pycache_filename))
379+
raise ValueError(f'expected only 2 or 3 dots in {pycache_filename!r}')
339380
elif dot_count == 3:
340381
optimization = pycache_filename.rsplit('.', 2)[-2]
341382
if not optimization.startswith(_OPT):
342383
raise ValueError("optimization portion of filename does not start "
343-
"with {!r}".format(_OPT))
384+
f"with {_OPT!r}")
344385
opt_level = optimization[len(_OPT):]
345386
if not opt_level.isalnum():
346-
raise ValueError("optimization level {!r} is not an alphanumeric "
347-
"value".format(optimization))
387+
raise ValueError(f"optimization level {optimization!r} is not an "
388+
"alphanumeric value")
348389
base_filename = pycache_filename.partition('.')[0]
349390
return _path_join(head, base_filename + SOURCE_SUFFIXES[0])
350391

@@ -1533,6 +1574,7 @@ def _setup(_bootstrap_module):
15331574
setattr(self_module, '_os', os_module)
15341575
setattr(self_module, 'path_sep', path_sep)
15351576
setattr(self_module, 'path_separators', ''.join(path_separators))
1577+
setattr(self_module, '_pathseps_with_colon', {f':{s}' for s in path_separators})
15361578

15371579
# Directly load the _thread module (needed during bootstrap).
15381580
thread_module = _bootstrap._builtin_from_name('_thread')

Lib/test/test_cmd_line.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,32 @@ def test_sys_flags_set(self):
519519
with self.subTest(envar_value=value):
520520
assert_python_ok('-c', code, **env_vars)
521521

522+
def test_set_pycache_prefix(self):
523+
# sys.pycache_prefix can be set from either -X pycache_prefix or
524+
# PYTHONPYCACHEPREFIX env var, with the former taking precedence.
525+
NO_VALUE = object() # `-X pycache_prefix` with no `=PATH`
526+
cases = [
527+
# (PYTHONPYCACHEPREFIX, -X pycache_prefix, sys.pycache_prefix)
528+
(None, None, None),
529+
('foo', None, 'foo'),
530+
(None, 'bar', 'bar'),
531+
('foo', 'bar', 'bar'),
532+
('foo', '', None),
533+
('foo', NO_VALUE, None),
534+
]
535+
for envval, opt, expected in cases:
536+
exp_clause = "is None" if expected is None else f'== "{expected}"'
537+
code = f"import sys; sys.exit(not sys.pycache_prefix {exp_clause})"
538+
args = ['-c', code]
539+
env = {} if envval is None else {'PYTHONPYCACHEPREFIX': envval}
540+
if opt is NO_VALUE:
541+
args[:0] = ['-X', 'pycache_prefix']
542+
elif opt is not None:
543+
args[:0] = ['-X', f'pycache_prefix={opt}']
544+
with self.subTest(envval=envval, opt=opt):
545+
with support.temp_cwd():
546+
assert_python_ok(*args, **env)
547+
522548
def run_xdev(self, *args, check_exitcode=True, xdev=True):
523549
env = dict(os.environ)
524550
env.pop('PYTHONWARNINGS', None)

Lib/test/test_importlib/test_util.py

Lines changed: 84 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
from . import util
1+
from . import util
22
abc = util.import_importlib('importlib.abc')
33
init = util.import_importlib('importlib')
44
machinery = util.import_importlib('importlib.machinery')
55
importlib_util = util.import_importlib('importlib.util')
66

7+
import contextlib
78
import importlib.util
89
import os
910
import pathlib
@@ -12,6 +13,7 @@
1213
from test import support
1314
import types
1415
import unittest
16+
import unittest.mock
1517
import warnings
1618

1719

@@ -557,8 +559,8 @@ class PEP3147Tests:
557559

558560
tag = sys.implementation.cache_tag
559561

560-
@unittest.skipUnless(sys.implementation.cache_tag is not None,
561-
'requires sys.implementation.cache_tag not be None')
562+
@unittest.skipIf(sys.implementation.cache_tag is None,
563+
'requires sys.implementation.cache_tag not be None')
562564
def test_cache_from_source(self):
563565
# Given the path to a .py file, return the path to its PEP 3147
564566
# defined .pyc file (i.e. under __pycache__).
@@ -678,18 +680,17 @@ def test_sep_altsep_and_sep_cache_from_source(self):
678680
self.util.cache_from_source('\\foo\\bar\\baz/qux.py', optimization=''),
679681
'\\foo\\bar\\baz\\__pycache__\\qux.{}.pyc'.format(self.tag))
680682

681-
@unittest.skipUnless(sys.implementation.cache_tag is not None,
682-
'requires sys.implementation.cache_tag not be None')
683+
@unittest.skipIf(sys.implementation.cache_tag is None,
684+
'requires sys.implementation.cache_tag not be None')
683685
def test_source_from_cache_path_like_arg(self):
684686
path = pathlib.PurePath('foo', 'bar', 'baz', 'qux.py')
685687
expect = os.path.join('foo', 'bar', 'baz', '__pycache__',
686688
'qux.{}.pyc'.format(self.tag))
687689
self.assertEqual(self.util.cache_from_source(path, optimization=''),
688690
expect)
689691

690-
@unittest.skipUnless(sys.implementation.cache_tag is not None,
691-
'requires sys.implementation.cache_tag to not be '
692-
'None')
692+
@unittest.skipIf(sys.implementation.cache_tag is None,
693+
'requires sys.implementation.cache_tag to not be None')
693694
def test_source_from_cache(self):
694695
# Given the path to a PEP 3147 defined .pyc file, return the path to
695696
# its source. This tests the good path.
@@ -749,15 +750,87 @@ def test_source_from_cache_missing_optimization(self):
749750
with self.assertRaises(ValueError):
750751
self.util.source_from_cache(path)
751752

752-
@unittest.skipUnless(sys.implementation.cache_tag is not None,
753-
'requires sys.implementation.cache_tag to not be '
754-
'None')
753+
@unittest.skipIf(sys.implementation.cache_tag is None,
754+
'requires sys.implementation.cache_tag to not be None')
755755
def test_source_from_cache_path_like_arg(self):
756756
path = pathlib.PurePath('foo', 'bar', 'baz', '__pycache__',
757757
'qux.{}.pyc'.format(self.tag))
758758
expect = os.path.join('foo', 'bar', 'baz', 'qux.py')
759759
self.assertEqual(self.util.source_from_cache(path), expect)
760760

761+
@unittest.skipIf(sys.implementation.cache_tag is None,
762+
'requires sys.implementation.cache_tag to not be None')
763+
def test_cache_from_source_respects_pycache_prefix(self):
764+
# If pycache_prefix is set, cache_from_source will return a bytecode
765+
# path inside that directory (in a subdirectory mirroring the .py file's
766+
# path) rather than in a __pycache__ dir next to the py file.
767+
pycache_prefixes = [
768+
os.path.join(os.path.sep, 'tmp', 'bytecode'),
769+
os.path.join(os.path.sep, 'tmp', '\u2603'), # non-ASCII in path!
770+
os.path.join(os.path.sep, 'tmp', 'trailing-slash') + os.path.sep,
771+
]
772+
drive = ''
773+
if os.name == 'nt':
774+
drive = 'C:'
775+
pycache_prefixes = [
776+
f'{drive}{prefix}' for prefix in pycache_prefixes]
777+
pycache_prefixes += [r'\\?\C:\foo', r'\\localhost\c$\bar']
778+
for pycache_prefix in pycache_prefixes:
779+
with self.subTest(path=pycache_prefix):
780+
path = drive + os.path.join(
781+
os.path.sep, 'foo', 'bar', 'baz', 'qux.py')
782+
expect = os.path.join(
783+
pycache_prefix, 'foo', 'bar', 'baz',
784+
'qux.{}.pyc'.format(self.tag))
785+
with util.temporary_pycache_prefix(pycache_prefix):
786+
self.assertEqual(
787+
self.util.cache_from_source(path, optimization=''),
788+
expect)
789+
790+
@unittest.skipIf(sys.implementation.cache_tag is None,
791+
'requires sys.implementation.cache_tag to not be None')
792+
def test_cache_from_source_respects_pycache_prefix_relative(self):
793+
# If the .py path we are given is relative, we will resolve to an
794+
# absolute path before prefixing with pycache_prefix, to avoid any
795+
# possible ambiguity.
796+
pycache_prefix = os.path.join(os.path.sep, 'tmp', 'bytecode')
797+
path = os.path.join('foo', 'bar', 'baz', 'qux.py')
798+
root = os.path.splitdrive(os.getcwd())[0] + os.path.sep
799+
expect = os.path.join(
800+
pycache_prefix,
801+
os.path.relpath(os.getcwd(), root),
802+
'foo', 'bar', 'baz', f'qux.{self.tag}.pyc')
803+
with util.temporary_pycache_prefix(pycache_prefix):
804+
self.assertEqual(
805+
self.util.cache_from_source(path, optimization=''),
806+
expect)
807+
808+
@unittest.skipIf(sys.implementation.cache_tag is None,
809+
'requires sys.implementation.cache_tag to not be None')
810+
def test_source_from_cache_inside_pycache_prefix(self):
811+
# If pycache_prefix is set and the cache path we get is inside it,
812+
# we return an absolute path to the py file based on the remainder of
813+
# the path within pycache_prefix.
814+
pycache_prefix = os.path.join(os.path.sep, 'tmp', 'bytecode')
815+
path = os.path.join(pycache_prefix, 'foo', 'bar', 'baz',
816+
f'qux.{self.tag}.pyc')
817+
expect = os.path.join(os.path.sep, 'foo', 'bar', 'baz', 'qux.py')
818+
with util.temporary_pycache_prefix(pycache_prefix):
819+
self.assertEqual(self.util.source_from_cache(path), expect)
820+
821+
@unittest.skipIf(sys.implementation.cache_tag is None,
822+
'requires sys.implementation.cache_tag to not be None')
823+
def test_source_from_cache_outside_pycache_prefix(self):
824+
# If pycache_prefix is set but the cache path we get is not inside
825+
# it, just ignore it and handle the cache path according to the default
826+
# behavior.
827+
pycache_prefix = os.path.join(os.path.sep, 'tmp', 'bytecode')
828+
path = os.path.join('foo', 'bar', 'baz', '__pycache__',
829+
f'qux.{self.tag}.pyc')
830+
expect = os.path.join('foo', 'bar', 'baz', 'qux.py')
831+
with util.temporary_pycache_prefix(pycache_prefix):
832+
self.assertEqual(self.util.source_from_cache(path), expect)
833+
761834

762835
(Frozen_PEP3147Tests,
763836
Source_PEP3147Tests

Lib/test/test_importlib/util.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,17 @@ def ensure_bytecode_path(bytecode_path):
319319
raise
320320

321321

322+
@contextlib.contextmanager
323+
def temporary_pycache_prefix(prefix):
324+
"""Adjust and restore sys.pycache_prefix."""
325+
_orig_prefix = sys.pycache_prefix
326+
sys.pycache_prefix = prefix
327+
try:
328+
yield
329+
finally:
330+
sys.pycache_prefix = _orig_prefix
331+
332+
322333
@contextlib.contextmanager
323334
def create_modules(*names):
324335
"""Temporarily create each named module with an attribute (named 'attr')
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Add :envvar:`PYTHONPYCACHEPREFIX` environment variable and :option:`-X`
2+
``pycache_prefix`` command-line option to set an alternate root directory for
3+
writing module bytecode cache files.

0 commit comments

Comments
 (0)