Skip to content

Commit 9baf265

Browse files
authored
feat: ✨ PythonModuleLoader
1 parent 46117f1 commit 9baf265

9 files changed

Lines changed: 319 additions & 217 deletions

File tree

conftest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
11
import logging
2+
from collections.abc import Iterator
3+
from unittest.mock import patch
24

35
import pytest
46

57
from injection import Module, mod
68
from injection._core.module import Module as CoreModule
9+
from injection.utils import PythonModuleLoader
710
from tests.helpers import EventHistory
811

912
logging.basicConfig(level=logging.DEBUG)
1013

1114

15+
@pytest.fixture(scope="session", autouse=True)
16+
def __patch_sys_modules() -> Iterator[None]:
17+
with patch.object(PythonModuleLoader, "_sys_modules", {}):
18+
yield
19+
20+
1221
@pytest.fixture(scope="function", autouse=True)
1322
def unlock():
1423
yield

documentation/utils.md

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# Utils
22

3-
## load_packages
3+
## PythonModuleLoader
44

5-
Useful for put in memory injectables hidden deep within a package. Example:
5+
Useful for put in memory injectables hidden deep within a package.
66

77
```
88
package
@@ -17,6 +17,52 @@ package
1717

1818
To load Injectable1 and Injectable2 into memory you can do the following:
1919

20+
```python
21+
# Imports
22+
from injection.utils import PythonModuleLoader
23+
import package
24+
```
25+
26+
```python
27+
def predicate(module_name: str) -> bool:
28+
# logic to determine whether the module should be imported or not
29+
return True
30+
31+
PythonModuleLoader(predicate).load(package)
32+
```
33+
34+
### Factory methods
35+
36+
* `from_keywords`
37+
38+
Automatically imports modules whose Python script contains one of the keywords passed in parameter.
39+
40+
```python
41+
PythonModuleLoader.from_keywords("# Auto-import").load(package)
42+
```
43+
44+
* `startswith`
45+
46+
Automatically imports modules whose Python script name begins with one of the prefixes passed in parameter.
47+
48+
```python
49+
profile: str = ...
50+
PythonModuleLoader.startswith(f"{profile}_").load(package)
51+
```
52+
53+
* `endswith`
54+
55+
Automatically imports modules whose Python script name ends with one of the suffixes passed in parameter.
56+
57+
```python
58+
profile: str = ...
59+
PythonModuleLoader.endswith(f"_{profile}").load(package)
60+
```
61+
62+
## load_packages
63+
64+
`load_packages` is a simplified version of `PythonModuleLoader`.
65+
2066
```python
2167
from injection.utils import load_packages
2268

injection/utils.py

Lines changed: 88 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
1+
import itertools
12
import sys
2-
from collections.abc import Callable, Collection, Iterator
3+
from collections.abc import Callable, Iterator, Mapping
4+
from dataclasses import dataclass, field
35
from importlib import import_module
46
from importlib.util import find_spec
7+
from os.path import isfile
58
from pkgutil import walk_packages
9+
from types import MappingProxyType
610
from types import ModuleType as PythonModule
7-
from typing import ContextManager
11+
from typing import ClassVar, ContextManager, Self
812

913
from injection import Module, mod
10-
from injection import __name__ as injection_package_name
1114

12-
__all__ = ("load_modules_with_keywords", "load_packages", "load_profile")
15+
__all__ = ("PythonModuleLoader", "load_packages", "load_profile")
1316

1417

1518
def load_profile(*names: str) -> ContextManager[Module]:
@@ -21,76 +24,107 @@ def load_profile(*names: str) -> ContextManager[Module]:
2124
return mod().load_profile(*names)
2225

2326

