"""
Utilities for temporarily modifying Python's import environment.
This module provides context-managed helpers for mounting custom ``PYTHONPATH``
entries and isolating ``sys.modules`` state. It enables predictable import
behavior by allowing you to activate a snapshot of paths and modules, then
restore the original environment when the context exits.
The module exposes the following public components:
* :func:`mount_pythonpath` - Context manager to temporarily prepend import paths
* :class:`PythonPathEnv` - Snapshot of an import environment with mount support
.. note::
All modifications to ``sys.path`` and ``sys.modules`` are reverted on context
exit. Use the ``keep`` argument of :meth:`PythonPathEnv.mount` to persist
changes into the snapshot.
Example::
>>> from hbutils.reflection import mount_pythonpath
>>> with mount_pythonpath('path/to/plugins') as env:
... import my_plugin
... print(my_plugin.__name__)
my_plugin
>>> # `my_plugin` is no longer importable outside the context
"""
import sys
import types
from contextlib import contextmanager
from typing import ContextManager, Mapping, List, Dict, Iterator
__all__ = [
'mount_pythonpath',
'PythonPathEnv',
]
def _copy_list(origin: List[str], target: List[str]) -> None:
"""
Copy the contents of ``target`` list into ``origin`` list in-place.
This helper performs an in-place update to the list referenced by ``origin``,
ensuring that all references to the original list reflect the new contents.
:param origin: The list to be modified in place.
:type origin: List[str]
:param target: The list to copy from.
:type target: List[str]
:return: This function returns ``None``.
:rtype: None
"""
origin[:] = target
def _copy_dict(origin: Dict[str, types.ModuleType],
target: Dict[str, types.ModuleType]) -> None:
"""
Synchronize ``origin`` dict with ``target`` dict in-place.
Keys that are not present in ``target`` are removed from ``origin``, and
keys present in ``target`` are added or updated to reference the same
module objects.
:param origin: The dictionary to be modified in place.
:type origin: Dict[str, types.ModuleType]
:param target: The dictionary to copy from.
:type target: Dict[str, types.ModuleType]
:return: This function returns ``None``.
:rtype: None
"""
for key in set(origin.keys()) | set(target.keys()):
if key not in target:
del origin[key]
else:
origin[key] = target[key]
@contextmanager
def _native_mount_pythonpath(paths: List[str],
modules: Dict[str, types.ModuleType]) -> Iterator[None]:
"""
Temporarily replace ``sys.path`` and ``sys.modules`` with provided values.
This internal context manager saves the current state of ``sys.path`` and
``sys.modules``, replaces them with the provided values, and restores the
original state upon exit.
:param paths: List of paths to assign to ``sys.path`` during the context.
:type paths: List[str]
:param modules: Mapping of modules to assign to ``sys.modules`` during the
context.
:type modules: Dict[str, types.ModuleType]
:return: A context manager that yields ``None`` while mounted.
:rtype: Iterator[None]
Example::
>>> with _native_mount_pythonpath(['/custom/path'], {}):
... # sys.path is now ['/custom/path']
... # sys.modules is now {}
... pass
>>> # sys.path and sys.modules are restored
"""
from ..collection import get_recovery_func
path_rec = get_recovery_func(sys.path, recursive=False)
modules_rec = get_recovery_func(sys.modules, recursive=False)
try:
_copy_list(sys.path, paths)
_copy_dict(sys.modules, modules)
yield
finally:
path_rec()
modules_rec()
[docs]
class PythonPathEnv:
"""
Snapshot of a Python import environment.
This class captures a specific ``PYTHONPATH`` and module set so that it can
be mounted later. It is typically created by :func:`mount_pythonpath`.
:param pythonpath: Python path list to be used in this environment.
:type pythonpath: List[str]
:param modules: Dictionary of modules loaded in this environment.
:type modules: Mapping[str, types.ModuleType]
:ivar pythonpath: Stored path entries used when mounted.
:vartype pythonpath: List[str]
:ivar modules: Stored module mapping used when mounted.
:vartype modules: Dict[str, types.ModuleType]
"""
[docs]
def __init__(self, pythonpath: List[str], modules: Mapping[str, types.ModuleType]):
"""
Constructor of :class:`PythonPathEnv`.
:param pythonpath: Python path list to be used in this environment.
:type pythonpath: List[str]
:param modules: Dictionary of modules loaded in this environment.
:type modules: Mapping[str, types.ModuleType]
"""
self.pythonpath: List[str] = list(pythonpath)
self.modules: Dict[str, types.ModuleType] = dict(modules)
[docs]
@contextmanager
def mount(self, keep: bool = True) -> Iterator['PythonPathEnv']:
"""
Mount the stored ``PYTHONPATH`` and modules of this environment.
This method activates the environment by setting ``sys.path`` and
``sys.modules`` to the values stored in this :class:`PythonPathEnv`
instance. When the context exits, the original environment is restored.
:param keep: If ``True``, changes made during the context (new imports,
module modifications) will be kept in this :class:`PythonPathEnv`
instance for future mounts. If ``False``, changes are discarded.
Defaults to ``True``.
:type keep: bool
:return: Context manager that yields this :class:`PythonPathEnv` instance.
:rtype: Iterator[PythonPathEnv]
Examples::
>>> from hbutils.reflection import mount_pythonpath
>>> with mount_pythonpath('test/testfile/igm') as env:
... from gf1 import FIXED
... print('FIXED in igm:', FIXED)
FIXED in igm: 1234567
>>> with env.mount():
... from gf1 import FIXED
... print('FIXED in igm:', FIXED)
FIXED in igm: 1234567
"""
if keep:
pythonpath, modules = self.pythonpath, self.modules
else:
pythonpath, modules = [*self.pythonpath], {**self.modules}
with _native_mount_pythonpath(pythonpath, modules):
yield self
[docs]
@contextmanager
def mount_pythonpath(*path: str) -> Iterator[PythonPathEnv]:
"""
Prepend paths to ``PYTHONPATH`` within a context manager.
This function temporarily inserts directories into ``sys.path`` and
restores both ``sys.path`` and ``sys.modules`` when the context exits,
ensuring isolation.
:param path: One or more directory paths to prepend to ``sys.path``.
:type path: str
:return: Context manager that yields a :class:`PythonPathEnv` instance
representing the mounted environment.
:rtype: Iterator[PythonPathEnv]
Examples::
Here is the testfile directory structure:
>>> import os
>>> os.system('tree test/testfile')
test/testfile
├── dir1
│ ├── gf1.py
├── dir2
│ ├── gf1.py
└── igm
└── gf1.py
We can import values from different directories:
>>> from hbutils.reflection import mount_pythonpath
>>> with mount_pythonpath('test/testfile/igm'):
... from gf1 import FIXED
... print('FIXED in igm:', FIXED)
FIXED in igm: 1234567
>>>
>>> with mount_pythonpath('test/testfile/dir1'):
... from gf1 import FIXED
... print('FIXED in dir1:', FIXED)
FIXED in dir1: 233
>>>
>>> with mount_pythonpath('test/testfile/dir2'):
... from gf1 import FIXED
... print('FIXED in dir2:', FIXED)
FIXED in dir2: 455
>>>
>>> from gf1 import FIXED # cannot import outside the context
ModuleNotFoundError: No module named 'gf1'
"""
with _native_mount_pythonpath([*path, *sys.path], {**sys.modules}):
yield PythonPathEnv(sys.path, sys.modules)