diff --git a/src/cpl-api/cpl/api/__init__.py b/src/cpl-api/cpl/api/__init__.py index e69de29b..f5309f9d 100644 --- a/src/cpl-api/cpl/api/__init__.py +++ b/src/cpl-api/cpl/api/__init__.py @@ -0,0 +1,20 @@ +from cpl.dependency.service_collection import ServiceCollection as _ServiceCollection + +def add_api(collection: _ServiceCollection): + try: + from cpl.database import mysql + collection.add_module(mysql) + except ImportError as e: + from cpl.core.errors import dependency_error + dependency_error("cpl-database", e) + + try: + from cpl import auth + from cpl.auth import permission + collection.add_module(auth) + collection.add_module(permission) + except ImportError as e: + from cpl.core.errors import dependency_error + dependency_error("cpl-auth", e) + +_ServiceCollection.with_module(add_api, __name__) \ No newline at end of file diff --git a/src/cpl-api/cpl/api/web_app.py b/src/cpl-api/cpl/api/web_app.py index 6c668782..9dbcb358 100644 --- a/src/cpl-api/cpl/api/web_app.py +++ b/src/cpl-api/cpl/api/web_app.py @@ -10,6 +10,7 @@ from starlette.responses import JSONResponse from starlette.routing import Route from starlette.types import ExceptionHandler +from cpl import api, auth from cpl.api.api_logger import APILogger from cpl.api.api_settings import ApiSettings from cpl.api.error import APIError @@ -27,7 +28,7 @@ _logger = APILogger("API") class WebApp(ApplicationABC): def __init__(self, services: ServiceProviderABC): - super().__init__(services) + super().__init__(services, [auth, api]) self._app: Starlette | None = None self._api_settings = Configuration.get(ApiSettings) diff --git a/src/cpl-application/cpl/application/abc/application_abc.py b/src/cpl-application/cpl/application/abc/application_abc.py index 9de8b97e..2aa5e7fc 100644 --- a/src/cpl-application/cpl/application/abc/application_abc.py +++ b/src/cpl-application/cpl/application/abc/application_abc.py @@ -22,8 +22,16 @@ class ApplicationABC(ABC): """ @abstractmethod - def __init__(self, services: ServiceProviderABC): + def __init__(self, services: ServiceProviderABC, required_modules: list[str | object] = None): self._services = services + self._required_modules = [ + x.__name__ if not isinstance(x, str) else x + for x in required_modules + ] if required_modules else [] + + @property + def required_modules(self) -> list[str]: + return self._required_modules @classmethod def extend(cls, name: str | Callable, func: Callable[[Self], Self]): diff --git a/src/cpl-application/cpl/application/application_builder.py b/src/cpl-application/cpl/application/application_builder.py index 8df7b60c..a1303aa1 100644 --- a/src/cpl-application/cpl/application/application_builder.py +++ b/src/cpl-application/cpl/application/application_builder.py @@ -6,6 +6,7 @@ from cpl.application.abc.application_extension_abc import ApplicationExtensionAB from cpl.application.abc.startup_abc import StartupABC from cpl.application.abc.startup_extension_abc import StartupExtensionABC from cpl.application.host import Host +from cpl.core.errors import dependency_error from cpl.dependency.service_collection import ServiceCollection TApp = TypeVar("TApp", bound=ApplicationABC) @@ -35,6 +36,18 @@ class ApplicationBuilder(Generic[TApp]): def service_provider(self): return self._services.build() + def validate_app_required_modules(self, app: ApplicationABC): + for module in app.required_modules: + if module in self._services.loaded_modules: + continue + + dependency_error( + module, + ImportError( + f"Required module '{module}' for application '{app.__class__.__name__}' is not loaded. Load using 'add_module({module})' method." + ), + ) + def with_startup(self, startup: Type[StartupABC]) -> "ApplicationBuilder": self._startup = startup return self @@ -62,4 +75,6 @@ class ApplicationBuilder(Generic[TApp]): for extension in self._app_extensions: Host.run(extension.run, self.service_provider) - return self._app(self.service_provider) + app = self._app(self.service_provider) + self.validate_app_required_modules(app) + return app diff --git a/src/cpl-auth/cpl/auth/__init__.py b/src/cpl-auth/cpl/auth/__init__.py index e7f292a5..1db9cf00 100644 --- a/src/cpl-auth/cpl/auth/__init__.py +++ b/src/cpl-auth/cpl/auth/__init__.py @@ -40,11 +40,10 @@ def _add_daos(collection: _ServiceCollection): def add_auth(collection: _ServiceCollection): import os - from cpl.core.console import Console - from cpl.database.service.migration_service import MigrationService - from cpl.database.model.server_type import ServerType, ServerTypes - try: + from cpl.database.service.migration_service import MigrationService + from cpl.database.model.server_type import ServerType, ServerTypes + collection.add_singleton(_KeycloakClient) collection.add_singleton(_KeycloakAdmin) @@ -59,22 +58,23 @@ def add_auth(collection: _ServiceCollection): elif ServerType.server_type == ServerTypes.MYSQL: migration_service.with_directory(os.path.join(os.path.dirname(os.path.realpath(__file__)), "scripts/mysql")) except ImportError as e: - Console.error("cpl-auth is not installed", str(e)) + from cpl.core.console import Console + Console.error("cpl-database is not installed", str(e)) def add_permission(collection: _ServiceCollection): - from cpl.auth.permission_seeder import PermissionSeeder - from cpl.database.abc.data_seeder_abc import DataSeederABC - from cpl.auth.permission.permissions_registry import PermissionsRegistry - from cpl.auth.permission.permissions import Permissions + from .permission_seeder import PermissionSeeder + from .permission.permissions_registry import PermissionsRegistry + from .permission.permissions import Permissions try: + from cpl.database.abc.data_seeder_abc import DataSeederABC collection.add_singleton(DataSeederABC, PermissionSeeder) PermissionsRegistry.with_enum(Permissions) except ImportError as e: from cpl.core.console import Console - Console.error("cpl-auth is not installed", str(e)) + Console.error("cpl-database is not installed", str(e)) _ServiceCollection.with_module(add_auth, __name__) diff --git a/src/cpl-core/cpl/core/configuration/configuration.py b/src/cpl-core/cpl/core/configuration/configuration.py index 79b50bfa..5963d6db 100644 --- a/src/cpl-core/cpl/core/configuration/configuration.py +++ b/src/cpl-core/cpl/core/configuration/configuration.py @@ -130,7 +130,7 @@ class Configuration: key_name = key.__name__ if inspect.isclass(key) else key result = cls._config.get(key_name, default) - if issubclass(key, ConfigurationModelABC) and result == default: + if isclass(key) and issubclass(key, ConfigurationModelABC) and result == default: result = key() cls.set(key, result) diff --git a/src/cpl-core/cpl/core/configuration/configuration_model_abc.py b/src/cpl-core/cpl/core/configuration/configuration_model_abc.py index d4306b67..e48eb36f 100644 --- a/src/cpl-core/cpl/core/configuration/configuration_model_abc.py +++ b/src/cpl-core/cpl/core/configuration/configuration_model_abc.py @@ -68,7 +68,7 @@ class ConfigurationModelABC(ABC): value = cast(Environment.get(env_field, str), cast_type) if value is None and required: - raise ValueError(f"{field} is required") + raise ValueError(f"{type(self).__name__}.{field} is required") elif value is None: self._options[field] = default return diff --git a/src/cpl-core/cpl/core/errors.py b/src/cpl-core/cpl/core/errors.py new file mode 100644 index 00000000..bfbd9d15 --- /dev/null +++ b/src/cpl-core/cpl/core/errors.py @@ -0,0 +1,15 @@ +import traceback + +from cpl.core.console import Console + + +def dependency_error(package_name: str, e: ImportError) -> None: + Console.error(f"'{package_name}' is required to use this feature. Please install it and try again.") + tb = traceback.format_exc() + if not tb.startswith("NoneType: None"): + Console.write_line("->", tb) + + elif e is not None: + Console.write_line("->", str(e)) + + exit(1) \ No newline at end of file diff --git a/src/cpl-dependency/cpl/dependency/service_collection.py b/src/cpl-dependency/cpl/dependency/service_collection.py index e1618cbc..3db2df36 100644 --- a/src/cpl-dependency/cpl/dependency/service_collection.py +++ b/src/cpl-dependency/cpl/dependency/service_collection.py @@ -1,4 +1,4 @@ -from typing import Union, Type, Callable +from typing import Union, Type, Callable, Self from cpl.core.log.logger import Logger from cpl.core.log.logger_abc import LoggerABC @@ -15,12 +15,17 @@ class ServiceCollection: _modules: dict[str, Callable] = {} @classmethod - def with_module(cls, func: Callable, name: str = None): + def with_module(cls, func: Callable, name: str = None) -> type[Self]: cls._modules[func.__name__ if name is None else name] = func return cls def __init__(self): self._service_descriptors: list[ServiceDescriptor] = [] + self._loaded_modules: set[str] = set() + + @property + def loaded_modules(self) -> set[str]: + return self._loaded_modules def _add_descriptor(self, service: Union[type, object], lifetime: ServiceLifetimeEnum, base_type: Callable = None): found = False @@ -45,15 +50,15 @@ class ServiceCollection: return self - def add_singleton(self, service_type: T, service: Service = None): + def add_singleton(self, service_type: T, service: Service = None) -> Self: self._add_descriptor_by_lifetime(service_type, ServiceLifetimeEnum.singleton, service) return self - def add_scoped(self, service_type: T, service: Service = None): + def add_scoped(self, service_type: T, service: Service = None) -> Self: self._add_descriptor_by_lifetime(service_type, ServiceLifetimeEnum.scoped, service) return self - def add_transient(self, service_type: T, service: Service = None): + def add_transient(self, service_type: T, service: Service = None) -> Self: self._add_descriptor_by_lifetime(service_type, ServiceLifetimeEnum.transient, service) return self @@ -62,7 +67,7 @@ class ServiceCollection: ServiceProviderABC.set_global_provider(sp) return sp - def add_module(self, module: str | object): + def add_module(self, module: str | object) -> Self: if not isinstance(module, str): module = module.__name__ @@ -70,7 +75,10 @@ class ServiceCollection: raise ValueError(f"Module {module} not found") self._modules[module](self) + if module not in self._loaded_modules: + self._loaded_modules.add(module) + return self - def add_logging(self): + def add_logging(self) -> Self: self.add_transient(LoggerABC, Logger) return self diff --git a/tests/custom/api/src/appsettings.development.json b/tests/custom/api/src/appsettings.development.json new file mode 100644 index 00000000..4f0c6a8a --- /dev/null +++ b/tests/custom/api/src/appsettings.development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "Path": "logs/", + "Filename": "log_$start_time.log", + "ConsoleLevel": "TRACE", + "Level": "TRACE" + } +} \ No newline at end of file diff --git a/tests/custom/api/src/appsettings.edrafts-pc.json b/tests/custom/api/src/appsettings.edrafts-pc.json new file mode 100644 index 00000000..3016d50a --- /dev/null +++ b/tests/custom/api/src/appsettings.edrafts-pc.json @@ -0,0 +1,26 @@ +{ + "TimeFormat": { + "DateFormat": "%Y-%m-%d", + "TimeFormat": "%H:%M:%S", + "DateTimeFormat": "%Y-%m-%d %H:%M:%S.%f", + "DateTimeLogFormat": "%Y-%m-%d_%H-%M-%S" + }, + + "Log": { + "Path": "logs/", + "Filename": "log_$start_time.log", + "ConsoleLevel": "TRACE", + "Level": "TRACE" + }, + + "Database": { + "Host": "localhost", + "User": "cpl", + "Port": 3306, + "Password": "cpl", + "Database": "cpl", + "Charset": "utf8mb4", + "UseUnicode": "true", + "Buffered": "true" + } +} \ No newline at end of file diff --git a/tests/custom/api/src/appsettings.json b/tests/custom/api/src/appsettings.json new file mode 100644 index 00000000..089c1b07 --- /dev/null +++ b/tests/custom/api/src/appsettings.json @@ -0,0 +1,15 @@ +{ + "TimeFormat": { + "DateFormat": "%Y-%m-%d", + "TimeFormat": "%H:%M:%S", + "DateTimeFormat": "%Y-%m-%d %H:%M:%S.%f", + "DateTimeLogFormat": "%Y-%m-%d_%H-%M-%S" + }, + + "Log": { + "Path": "logs/", + "Filename": "log_$start_time.log", + "ConsoleLevel": "ERROR", + "Level": "WARNING" + } +} \ No newline at end of file diff --git a/tests/custom/api/src/main.py b/tests/custom/api/src/main.py index e0064b47..db9d0e63 100644 --- a/tests/custom/api/src/main.py +++ b/tests/custom/api/src/main.py @@ -1,15 +1,24 @@ from starlette.responses import JSONResponse +from cpl import api from cpl.api.web_app import WebApp from cpl.application import ApplicationBuilder +from cpl.core.configuration import Configuration +from cpl.core.environment import Environment from service import PingService def main(): builder = ApplicationBuilder[WebApp](WebApp) + + Configuration.add_json_file(f"appsettings.json") + Configuration.add_json_file(f"appsettings.{Environment.get_environment()}.json") + Configuration.add_json_file(f"appsettings.{Environment.get_host_name()}.json", optional=True) + builder.services.add_logging() builder.services.add_transient(PingService) + builder.services.add_module(api) app = builder.build() app.with_route(path="/route1", fn=lambda r: JSONResponse("route1"), method="GET")