24-
def load_modules_with_keywords(
27+
def load_packages(
2528
*packages: PythonModule | str,
26-
keywords: Collection[str] | None = None,
29+
predicate: Callable[[str], bool] = lambda module_name: True,
2730
) -> dict[str, PythonModule]:
2831
"""
29-
Function to import modules from a Python package if one of the keywords is contained in the Python script.
30-
The default keywords are:
31-
- `from injection `
32-
- `from injection.`
33-
- `import injection`
32+
Function for importing all modules in a Python package.
33+
Pass the `predicate` parameter if you want to filter the modules to be imported.
3434
"""
3535

36-
if keywords is None:
37-
keywords = (
38-
f"from {injection_package_name} ",
39-
f"from {injection_package_name}.",
40-
f"import {injection_package_name}",
36+
return PythonModuleLoader(predicate).load(*packages).modules
37+
38+
39+
@dataclass(repr=False, eq=False, frozen=True, slots=True)
40+
class PythonModuleLoader:
41+
predicate: Callable[[str], bool]
42+
__modules: dict[str, PythonModule | None] = field(
43+
default_factory=dict,
44+
init=False,
45+
)
46+
47+
# To easily mock `sys.modules` in tests
48+
_sys_modules: ClassVar[Mapping[str, PythonModule]] = MappingProxyType(sys.modules)
49+
50+
@property
51+
def modules(self) -> dict[str, PythonModule]:
52+
return {
53+
name: module
54+
for name, module in self.__modules.items()
55+
if module is not None
56+
}
57+
58+
def load(self, *packages: PythonModule | str) -> Self:
59+
modules = itertools.chain.from_iterable(
60+
self.__iter_modules(package) for package in packages
4161
)
62+
self.__modules.update(modules)
63+
return self
4264

43-
def predicate(module_name: str) -> bool:
44-
spec = find_spec(module_name)
65+
def __is_already_loaded(self, module_name: str) -> bool:
66+
return any(
67+
module_name in modules for modules in (self.__modules, self._sys_modules)
68+
)
4569

46-
if spec and (module_path := spec.origin):
47-
with open(module_path, "r") as file:
48-
python_script = file.read()
70+
def __iter_modules(
71+
self,
72+
package: PythonModule | str,
73+
) -> Iterator[tuple[str, PythonModule | None]]:
74+
if isinstance(package, str):
75+
package = import_module(package)
4976

50-
return bool(python_script) and any(
51-
keyword in python_script for keyword in keywords
52-
)
77+
package_name = package.__name__
5378

54-
return False
79+
try:
80+
package_path = package.__path__
81+
except AttributeError as exc:
82+
raise TypeError(f"`{package_name}` isn't Python package.") from exc
5583

56-
return load_packages(*packages, predicate=predicate)
84+
for info in walk_packages(path=package_path, prefix=f"{package_name}."):
85+
name = info.name
5786

87+
if info.ispkg or self.__is_already_loaded(name):
88+
continue
5889

59-
def load_packages(
60-
*packages: PythonModule | str,
61-
predicate: Callable[[str], bool] = lambda module_name: True,
62-
) -> dict[str, PythonModule]:
63-
"""
64-
Function for importing all modules in a Python package.
65-
Pass the `predicate` parameter if you want to filter the modules to be imported.
66-
"""
90+
module = import_module(name) if self.predicate(name) else None
91+
yield name, module
6792

68-
loaded: dict[str, PythonModule] = {}
93+
@classmethod
94+
def from_keywords(cls, *keywords: str) -> Self:
95+
"""
96+
Create loader to import modules from a Python package if one of the keywords is
97+
contained in the Python script.
98+
"""
6999

70-
for package in packages:
71-
if isinstance(package, str):
72-
package = import_module(package)
100+
def predicate(module_name: str) -> bool:
101+
spec = find_spec(module_name)
102+
103+
if spec is None:
104+
return False
73105

74-
loaded |= __iter_modules_from(package, predicate)
106+
module_path = spec.origin
75107

76-
return loaded
108+
if module_path is None or not isfile(module_path):
109+
return False
77110

111+
with open(module_path, "r") as script:
112+
return any(keyword in line for line in script for keyword in keywords)
78113

79-
def __iter_modules_from(
80-
package: PythonModule,
81-
predicate: Callable[[str], bool],
82-
) -> Iterator[tuple[str, PythonModule]]:
83-
package_name = package.__name__
114+
return cls(predicate)
84115

85-
try:
86-
package_path = package.__path__
87-
except AttributeError as exc:
88-
raise TypeError(f"`{package_name}` isn't Python package.") from exc
116+
@classmethod
117+
def startswith(cls, *prefixes: str) -> Self:
118+
def predicate(module_name: str) -> bool:
119+
script_name = module_name.split(".")[-1]
120+
return any(script_name.startswith(prefix) for prefix in prefixes)
89121

90-
for info in walk_packages(path=package_path, prefix=f"{package_name}."):
91-
name = info.name
122+
return cls(predicate)
92123

93-
if info.ispkg or name in sys.modules or not predicate(name):
94-
continue
124+
@classmethod
125+
def endswith(cls, *suffixes: str) -> Self:
126+
def predicate(module_name: str) -> bool:
127+
script_name = module_name.split(".")[-1]
128+
return any(script_name.endswith(suffix) for suffix in suffixes)
95129

96-
yield name, import_module(name)
130+
return cls(predicate)

tests/utils/package1/module_suffix.py

Whitespace-only changes.

tests/utils/package1/prefix_module.py

Whitespace-only changes.

tests/utils/test_load_modules_with_keywords.py

Lines changed: 0 additions & 14 deletions
This file was deleted.

tests/utils/test_load_packages.py

Lines changed: 0 additions & 59 deletions
This file was deleted.

0 commit comments

Comments
 (0)