Skip to content

Commit 40bd4d3

Browse files
authored
# Module Cache Mechanism Implementation (#17)
1 parent 89e8694 commit 40bd4d3

File tree

9 files changed

+573
-69
lines changed

9 files changed

+573
-69
lines changed

py_spring_core/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from py_spring_core.event.application_event_publisher import ApplicationEventPublisher
2323
from py_spring_core.event.commons import ApplicationEvent
2424

25-
__version__ = "0.0.21"
25+
__version__ = "0.0.22"
2626

2727
__all__ = [
2828
"PySpringApplication",

py_spring_core/commons/class_scanner.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import ast
2-
import importlib.util
3-
from typing import Iterable, Optional, Type
2+
import re
3+
from typing import Any, Iterable, Type
44

55
from loguru import logger
66

7+
from .module_importer import ModuleImporter
8+
79

810
class ClassScanner:
911
"""
@@ -17,6 +19,8 @@ class ClassScanner:
1719
def __init__(self, file_paths: Iterable[str]) -> None:
1820
self.file_paths = file_paths
1921
self.scanned_classes: dict[str, dict[str, Type[object]]] = {}
22+
# Use ModuleImporter for handling module imports
23+
self._module_importer = ModuleImporter()
2024

2125
def extract_classes_from_file(self, file_path: str) -> dict[str, Type[object]]:
2226
with open(file_path, "r") as file:
@@ -44,23 +48,25 @@ def _extract_classes_from_file_content(
4448

4549
return class_objects
4650

47-
def scan_classes_for_file_paths(self) -> None:
51+
def scan_classes_for_file_paths(self, exclude_file_patterns: Iterable[str]) -> None:
4852
for file_path in self.file_paths:
53+
if any(re.match(pattern, file_path) for pattern in exclude_file_patterns):
54+
logger.debug(f"[EXCLUDED FILE] {file_path} is excluded")
55+
continue
56+
4957
object_cls_dict: dict[str, Type[object]] = self.extract_classes_from_file(
5058
file_path
5159
)
5260
self.scanned_classes[file_path] = object_cls_dict
5361

5462
def import_class_from_file(
5563
self, file_path: str, class_name: str
56-
) -> Optional[Type[object]]:
57-
spec = importlib.util.spec_from_file_location(class_name, file_path)
58-
if spec is None:
64+
) -> Type[object] | None:
65+
# Use ModuleImporter to handle module import
66+
module = self._module_importer.import_module_from_path(file_path)
67+
if module is None:
5968
return None
60-
module = importlib.util.module_from_spec(spec)
61-
if spec.loader is None:
62-
return None
63-
spec.loader.exec_module(module)
69+
6470
cls = getattr(module, class_name, None)
6571
return cls
6672

@@ -79,3 +85,11 @@ def display_classes(self) -> None:
7985
repr += f" Class: {class_name}\n"
8086

8187
logger.debug(repr)
88+
89+
def clear_module_cache(self) -> None:
90+
"""Clear the module cache. Useful for testing or when you need to force re-import."""
91+
self._module_importer.clear_cache()
92+
93+
def get_cache_size(self) -> int:
94+
"""Get the number of cached modules."""
95+
return self._module_importer.get_cache_size()
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import importlib.util
2+
import inspect
3+
from pathlib import Path
4+
from typing import Any, Iterable, Type, Optional
5+
6+
from loguru import logger
7+
8+
9+
class ModuleImporter:
10+
"""
11+
A class that handles dynamic module importing with caching capabilities.
12+
Provides functionality to import modules from file paths and extract classes from them.
13+
"""
14+
15+
def __init__(self) -> None:
16+
# Module cache to prevent duplicate imports
17+
self._module_cache: dict[str, Any] = {}
18+
19+
def import_module_from_path(self, file_path: str) -> Optional[Any]:
20+
"""
21+
Import a module from a file path with caching.
22+
23+
Args:
24+
file_path (str): The file path of the module to import.
25+
26+
Returns:
27+
Optional[Any]: The imported module or None if import fails.
28+
"""
29+
resolved_path = Path(file_path).resolve()
30+
cache_key = str(resolved_path)
31+
module_name = resolved_path.stem
32+
33+
# Check if module is already cached
34+
if cache_key in self._module_cache:
35+
logger.debug(f"[MODULE CACHE] Using cached module: {module_name}")
36+
return self._module_cache[cache_key]
37+
38+
logger.info(f"[MODULE IMPORT] Import module path: {resolved_path}")
39+
40+
# Create a module specification
41+
spec = importlib.util.spec_from_file_location(module_name, resolved_path)
42+
if spec is None:
43+
logger.warning(f"[MODULE IMPORT] Could not create spec for {module_name}")
44+
return None
45+
46+
# Create a new module based on the specification
47+
module = importlib.util.module_from_spec(spec)
48+
if spec.loader is None:
49+
logger.warning(f"[MODULE IMPORT] No loader found for {module_name}")
50+
return None
51+
52+
# Execute the module in its own namespace
53+
logger.info(f"[MODULE IMPORT] Import module: {module_name}")
54+
try:
55+
spec.loader.exec_module(module)
56+
logger.success(f"[MODULE IMPORT] Successfully imported {module_name}")
57+
# Cache the module
58+
self._module_cache[cache_key] = module
59+
return module
60+
except Exception as error:
61+
logger.warning(f"[MODULE IMPORT] Failed to import {module_name}: {error}")
62+
return None
63+
64+
def extract_classes_from_module(self, module: Any) -> list[Type[object]]:
65+
"""
66+
Extract all classes from a module.
67+
68+
Args:
69+
module (Any): The module to extract classes from.
70+
71+
Returns:
72+
list[Type[object]]: List of classes found in the module.
73+
"""
74+
loaded_classes = []
75+
for attr in dir(module):
76+
obj = getattr(module, attr)
77+
if attr.startswith("__"):
78+
continue
79+
if not inspect.isclass(obj):
80+
continue
81+
loaded_classes.append(obj)
82+
return loaded_classes
83+
84+
def import_classes_from_paths(
85+
self,
86+
file_paths: Iterable[str],
87+
target_subclasses: Iterable[Type[object]] = [],
88+
ignore_errors: bool = True
89+
) -> set[Type[object]]:
90+
"""
91+
Import classes from multiple file paths with optional filtering.
92+
93+
Args:
94+
file_paths (Iterable[str]): The file paths of the modules to import.
95+
target_subclasses (Iterable[Type[object]], optional): Target subclasses to filter. Defaults to [].
96+
ignore_errors (bool, optional): Whether to ignore import errors. Defaults to True.
97+
98+
Returns:
99+
set[Type[object]]: Set of imported classes.
100+
"""
101+
all_loaded_classes: list[Type[object]] = []
102+
103+
for file_path in file_paths:
104+
module = self.import_module_from_path(file_path)
105+
if module is None:
106+
if not ignore_errors:
107+
raise ImportError(f"Failed to import module from {file_path}")
108+
continue
109+
110+
loaded_classes = self.extract_classes_from_module(module)
111+
all_loaded_classes.extend(loaded_classes)
112+
113+
returned_target_classes: set[Type[object]] = set()
114+
115+
# If no target subclasses specified, return all loaded classes
116+
if not target_subclasses:
117+
returned_target_classes = set(all_loaded_classes)
118+
else:
119+
# Filter classes based on target subclasses
120+
for target_cls in target_subclasses:
121+
for loaded_class in all_loaded_classes:
122+
if loaded_class in target_subclasses:
123+
continue
124+
if issubclass(loaded_class, target_cls):
125+
returned_target_classes.add(loaded_class)
126+
127+
return returned_target_classes
128+
129+
def clear_cache(self) -> None:
130+
"""Clear the module cache. Useful for testing or when you need to force re-import."""
131+
self._module_cache.clear()
132+
133+
def get_cache_size(self) -> int:
134+
"""Get the number of cached modules."""
135+
return len(self._module_cache)

py_spring_core/core/application/application_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ class ApplicationConfig(BaseModel):
3636
model_config = ConfigDict(protected_namespaces=())
3737

3838
app_src_target_dir: str
39+
exclude_file_patterns: list[str] = Field(default_factory=lambda: [r".*/models\.py$"])
3940
server_config: ServerConfig
4041
properties_file_path: str
4142
loguru_config: LoguruConfig

py_spring_core/core/application/py_spring_application.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,9 @@ def _get_system_managed_classes(self) -> Iterable[Type[Component]]:
123123
return [ApplicationEventPublisher, ApplicationEventHandlerRegistry]
124124

125125
def _scan_classes_for_project(self) -> Iterable[Type[object]]:
126-
self.app_class_scanner.scan_classes_for_file_paths()
126+
self.app_class_scanner.scan_classes_for_file_paths(
127+
self.app_config.exclude_file_patterns
128+
)
127129
return self.app_class_scanner.get_classes()
128130

129131
def _register_app_entities(self, classes: Iterable[Type[object]]) -> None:

py_spring_core/core/utils.py

Lines changed: 16 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
import importlib.util
21
import inspect
32
from abc import ABC
4-
from pathlib import Path
53
from typing import Any, Iterable, Type
64

75
from loguru import logger
86

7+
from ..commons.module_importer import ModuleImporter
8+
9+
# Global module importer instance
10+
_module_importer = ModuleImporter()
11+
912

1013
def dynamically_import_modules(
1114
module_paths: Iterable[str],
@@ -18,64 +21,21 @@ def dynamically_import_modules(
1821
Args:
1922
module_paths (Iterable[str]): The file paths of the modules to import.
2023
is_ignore_error (bool, optional): Whether to ignore any errors that occur during the import process. Defaults to True.
24+
target_subclasses (Iterable[Type[object]], optional): Target subclasses to filter. Defaults to [].
2125
2226
Raises:
2327
Exception: If an error occurs during the import process and `is_ignore_error` is False.
2428
"""
25-
all_loaded_classes: list[Type[object]] = []
26-
27-
for module_path in module_paths:
28-
file_path = Path(module_path).resolve()
29-
module_name = file_path.stem
30-
logger.info(f"[MODULE IMPORT] Import module path: {file_path}")
31-
# Create a module specification
32-
spec = importlib.util.spec_from_file_location(module_name, file_path)
33-
if spec is None:
34-
logger.warning(
35-
f"[DYNAMICALLY MODULE IMPORT] Could not create spec for {module_name}"
36-
)
37-
continue
38-
39-
# Create a new module based on the specification
40-
module = importlib.util.module_from_spec(spec)
41-
if spec.loader is None:
42-
logger.warning(
43-
f"[DYNAMICALLY MODULE IMPORT] No loader found for {module_name}"
44-
)
45-
continue
46-
47-
# Execute the module in its own namespace
48-
49-
logger.info(f"[DYNAMICALLY MODULE IMPORT] Import module: {module_name}")
50-
try:
51-
spec.loader.exec_module(module)
52-
logger.success(
53-
f"[DYNAMICALLY MODULE IMPORT] Successfully imported {module_name}"
54-
)
55-
except Exception as error:
56-
logger.warning(error)
57-
if not is_ignore_error:
58-
raise error
59-
60-
loaded_classes = []
61-
for attr in dir(module):
62-
obj = getattr(module, attr)
63-
if attr.startswith("__"):
64-
continue
65-
if not inspect.isclass(obj):
66-
continue
67-
loaded_classes.append(obj)
68-
all_loaded_classes.extend(loaded_classes)
69-
70-
returned_target_classes: set[Type[object]] = set()
71-
for target_cls in target_subclasses:
72-
for loaded_class in all_loaded_classes:
73-
if loaded_class in target_subclasses:
74-
continue
75-
if issubclass(loaded_class, target_cls):
76-
returned_target_classes.add(loaded_class)
77-
78-
return returned_target_classes
29+
return _module_importer.import_classes_from_paths(
30+
file_paths=module_paths,
31+
target_subclasses=target_subclasses,
32+
ignore_errors=is_ignore_error
33+
)
34+
35+
36+
def clear_module_cache() -> None:
37+
"""Clear the global module cache. Useful for testing or when you need to force re-import."""
38+
_module_importer.clear_cache()
7939

8040

8141
def get_unimplemented_abstract_methods(cls: Type[Any]) -> set[str]:

tests/test_component_features.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from abc import ABC
21
from typing import Annotated
32

43
import pytest

0 commit comments

Comments
 (0)