import os
import warnings
from runpy import run_path, run_module
from typing import Dict, Optional, Callable
from typing import Set
from pycollect import PythonFileCollector, module_finder
from injectable.container.injectable import Injectable
from injectable.container.namespace import Namespace
from injectable.common_utils import get_caller_filepath
from injectable.constants import DEFAULT_NAMESPACE
[docs]class InjectionContainer:
"""
InjectionContainer globally manages injection namespaces and the respective
injectables registries.
This class shouldn't be used directly and will be removed from the injectable's
public API in the future.
Invoking :func:`load_injection_container` is the only necessary action before
injecting dependencies. Attempting to call an autowired function before invoking
:func:`load_injection_container` will log a warning indicating that the injection
container is empty.
This class is not meant to be instantiated and will raise an error if instantiation
is attempted.
.. deprecated:: 3.4.0
This class will be removed from the public API in the future.
"""
LOADING_DEFAULT_NAMESPACE: Optional[str] = None
LOADING_FILEPATH: Optional[str] = None
LOADED_FILEPATHS: Set[str] = set()
NAMESPACES: Dict[str, Namespace] = {}
def __new__(cls):
raise NotImplementedError("InjectionContainer must not be instantiated")
[docs] @classmethod
def load(
cls,
search_path: str = None,
*,
default_namespace: str = DEFAULT_NAMESPACE,
):
"""
Loads injectables under the search path to the :class:`InjectionContainer`
under the designated namespaces.
:param search_path: (optional) path under which to search for injectables. Can
be either a relative or absolute path. Defaults to the caller's file
directory.
:param default_namespace: (optional) designated namespace for registering
injectables which does not explicitly request to be addressed in a
specific namespace. Defaults to
:const:`injectable.constants.DEFAULT_NAMESPACE`.
Usage::
>>> from injectable import InjectionContainer
>>> InjectionContainer.load()
.. note::
This method will not scan any file more than once regardless of being
called successively. Multiple invocations to different search paths will
add found injectables to the :class:`InjectionContainer` without clearing
previously found ones.
.. deprecated:: 3.4.0
This method will be removed from the public API in the future. Use
:func:`load_injection_container` instead.
"""
warnings.warn(
"Using 'load' directly from the 'InjectionContainer' is deprecated."
" Use 'load_injection_container' instead. This class will be removed from"
" the injectable's public API in the future.",
DeprecationWarning,
2,
)
cls.LOADING_DEFAULT_NAMESPACE = default_namespace
if default_namespace not in cls.NAMESPACES:
cls.NAMESPACES[default_namespace] = Namespace()
if search_path is None:
search_path = os.path.dirname(get_caller_filepath())
elif not os.path.isabs(search_path):
caller_path = os.path.dirname(get_caller_filepath())
search_path = os.path.normpath(os.path.join(caller_path, search_path))
cls._link_dependencies(search_path)
cls.LOADING_DEFAULT_NAMESPACE = None
@classmethod
def _register_injectable(
cls,
klass: type,
filepath: str,
qualifier: str = None,
primary: bool = False,
namespace: str = None,
group: str = None,
singleton: bool = False,
):
unique_id = f"{klass.__qualname__}@{filepath}"
injectable = Injectable(klass, unique_id, primary, group, singleton)
namespace_entry = cls._get_namespace_entry(
namespace or cls.LOADING_DEFAULT_NAMESPACE
)
namespace_entry.register_injectable(injectable, klass, qualifier)
@classmethod
def _register_factory(
cls,
factory: Callable,
filepath: str,
dependency: Optional[type] = None,
qualifier: str = None,
primary: bool = False,
namespace: str = None,
group: str = None,
singleton: bool = False,
):
unique_id = f"{factory.__qualname__}@{filepath}"
injectable = Injectable(factory, unique_id, primary, group, singleton)
namespace_entry = cls._get_namespace_entry(
namespace or cls.LOADING_DEFAULT_NAMESPACE
)
namespace_entry.register_injectable(injectable, dependency, qualifier)
@classmethod
def _get_namespace_entry(cls, namespace: str) -> Namespace:
if namespace not in cls.NAMESPACES:
cls.NAMESPACES[namespace] = Namespace()
return cls.NAMESPACES[namespace]
@classmethod
def _link_dependencies(cls, search_path: str):
files = cls._collect_python_files(search_path)
for file in files:
if not cls._contains_injectables(file, encoding="utf-8"):
continue
if file.path in cls.LOADED_FILEPATHS:
continue
cls.LOADING_FILEPATH = file.path
try:
run_module(module_finder.find_module_name(file.path))
except AttributeError:
# This is needed for some corner cases involving pytest
# See more at https://github.com/pytest-dev/pytest/issues/9007
run_path(file.path)
cls.LOADED_FILEPATHS.add(file.path)
cls.LOADING_FILEPATH = None
@classmethod
def load_dependencies_from(
cls, absolute_search_path: str, default_namespace: str, encoding: str = "utf-8"
):
files = cls._collect_python_files(absolute_search_path)
cls.LOADING_DEFAULT_NAMESPACE = default_namespace
if default_namespace not in cls.NAMESPACES:
cls.NAMESPACES[default_namespace] = Namespace()
for file in files:
if not cls._contains_injectables(file, encoding):
continue
if file.path in cls.LOADED_FILEPATHS:
continue
cls.LOADING_FILEPATH = file.path
try:
run_module(module_finder.find_module_name(file.path))
except AttributeError:
# This is needed for some corner cases involving pytest
# See more at https://github.com/pytest-dev/pytest/issues/9007
run_path(file.path)
cls.LOADED_FILEPATHS.add(file.path)
cls.LOADING_FILEPATH = None
cls.LOADING_DEFAULT_NAMESPACE = None
@classmethod
def _collect_python_files(cls, search_path) -> Set[os.DirEntry]:
collector = PythonFileCollector()
return collector.collect(search_path)
@classmethod
def _contains_injectables(cls, file_entry: os.DirEntry, encoding: str) -> bool:
with open(file_entry, encoding=encoding) as file:
source = file.read()
# TODO: Consider the use of ast.parse for this
return any(
usage in source
for usage in [
"@injectable",
"injectable(",
"@injectable_factory",
"injectable_factory(",
]
)