From ddc62dfb9aeea65e63c5140ce3bbdeee981bc718 Mon Sep 17 00:00:00 2001 From: edraft Date: Fri, 19 Sep 2025 21:03:33 +0200 Subject: [PATCH] Added api & route handling --- .gitea/workflows/build-dev.yaml | 7 + src/cpl-api/cpl/api/__init__.py | 0 src/cpl-api/cpl/api/api_logger.py | 7 + src/cpl-api/cpl/api/api_settings.py | 13 ++ src/cpl-api/cpl/api/error.py | 25 +++ src/cpl-api/cpl/api/middleware/__init__.py | 0 src/cpl-api/cpl/api/middleware/logging.py | 65 ++++++++ src/cpl-api/cpl/api/middleware/request.py | 48 ++++++ src/cpl-api/cpl/api/router.py | 60 +++++++ src/cpl-api/cpl/api/typing.py | 13 ++ src/cpl-api/cpl/api/web_app.py | 153 ++++++++++++++++++ src/cpl-api/pyproject.toml | 30 ++++ src/cpl-api/requirements.dev.txt | 1 + src/cpl-api/requirements.txt | 6 + .../cpl/application/abc/application_abc.py | 2 +- .../cpl/application/application_builder.py | 9 +- .../cpl/core/configuration/configuration.py | 11 +- .../configuration/configuration_model_abc.py | 5 +- src/cpl-core/cpl/core/log/__init__.py | 4 +- .../log/{log_level_enum.py => log_level.py} | 0 .../{logging_settings.py => log_settings.py} | 6 +- src/cpl-core/cpl/core/log/logger.py | 31 +++- src/cpl-core/cpl/core/log/logger_abc.py | 2 +- src/cpl-core/cpl/core/utils/cast.py | 13 +- src/cpl-core/cpl/core/utils/get_value.py | 5 +- src/cpl-core/cpl/core/utils/string.py | 33 +++- .../database/abc/data_access_object_abc.py | 5 +- .../cpl/dependency/service_provider.py | 2 +- .../cpl/dependency/service_provider_abc.py | 8 +- .../cpl/mail/email_client_settings.py | 6 +- tests/custom/api/src/main.py | 23 +++ tests/custom/api/src/routes/__init__.py | 0 tests/custom/api/src/routes/ping.py | 13 ++ tests/custom/api/src/service.py | 4 + 34 files changed, 568 insertions(+), 42 deletions(-) create mode 100644 src/cpl-api/cpl/api/__init__.py create mode 100644 src/cpl-api/cpl/api/api_logger.py create mode 100644 src/cpl-api/cpl/api/api_settings.py create mode 100644 src/cpl-api/cpl/api/error.py create mode 100644 src/cpl-api/cpl/api/middleware/__init__.py create mode 100644 src/cpl-api/cpl/api/middleware/logging.py create mode 100644 src/cpl-api/cpl/api/middleware/request.py create mode 100644 src/cpl-api/cpl/api/router.py create mode 100644 src/cpl-api/cpl/api/typing.py create mode 100644 src/cpl-api/cpl/api/web_app.py create mode 100644 src/cpl-api/pyproject.toml create mode 100644 src/cpl-api/requirements.dev.txt create mode 100644 src/cpl-api/requirements.txt rename src/cpl-core/cpl/core/log/{log_level_enum.py => log_level.py} (100%) rename src/cpl-core/cpl/core/log/{logging_settings.py => log_settings.py} (70%) create mode 100644 tests/custom/api/src/main.py create mode 100644 tests/custom/api/src/routes/__init__.py create mode 100644 tests/custom/api/src/routes/ping.py create mode 100644 tests/custom/api/src/service.py diff --git a/.gitea/workflows/build-dev.yaml b/.gitea/workflows/build-dev.yaml index 71d9b284..374ff2a5 100644 --- a/.gitea/workflows/build-dev.yaml +++ b/.gitea/workflows/build-dev.yaml @@ -12,6 +12,13 @@ jobs: version_suffix: 'dev' secrets: inherit + api: + uses: ./.gitea/workflows/package.yaml + needs: [ prepare, application, auth, core, dependency ] + with: + working_directory: src/cpl-application + secrets: inherit + application: uses: ./.gitea/workflows/package.yaml needs: [ prepare, core, dependency ] diff --git a/src/cpl-api/cpl/api/__init__.py b/src/cpl-api/cpl/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/cpl-api/cpl/api/api_logger.py b/src/cpl-api/cpl/api/api_logger.py new file mode 100644 index 00000000..e7f2cfd2 --- /dev/null +++ b/src/cpl-api/cpl/api/api_logger.py @@ -0,0 +1,7 @@ +from cpl.core.log.logger import Logger + + +class APILogger(Logger): + + def __init__(self, source: str): + Logger.__init__(self, source, "api") diff --git a/src/cpl-api/cpl/api/api_settings.py b/src/cpl-api/cpl/api/api_settings.py new file mode 100644 index 00000000..2f11f5d7 --- /dev/null +++ b/src/cpl-api/cpl/api/api_settings.py @@ -0,0 +1,13 @@ +from typing import Optional + +from cpl.core.configuration import ConfigurationModelABC + + +class ApiSettings(ConfigurationModelABC): + + def __init__(self, src: Optional[dict] = None): + super().__init__(src) + + self.option("host", str, "0.0.0.0") + self.option("port", int, 5000) + self.option("allowed_origins", list[str]) diff --git a/src/cpl-api/cpl/api/error.py b/src/cpl-api/cpl/api/error.py new file mode 100644 index 00000000..89d5373c --- /dev/null +++ b/src/cpl-api/cpl/api/error.py @@ -0,0 +1,25 @@ +from http.client import HTTPException + + +class APIError(HTTPException): + status_code = 500 + + +class Unauthorized(APIError): + status_code = 401 + + +class Forbidden(APIError): + status_code = 403 + + +class NotFound(APIError): + status_code = 404 + + +class AlreadyExists(APIError): + status_code = 409 + + +class EndpointNotImplemented(APIError): + status_code = 501 diff --git a/src/cpl-api/cpl/api/middleware/__init__.py b/src/cpl-api/cpl/api/middleware/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/cpl-api/cpl/api/middleware/logging.py b/src/cpl-api/cpl/api/middleware/logging.py new file mode 100644 index 00000000..96919d0d --- /dev/null +++ b/src/cpl-api/cpl/api/middleware/logging.py @@ -0,0 +1,65 @@ +import time + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +from cpl.api.api_logger import APILogger + +_logger = APILogger(__name__) + + +class LoggingMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + await self._log_request(request) + response = await call_next(request) + await self._log_after_request(request, response) + + return response + + @staticmethod + def _filter_relevant_headers(headers: dict) -> dict: + relevant_keys = { + "content-type", + "host", + "connection", + "user-agent", + "origin", + "referer", + "accept", + } + return {key: value for key, value in headers.items() if key in relevant_keys} + + @classmethod + async def _log_request(cls, request: Request): + _logger.debug( + f"Request {request.state.request_id}: {request.method}@{request.url.path} from {request.client.host}" + ) + + from cpl.core.ctx.user_context import get_user + + user = get_user() + + request_info = { + "headers": cls._filter_relevant_headers(dict(request.headers)), + "args": dict(request.query_params), + "form-data": ( + await request.form() + if request.headers.get("content-type") == "application/x-www-form-urlencoded" + else None + ), + "payload": (await request.json() if request.headers.get("content-length") == "0" else None), + "user": f"{user.id}-{user.keycloak_id}" if user else None, + "files": ( + {key: file.filename for key, file in (await request.form()).items()} if await request.form() else None + ), + } + + _logger.trace(f"Request {request.state.request_id}: {request_info}") + + @staticmethod + async def _log_after_request(request: Request, response: Response): + duration = (time.time() - request.state.start_time) * 1000 + _logger.info( + f"Request finished {request.state.request_id}: {response.status_code}-{request.method}@{request.url.path} from {request.client.host} in {duration:.2f}ms" + ) diff --git a/src/cpl-api/cpl/api/middleware/request.py b/src/cpl-api/cpl/api/middleware/request.py new file mode 100644 index 00000000..16c48ea7 --- /dev/null +++ b/src/cpl-api/cpl/api/middleware/request.py @@ -0,0 +1,48 @@ +import time +from contextvars import ContextVar +from typing import Optional, Union +from uuid import uuid4 + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.websockets import WebSocket + +from cpl.api.api_logger import APILogger +from cpl.api.typing import TRequest + +_request_context: ContextVar[Union[TRequest, None]] = ContextVar("request", default=None) + +_logger = APILogger(__name__) + + +class RequestMiddleware(BaseHTTPMiddleware): + _request_token = {} + _user_token = {} + + @classmethod + async def set_request_data(cls, request: TRequest): + request.state.request_id = uuid4() + request.state.start_time = time.time() + _logger.trace(f"Set new current request: {request.state.request_id}") + + cls._request_token[request.state.request_id] = _request_context.set(request) + + @classmethod + async def clean_request_data(cls): + request = get_request() + if request is None: + return + + if request.state.request_id in cls._request_token: + _request_context.reset(cls._request_token[request.state.request_id]) + + async def dispatch(self, request: TRequest, call_next): + await self.set_request_data(request) + try: + response = await call_next(request) + return response + finally: + await self.clean_request_data() + + +def get_request() -> Optional[Union[TRequest, WebSocket]]: + return _request_context.get() diff --git a/src/cpl-api/cpl/api/router.py b/src/cpl-api/cpl/api/router.py new file mode 100644 index 00000000..0a8d8ba0 --- /dev/null +++ b/src/cpl-api/cpl/api/router.py @@ -0,0 +1,60 @@ +from starlette.routing import Route + + +class Router: + _registered_routes: list[Route] = [] + + @classmethod + def get_routes(cls) -> list[Route]: + return cls._registered_routes + + @classmethod + def route(cls, path=None, **kwargs): + def inner(fn): + cls._registered_routes.append(Route(path, fn, **kwargs)) + setattr(fn, "_route_path", path) + return fn + + return inner + + @classmethod + def get(cls, path=None, **kwargs): + return cls.route(path, methods=["GET"], **kwargs) + + @classmethod + def post(cls, path=None, **kwargs): + return cls.route(path, methods=["POST"], **kwargs) + + @classmethod + def head(cls, path=None, **kwargs): + return cls.route(path, methods=["HEAD"], **kwargs) + + @classmethod + def put(cls, path=None, **kwargs): + return cls.route(path, methods=["PUT"], **kwargs) + + @classmethod + def delete(cls, path=None, **kwargs): + return cls.route(path, methods=["DELETE"], **kwargs) + + @classmethod + def override(cls): + """ + Decorator to override an existing route with the same path. + Usage: + @Route.override() + @Route.get("/example") + async def example_endpoint(request: TRequest): + ... + """ + + def inner(fn): + route_path = getattr(fn, "_route_path", None) + + routes = list(filter(lambda x: x.path == route_path, cls._registered_routes)) + for route in routes[:-1]: + cls._registered_routes.remove(route) + + return fn + + return inner \ No newline at end of file diff --git a/src/cpl-api/cpl/api/typing.py b/src/cpl-api/cpl/api/typing.py new file mode 100644 index 00000000..06eea50e --- /dev/null +++ b/src/cpl-api/cpl/api/typing.py @@ -0,0 +1,13 @@ +from typing import Union, Literal, Callable +from urllib.request import Request + +from starlette.middleware import Middleware +from starlette.types import ASGIApp +from starlette.websockets import WebSocket + +TRequest = Union[Request, WebSocket] +HTTPMethods = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] +PartialMiddleware = Union[ + Middleware, + Callable[[ASGIApp], ASGIApp], +] \ 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 new file mode 100644 index 00000000..ff4438cb --- /dev/null +++ b/src/cpl-api/cpl/api/web_app.py @@ -0,0 +1,153 @@ +import os +from typing import Mapping, Any, Callable + +import uvicorn +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.cors import CORSMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Route +from starlette.types import ExceptionHandler + +from cpl.api.api_logger import APILogger +from cpl.api.api_settings import ApiSettings +from cpl.api.error import APIError +from cpl.api.middleware.logging import LoggingMiddleware +from cpl.api.middleware.request import RequestMiddleware +from cpl.api.router import Router +from cpl.api.typing import HTTPMethods, PartialMiddleware +from cpl.application.abc.application_abc import ApplicationABC +from cpl.core.configuration import Configuration +from cpl.dependency.service_provider_abc import ServiceProviderABC + +_logger = APILogger("API") + + + +class WebApp(ApplicationABC): + def __init__(self, services: ServiceProviderABC): + super().__init__(services) + self._app: Starlette | None = None + + self._api_settings = Configuration.get(ApiSettings) + + self._routes: list[Route] = [] + self._middleware: list[Middleware] = [ + Middleware(RequestMiddleware), + Middleware(LoggingMiddleware), + ] + self._exception_handlers: Mapping[Any, ExceptionHandler] = {Exception: self.handle_exception} + + @staticmethod + async def handle_exception(request: Request, exc: Exception): + if hasattr(request.state, "request_id"): + _logger.error(f"Request {request.state.request_id}", exc) + else: + _logger.error("Request unknown", exc) + + if isinstance(exc, APIError): + return JSONResponse({"error": str(exc)}, status_code=exc.status_code) + + return JSONResponse({"error": str(exc)}, status_code=500) + + def _get_allowed_origins(self): + origins = self._api_settings.allowed_origins + + if origins is None or origins == "": + _logger.warning("No allowed origins specified, allowing all origins") + return ["*"] + + _logger.debug(f"Allowed origins: {origins}") + return origins.split(",") + + def with_app(self, app: Starlette): + assert app is not None, "app must not be None" + assert isinstance(app, Starlette), "app must be an instance of Starlette" + self._app = app + return self + + def _check_for_app(self): + if self._app is not None: + raise ValueError("App is already set, cannot add routes or middleware") + + def with_routes_directory(self, directory: str) -> "WebApp": + self._check_for_app() + assert directory is not None, "directory must not be None" + + base = directory.replace("/", ".").replace("\\", ".") + + for filename in os.listdir(directory): + if not filename.endswith(".py") or filename == "__init__.py": + continue + + __import__(f"{base}.{filename[:-3]}") + + return self + + def with_routes(self, routes: list[Route]) -> "WebApp": + self._check_for_app() + assert self._routes is not None, "routes must not be None" + assert all(isinstance(route, Route) for route in routes), "all routes must be of type starlette.routing.Route" + self._routes.extend(routes) + return self + + def with_route(self, path: str, fn: Callable[[Request], Any], method: HTTPMethods, **kwargs) -> "WebApp": + self._check_for_app() + assert path is not None, "path must not be None" + assert fn is not None, "fn must not be None" + assert method in ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"], "method must be a valid HTTP method" + self._routes.append(Route(path, fn, methods=[method], **kwargs)) + return self + + def with_middleware(self, middleware: PartialMiddleware) -> "WebApp": + self._check_for_app() + + if isinstance(middleware, Middleware): + self._middleware.append(middleware) + + elif callable(middleware): + self._middleware.append(Middleware(middleware)) + else: + raise ValueError("middleware must be of type starlette.middleware.Middleware or a callable") + + + return self + + def main(self): + _logger.debug(f"Preparing API") + if self._app is None: + routes = [ + Route( + path=route.path, + endpoint=self._services.inject(route.endpoint), + methods=route.methods, + name=route.name, + ) + for route in self._routes + Router.get_routes() + ] + + app = Starlette( + routes=routes, + middleware=[ + *self._middleware, + Middleware( + CORSMiddleware, + allow_origins=self._get_allowed_origins(), + allow_methods=["*"], + allow_headers=["*"], + ), + ], + exception_handlers=self._exception_handlers, + ) + else: + app = self._app + + _logger.info(f"Start API on {self._api_settings.host}:{self._api_settings.port}") + uvicorn.run( + app, + host=self._api_settings.host, + port=self._api_settings.port, + log_config=None, + ) + _logger.info("Shutdown API") diff --git a/src/cpl-api/pyproject.toml b/src/cpl-api/pyproject.toml new file mode 100644 index 00000000..c5e0f99c --- /dev/null +++ b/src/cpl-api/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["setuptools>=70.1.0", "wheel>=0.43.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "cpl-application" +version = "2024.7.0" +description = "CPL application" +readme ="CPL application package" +requires-python = ">=3.12" +license = { text = "MIT" } +authors = [ + { name = "Sven Heidemann", email = "sven.heidemann@sh-edraft.de" } +] +keywords = ["cpl", "application", "backend", "shared", "library"] + +dynamic = ["dependencies", "optional-dependencies"] + +[project.urls] +Homepage = "https://www.sh-edraft.de" + +[tool.setuptools.packages.find] +where = ["."] +include = ["cpl*"] + +[tool.setuptools.dynamic] +dependencies = { file = ["requirements.txt"] } +optional-dependencies.dev = { file = ["requirements.dev.txt"] } + + diff --git a/src/cpl-api/requirements.dev.txt b/src/cpl-api/requirements.dev.txt new file mode 100644 index 00000000..e7664b42 --- /dev/null +++ b/src/cpl-api/requirements.dev.txt @@ -0,0 +1 @@ +black==25.1.0 \ No newline at end of file diff --git a/src/cpl-api/requirements.txt b/src/cpl-api/requirements.txt new file mode 100644 index 00000000..51fbdaf0 --- /dev/null +++ b/src/cpl-api/requirements.txt @@ -0,0 +1,6 @@ +cpl-auth +cpl-application +cpl-core +cpl-dependency +starlette==0.48.0 +python-multipart==0.0.20 \ No newline at end of file diff --git a/src/cpl-application/cpl/application/abc/application_abc.py b/src/cpl-application/cpl/application/abc/application_abc.py index 609b86ce..9de8b97e 100644 --- a/src/cpl-application/cpl/application/abc/application_abc.py +++ b/src/cpl-application/cpl/application/abc/application_abc.py @@ -4,7 +4,7 @@ from typing import Callable, Self from cpl.application.host import Host from cpl.core.console.console import Console from cpl.core.log import LogSettings -from cpl.core.log.log_level_enum import LogLevel +from cpl.core.log.log_level import LogLevel from cpl.core.log.logger_abc import LoggerABC from cpl.dependency.service_provider_abc import ServiceProviderABC diff --git a/src/cpl-application/cpl/application/application_builder.py b/src/cpl-application/cpl/application/application_builder.py index 7e329c9b..8df7b60c 100644 --- a/src/cpl-application/cpl/application/application_builder.py +++ b/src/cpl-application/cpl/application/application_builder.py @@ -1,5 +1,5 @@ import asyncio -from typing import Type, Optional +from typing import Type, Optional, TypeVar, Generic from cpl.application.abc.application_abc import ApplicationABC from cpl.application.abc.application_extension_abc import ApplicationExtensionABC @@ -8,9 +8,10 @@ from cpl.application.abc.startup_extension_abc import StartupExtensionABC from cpl.application.host import Host from cpl.dependency.service_collection import ServiceCollection +TApp = TypeVar("TApp", bound=ApplicationABC) -class ApplicationBuilder: - r"""A builder for constructing an application with configurable services and extensions.""" + +class ApplicationBuilder(Generic[TApp]): def __init__(self, app: Type[ApplicationABC]): assert app is not None, "app must not be None" @@ -49,7 +50,7 @@ class ApplicationBuilder: return self - def build(self) -> ApplicationABC: + def build(self) -> TApp: for extension in self._startup_extensions: Host.run(extension.configure_configuration) Host.run(extension.configure_services, self._services) diff --git a/src/cpl-core/cpl/core/configuration/configuration.py b/src/cpl-core/cpl/core/configuration/configuration.py index 989a4793..79b50bfa 100644 --- a/src/cpl-core/cpl/core/configuration/configuration.py +++ b/src/cpl-core/cpl/core/configuration/configuration.py @@ -2,6 +2,7 @@ import inspect import json import os import sys +from inspect import isclass from typing import Any from cpl.core.configuration.configuration_model_abc import ConfigurationModelABC @@ -126,7 +127,11 @@ class Configuration: @classmethod def get(cls, key: Any, default: D = None) -> T | D: - if inspect.isclass(key): - key = key.__name__ + key_name = key.__name__ if inspect.isclass(key) else key - return cls._config.get(key, default) + result = cls._config.get(key_name, default) + if issubclass(key, ConfigurationModelABC) and result == default: + result = key() + cls.set(key, result) + + return 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 fe7b5a71..d4306b67 100644 --- a/src/cpl-core/cpl/core/configuration/configuration_model_abc.py +++ b/src/cpl-core/cpl/core/configuration/configuration_model_abc.py @@ -49,7 +49,7 @@ class ConfigurationModelABC(ABC): String.first_to_lower(field), String.to_camel_case(field), String.to_snake_case(field), - String.first_to_upper(String.to_camel_case(field)), + String.to_pascal_case(field), ] value = None @@ -64,7 +64,8 @@ class ConfigurationModelABC(ABC): env_field = field.upper() if self._env_prefix: env_field = f"{self._env_prefix}_{env_field}" - value = Environment.get(env_field, cast_type) + + value = cast(Environment.get(env_field, str), cast_type) if value is None and required: raise ValueError(f"{field} is required") diff --git a/src/cpl-core/cpl/core/log/__init__.py b/src/cpl-core/cpl/core/log/__init__.py index 496346ff..f66f1426 100644 --- a/src/cpl-core/cpl/core/log/__init__.py +++ b/src/cpl-core/cpl/core/log/__init__.py @@ -1,4 +1,4 @@ from .logger import Logger from .logger_abc import LoggerABC -from .log_level_enum import LogLevel -from .logging_settings import LogSettings +from .log_level import LogLevel +from .log_settings import LogSettings diff --git a/src/cpl-core/cpl/core/log/log_level_enum.py b/src/cpl-core/cpl/core/log/log_level.py similarity index 100% rename from src/cpl-core/cpl/core/log/log_level_enum.py rename to src/cpl-core/cpl/core/log/log_level.py diff --git a/src/cpl-core/cpl/core/log/logging_settings.py b/src/cpl-core/cpl/core/log/log_settings.py similarity index 70% rename from src/cpl-core/cpl/core/log/logging_settings.py rename to src/cpl-core/cpl/core/log/log_settings.py index ed7eb4f6..e5e2bd85 100644 --- a/src/cpl-core/cpl/core/log/logging_settings.py +++ b/src/cpl-core/cpl/core/log/log_settings.py @@ -1,7 +1,7 @@ from typing import Optional from cpl.core.configuration.configuration_model_abc import ConfigurationModelABC -from cpl.core.log.log_level_enum import LogLevel +from cpl.core.log.log_level import LogLevel class LogSettings(ConfigurationModelABC): @@ -10,9 +10,9 @@ class LogSettings(ConfigurationModelABC): self, src: Optional[dict] = None, ): - ConfigurationModelABC.__init__(self, src) + ConfigurationModelABC.__init__(self, src, "LOG") self.option("path", str, default="logs") self.option("filename", str, default="app.log") - self.option("console_level", LogLevel, default=LogLevel.info) + self.option("console", LogLevel, default=LogLevel.info) self.option("level", LogLevel, default=LogLevel.info) diff --git a/src/cpl-core/cpl/core/log/logger.py b/src/cpl-core/cpl/core/log/logger.py index 9f413691..31a0a707 100644 --- a/src/cpl-core/cpl/core/log/logger.py +++ b/src/cpl-core/cpl/core/log/logger.py @@ -3,13 +3,12 @@ import traceback from datetime import datetime from cpl.core.console import Console -from cpl.core.log.log_level_enum import LogLevel +from cpl.core.log.log_level import LogLevel from cpl.core.log.logger_abc import LoggerABC from cpl.core.typing import Messages, Source class Logger(LoggerABC): - _level = LogLevel.info _levels = [x for x in LogLevel] # ANSI color codes for different log levels @@ -36,6 +35,13 @@ class Logger(LoggerABC): self._file_prefix = file_prefix self._create_log_dir() + @property + def _settings(self): + from cpl.core.configuration.configuration import Configuration + from cpl.core.log.log_settings import LogSettings + + return Configuration.get(LogSettings) + @property def log_file(self): return f"logs/{self._file_prefix}_{datetime.now().strftime('%Y-%m-%d')}.log" @@ -65,23 +71,32 @@ class Logger(LoggerABC): f"{log_file.split('.log')[0]}_{datetime.now().strftime('%H-%M-%S')}.log", ) - def _write_log_to_file(self, content: str): + def _should_log(self, input_level: LogLevel, settings_level: LogLevel) -> bool: + return self._levels.index(input_level) >= self._levels.index(settings_level) + + def _write_log_to_file(self, level: LogLevel, content: str): + if not self._should_log(level, self._settings.level): + return + file = self.log_file self._ensure_file_size(file) with open(file, "a") as log_file: log_file.write(content + "\n") log_file.close() + def _write_to_console(self, level: LogLevel, content: str): + if not self._should_log(level, self._settings.console): + return + + Console.write_line(f"{self._COLORS.get(level, '\033[0m')}{content}\033[0m") + def _log(self, level: LogLevel, *messages: Messages): try: - if self._levels.index(level) < self._levels.index(self._level): - return - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") formatted_message = self._format_message(level.value, timestamp, *messages) - self._write_log_to_file(formatted_message) - Console.write_line(f"{self._COLORS.get(level, '\033[0m')}{formatted_message}\033[0m") + self._write_log_to_file(level, formatted_message) + self._write_to_console(level, formatted_message) except Exception as e: print(f"Error while logging: {e} -> {traceback.format_exc()}") diff --git a/src/cpl-core/cpl/core/log/logger_abc.py b/src/cpl-core/cpl/core/log/logger_abc.py index 08a7f00e..f4efb608 100644 --- a/src/cpl-core/cpl/core/log/logger_abc.py +++ b/src/cpl-core/cpl/core/log/logger_abc.py @@ -1,6 +1,6 @@ from abc import abstractmethod, ABC -from cpl.core.log.log_level_enum import LogLevel +from cpl.core.log.log_level import LogLevel from cpl.core.typing import Messages diff --git a/src/cpl-core/cpl/core/utils/cast.py b/src/cpl-core/cpl/core/utils/cast.py index dab9fa4a..08405cb5 100644 --- a/src/cpl-core/cpl/core/utils/cast.py +++ b/src/cpl-core/cpl/core/utils/cast.py @@ -3,6 +3,7 @@ from typing import Type, Any from cpl.core.typing import T + def _cast_enum(value: str, enum_type: Type[Enum]) -> Enum: try: return enum_type(value) @@ -36,6 +37,7 @@ def _cast_enum(value: str, enum_type: Type[Enum]) -> Enum: raise ValueError(f"Cannot cast value '{value}' to enum '{enum_type.__name__}'") + def cast(value: Any, cast_type: Type[T], list_delimiter: str = ",") -> T: """ Cast a value to a specified type. @@ -44,12 +46,12 @@ def cast(value: Any, cast_type: Type[T], list_delimiter: str = ",") -> T: :param str list_delimiter: The delimiter to split the value into a list. Defaults to ",". :return: """ + if value is None: + return None + if cast_type == bool: return value.lower() in ["true", "1", "yes", "on"] - if issubclass(cast_type, Enum): - return _cast_enum(value, cast_type) - if (cast_type if not hasattr(cast_type, "__origin__") else cast_type.__origin__) == list: if not (value.startswith("[") and value.endswith("]")) and list_delimiter not in value: raise ValueError("List values must be enclosed in square brackets or use a delimiter.") @@ -61,4 +63,7 @@ def cast(value: Any, cast_type: Type[T], list_delimiter: str = ",") -> T: subtype = cast_type.__args__[0] if hasattr(cast_type, "__args__") else None return [subtype(item) if subtype is not None else item for item in value] - return cast_type(value) \ No newline at end of file + if isinstance(cast_type, type) and issubclass(cast_type, Enum): + return _cast_enum(value, cast_type) + + return cast_type(value) diff --git a/src/cpl-core/cpl/core/utils/get_value.py b/src/cpl-core/cpl/core/utils/get_value.py index 8ec5fd24..c1bcd44e 100644 --- a/src/cpl-core/cpl/core/utils/get_value.py +++ b/src/cpl-core/cpl/core/utils/get_value.py @@ -38,6 +38,9 @@ def get_value( return value try: - cast(cast_type, value, list_delimiter) + cast(value, cast_type, list_delimiter) except (ValueError, TypeError): + from cpl.core.log import Logger + + Logger(__name__).debug(f"Failed to cast value '{value}' to type '{cast_type.__name__}'") return default diff --git a/src/cpl-core/cpl/core/utils/string.py b/src/cpl-core/cpl/core/utils/string.py index ce1c17b3..fb11026c 100644 --- a/src/cpl-core/cpl/core/utils/string.py +++ b/src/cpl-core/cpl/core/utils/string.py @@ -18,10 +18,35 @@ class String: String converted to CamelCase """ - s = String.to_snake_case(s) - words = s.split('_') - return words[0] + ''.join(word.title() for word in words[1:]) - # return re.sub(r"(? str: + r"""Converts string to pascal case + + Parameter: + chars: :class:`str` + String to convert + + Returns: + String converted to PascalCase + """ + + parts = re.split(r"[^a-zA-Z0-9]+", s.strip()) + + parts = [p for p in parts if p] + + if not parts: + return "" + + return "".join(word.capitalize() for word in parts) @staticmethod def to_snake_case(chars: str) -> str: diff --git a/src/cpl-database/cpl/database/abc/data_access_object_abc.py b/src/cpl-database/cpl/database/abc/data_access_object_abc.py index 476ff026..3dba5dd8 100644 --- a/src/cpl-database/cpl/database/abc/data_access_object_abc.py +++ b/src/cpl-database/cpl/database/abc/data_access_object_abc.py @@ -4,10 +4,9 @@ from enum import Enum from types import NoneType from typing import Generic, Optional, Union, Type, List, Any -from cpl.core.ctx import get_user from cpl.core.typing import T, Id -from cpl.core.utils.string import String from cpl.core.utils.get_value import get_value +from cpl.core.utils.string import String from cpl.database.abc.db_context_abc import DBContextABC from cpl.database.const import DATETIME_FORMAT from cpl.database.db_logger import DBLogger @@ -869,6 +868,8 @@ class DataAccessObjectABC(ABC, Generic[T_DBM]): async def _get_editor_id(obj: T_DBM): editor_id = obj.editor_id if editor_id is None: + from cpl.core.ctx.user_context import get_user + user = get_user() if user is not None: editor_id = user.id diff --git a/src/cpl-dependency/cpl/dependency/service_provider.py b/src/cpl-dependency/cpl/dependency/service_provider.py index 592dd54f..075c14c5 100644 --- a/src/cpl-dependency/cpl/dependency/service_provider.py +++ b/src/cpl-dependency/cpl/dependency/service_provider.py @@ -77,7 +77,7 @@ class ServiceProvider(ServiceProviderABC): return implementations - def _build_by_signature(self, sig: Signature, origin_service_type: type) -> list[R]: + def _build_by_signature(self, sig: Signature, origin_service_type: type=None) -> list[R]: params = [] for param in sig.parameters.items(): parameter = param[1] diff --git a/src/cpl-dependency/cpl/dependency/service_provider_abc.py b/src/cpl-dependency/cpl/dependency/service_provider_abc.py index 2f4742ee..5873f443 100644 --- a/src/cpl-dependency/cpl/dependency/service_provider_abc.py +++ b/src/cpl-dependency/cpl/dependency/service_provider_abc.py @@ -1,6 +1,6 @@ import functools from abc import abstractmethod, ABC -from inspect import Signature, signature +from inspect import Signature, signature, iscoroutinefunction from typing import Optional, Type from cpl.core.typing import T, R @@ -36,7 +36,7 @@ class ServiceProviderABC(ABC): return cls._provider.get_services(instance_type, *args, **kwargs) @abstractmethod - def _build_by_signature(self, sig: Signature, origin_service_type: type) -> list[R]: ... + def _build_by_signature(self, sig: Signature, origin_service_type: type=None) -> list[R]: ... @abstractmethod def _build_service(self, service_type: type, *args, **kwargs) -> object: @@ -115,11 +115,13 @@ class ServiceProviderABC(ABC): return functools.partial(cls.inject) @functools.wraps(f) - def inner(*args, **kwargs): + async def inner(*args, **kwargs): if cls._provider is None: raise Exception(f"{cls.__name__} not build!") injection = [x for x in cls._provider._build_by_signature(signature(f)) if x is not None] + if iscoroutinefunction(f): + return await f(*args, *injection, **kwargs) return f(*args, *injection, **kwargs) return inner diff --git a/src/cpl-mail/cpl/mail/email_client_settings.py b/src/cpl-mail/cpl/mail/email_client_settings.py index 3640820b..7cf18341 100644 --- a/src/cpl-mail/cpl/mail/email_client_settings.py +++ b/src/cpl-mail/cpl/mail/email_client_settings.py @@ -6,12 +6,12 @@ from cpl.core.configuration.configuration_model_abc import ConfigurationModelABC class EMailClientSettings(ConfigurationModelABC): def __init__( - self, - src: Optional[dict] = None, + self, + src: Optional[dict] = None, ): ConfigurationModelABC.__init__(self, src, "EMAIL") self.option("host", str, required=True) self.option("port", int, 587, required=True) self.option("user_name", str, required=True) - self.option("credentials", str, required=True) \ No newline at end of file + self.option("credentials", str, required=True) diff --git a/tests/custom/api/src/main.py b/tests/custom/api/src/main.py new file mode 100644 index 00000000..d6b3bed1 --- /dev/null +++ b/tests/custom/api/src/main.py @@ -0,0 +1,23 @@ +from starlette.responses import JSONResponse + +from cpl.api.web_app import WebApp +from cpl.application import ApplicationBuilder +from custom.api.src.service import PingService + + +def main(): + builder = ApplicationBuilder[WebApp](WebApp) + + builder.services.add_logging() + builder.services.add_transient(PingService) + + app = builder.build() + app.with_route(path="/route1", fn=lambda r: JSONResponse("route1"), method="GET") + app.with_routes_directory("routes") + app.with_logging() + + app.run() + + +if __name__ == "__main__": + main() diff --git a/tests/custom/api/src/routes/__init__.py b/tests/custom/api/src/routes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/custom/api/src/routes/ping.py b/tests/custom/api/src/routes/ping.py new file mode 100644 index 00000000..ae47565e --- /dev/null +++ b/tests/custom/api/src/routes/ping.py @@ -0,0 +1,13 @@ +from urllib.request import Request + +from starlette.responses import JSONResponse + +from cpl.api.router import Router +from cpl.core.log import Logger +from custom.api.src.service import PingService + + +@Router.get(f"/ping") +async def ping(r: Request, ping: PingService, logger: Logger): + logger.info(f"Ping: {ping}") + return JSONResponse(ping.ping(r)) diff --git a/tests/custom/api/src/service.py b/tests/custom/api/src/service.py new file mode 100644 index 00000000..be6e37c1 --- /dev/null +++ b/tests/custom/api/src/service.py @@ -0,0 +1,4 @@ +class PingService: + + def ping(self, r): + return "pong"