1+ import itertools
12import sys
2- from collections .abc import Callable , Collection , Iterator
3+ from collections .abc import Callable , Iterator , Mapping
4+ from dataclasses import dataclass , field
35from importlib import import_module
46from importlib .util import find_spec
7+ from os .path import isfile
58from pkgutil import walk_packages
9+ from types import MappingProxyType
610from types import ModuleType as PythonModule
7- from typing import ContextManager
11+ from typing import ClassVar , ContextManager , Self
812
913from 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
1518def 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 )
0 commit comments