Skip to content

Commit 3a9333c

Browse files
committed
🌱 implement DependencyManager for managing optional dependencies
1 parent d42f4db commit 3a9333c

File tree

1 file changed

+281
-0
lines changed

1 file changed

+281
-0
lines changed
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
from functools import wraps
2+
from importlib import metadata
3+
import sys
4+
from types import ModuleType
5+
from typing import Any, Callable, ClassVar, Optional, TypedDict, TypeVar
6+
7+
from nonebot.log import logger
8+
from packaging import version
9+
10+
if sys.version_info >= (3, 11):
11+
from typing import Self
12+
else:
13+
from typing_extensions import Self
14+
15+
16+
class DependencyInfo(TypedDict):
17+
min_version: Optional[str]
18+
required: bool
19+
available: bool
20+
loaded: bool
21+
22+
23+
ComponentDeps = dict[str, set[str]]
24+
ModuleCache = dict[str, ModuleType]
25+
T = TypeVar("T")
26+
27+
28+
class DependencyManager:
29+
"""A singleton class to manage optional dependencies.
30+
31+
Provides functionality to register, check and load optional dependencies
32+
for different components of the application.
33+
34+
Attributes:
35+
_instance: The singleton instance of dependency manager.
36+
_initialized: Whether the instance has been initialized.
37+
_dependencies: Dict storing dependency configurations.
38+
_modules: Dict storing loaded module instances.
39+
_component_deps: Dict mapping components to their dependencies.
40+
"""
41+
42+
_instance: ClassVar[Optional[Self]] = None
43+
_initialized: bool = False
44+
45+
def __new__(cls) -> Self:
46+
if cls._instance is None:
47+
cls._instance = super().__new__(cls)
48+
return cls._instance
49+
50+
def __init__(self) -> None:
51+
if self._initialized:
52+
return
53+
54+
self._dependencies: dict[str, DependencyInfo] = {}
55+
self._modules: ModuleCache = {}
56+
self._component_deps: ComponentDeps = {}
57+
self._initialized = True
58+
59+
def register_dependency(
60+
self,
61+
name: str,
62+
min_version: Optional[str] = None,
63+
component: Optional[str] = None,
64+
*,
65+
required: bool = False,
66+
) -> None:
67+
"""Registers a new dependency.
68+
69+
Args:
70+
name: Name of the dependency.
71+
min_version: Minimum version requirement.
72+
required: Whether the dependency is required.
73+
component: Name of component this dependency belongs to.
74+
75+
Example:
76+
>>> dependency_manager = DependencyManager()
77+
>>> dependency_manager.register_dependency(
78+
... "numpy",
79+
... min_version="1.20.0",
80+
... component="math"
81+
... )
82+
"""
83+
if name in self._dependencies:
84+
dep = self._dependencies[name]
85+
if min_version:
86+
dep["min_version"] = min_version
87+
dep["required"] |= required
88+
else:
89+
self._dependencies[name] = {
90+
"min_version": min_version,
91+
"required": required,
92+
"available": False,
93+
"loaded": False,
94+
}
95+
96+
if component:
97+
self._component_deps.setdefault(component, set()).add(name)
98+
99+
def check_dependency(self, name: str) -> bool:
100+
"""Checks if a dependency is available.
101+
102+
Args:
103+
name: Name of dependency to check.
104+
105+
Returns:
106+
bool: True if dependency is available, False otherwise.
107+
108+
Raises:
109+
ValueError: If dependency name is unknown.
110+
"""
111+
if name not in self._dependencies:
112+
raise ValueError(
113+
f"Dependency '{name}' not registered. Call register_dependency() first."
114+
)
115+
116+
dep = self._dependencies[name]
117+
if dep["available"]:
118+
return True
119+
120+
try:
121+
pkg_version = metadata.version(name)
122+
except metadata.PackageNotFoundError:
123+
return False
124+
125+
if dep["min_version"] and version.parse(pkg_version) < version.parse(
126+
dep["min_version"]
127+
):
128+
return False
129+
130+
dep["available"] = True
131+
return True
132+
133+
def load_dependency(self, name: str) -> Optional[ModuleType]:
134+
"""Loads a dependency module.
135+
136+
Args:
137+
name: Name of dependency to load.
138+
139+
Returns:
140+
Optional[ModuleType]: Loaded module or None if not available.
141+
142+
Raises:
143+
ValueError: If dependency name is unknown.
144+
ImportError: If required dependency cannot be loaded.
145+
"""
146+
if name not in self._dependencies:
147+
raise ValueError(f"Unknown dependency: {name}")
148+
149+
if name in self._modules:
150+
return self._modules[name]
151+
152+
dep = self._dependencies[name]
153+
if not self.check_dependency(name):
154+
if dep["required"]:
155+
raise ImportError(f"Required dependency {name} is not available")
156+
return None
157+
158+
try:
159+
module = __import__(name, fromlist=["*"])
160+
except ImportError as e:
161+
logger.debug(f"Failed to load {name}: {e}")
162+
if dep["required"]:
163+
raise
164+
return None
165+
166+
self._modules[name] = module
167+
dep["loaded"] = True
168+
return module
169+
170+
def get_module(self, name: str) -> Optional[ModuleType]:
171+
"""Gets a loaded module by name.
172+
173+
Args:
174+
name: Name of module to get.
175+
176+
Returns:
177+
Optional[ModuleType]: Requested module or None if not available.
178+
"""
179+
if name not in self._modules:
180+
return self.load_dependency(name)
181+
return self._modules[name]
182+
183+
def check_component(self, component: str) -> bool:
184+
"""Checks if all dependencies of a component are available.
185+
186+
Args:
187+
component: Name of component to check.
188+
189+
Returns:
190+
bool: True if all dependencies are available, False otherwise.
191+
"""
192+
if component not in self._component_deps:
193+
return False
194+
195+
deps = self._component_deps[component]
196+
return all(self.check_dependency(dep) for dep in deps)
197+
198+
def load_component(self, component: str) -> dict[str, Optional[ModuleType]]:
199+
"""Loads all dependencies of a component.
200+
201+
Args:
202+
component: Name of component to load dependencies for.
203+
204+
Returns:
205+
dict[str, Optional[ModuleType]]: Dict of loaded modules.
206+
207+
Raises:
208+
ValueError: If component name is unknown.
209+
"""
210+
if component not in self._component_deps:
211+
raise ValueError(f"Unknown component: {component}")
212+
213+
return {
214+
name: self.load_dependency(name) for name in self._component_deps[component]
215+
}
216+
217+
def requires(
218+
self, *dependencies: str, component: Optional[str] = None
219+
) -> Callable[[Callable[..., T]], Callable[..., T]]:
220+
"""Decorator to mark function dependencies.
221+
222+
Args:
223+
*dependencies: Names of required dependencies.
224+
component: Optional component name.
225+
226+
Returns:
227+
Callable: Decorator function that checks dependencies before execution.
228+
229+
Example:
230+
>>> @dependency_manager.requires("numpy", "pandas", component="data")
231+
... def process_data(df):
232+
... pass
233+
"""
234+
if component:
235+
for dep in dependencies:
236+
if dep not in self._dependencies:
237+
self.register_dependency(dep, component=component)
238+
elif component not in self._component_deps:
239+
self._component_deps[component] = {dep}
240+
else:
241+
self._component_deps[component].add(dep)
242+
243+
def decorator(func: Callable[..., T]) -> Callable[..., T]:
244+
@wraps(func)
245+
def wrapper(*args: Any, **kwargs: Any) -> T:
246+
missing = [
247+
dep for dep in dependencies if not self.check_dependency(dep)
248+
]
249+
if missing:
250+
raise ImportError(
251+
f"Missing required dependencies for {func.__name__}: {', '.join(missing)}"
252+
)
253+
return func(*args, **kwargs)
254+
255+
return wrapper
256+
257+
return decorator
258+
259+
def clear(self) -> None:
260+
"""Clears all dependency states and caches."""
261+
self._dependencies.clear()
262+
self._modules.clear()
263+
self._component_deps.clear()
264+
265+
@staticmethod
266+
def get_version(name: str) -> Optional[str]:
267+
"""Gets installed version of a package.
268+
269+
Args:
270+
name: Name of package.
271+
272+
Returns:
273+
Optional[str]: Package version or None if not found.
274+
"""
275+
try:
276+
return metadata.version(name)
277+
except metadata.PackageNotFoundError:
278+
return None
279+
280+
281+
dependency_manager = DependencyManager()

0 commit comments

Comments
 (0)