WIP: dev into master #184
@@ -12,6 +12,13 @@ jobs:
|
|||||||
version_suffix: 'dev'
|
version_suffix: 'dev'
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
|
api:
|
||||||
|
uses: ./.gitea/workflows/package.yaml
|
||||||
|
needs: [ prepare, application, auth, core, dependency ]
|
||||||
|
with:
|
||||||
|
working_directory: src/cpl-application
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
application:
|
application:
|
||||||
uses: ./.gitea/workflows/package.yaml
|
uses: ./.gitea/workflows/package.yaml
|
||||||
needs: [ prepare, core, dependency ]
|
needs: [ prepare, core, dependency ]
|
||||||
|
|||||||
0
src/cpl-api/cpl/api/__init__.py
Normal file
0
src/cpl-api/cpl/api/__init__.py
Normal file
7
src/cpl-api/cpl/api/api_logger.py
Normal file
7
src/cpl-api/cpl/api/api_logger.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from cpl.core.log.logger import Logger
|
||||||
|
|
||||||
|
|
||||||
|
class APILogger(Logger):
|
||||||
|
|
||||||
|
def __init__(self, source: str):
|
||||||
|
Logger.__init__(self, source, "api")
|
||||||
13
src/cpl-api/cpl/api/api_settings.py
Normal file
13
src/cpl-api/cpl/api/api_settings.py
Normal file
@@ -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])
|
||||||
25
src/cpl-api/cpl/api/error.py
Normal file
25
src/cpl-api/cpl/api/error.py
Normal file
@@ -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
|
||||||
0
src/cpl-api/cpl/api/middleware/__init__.py
Normal file
0
src/cpl-api/cpl/api/middleware/__init__.py
Normal file
65
src/cpl-api/cpl/api/middleware/logging.py
Normal file
65
src/cpl-api/cpl/api/middleware/logging.py
Normal file
@@ -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"
|
||||||
|
)
|
||||||
48
src/cpl-api/cpl/api/middleware/request.py
Normal file
48
src/cpl-api/cpl/api/middleware/request.py
Normal file
@@ -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()
|
||||||
60
src/cpl-api/cpl/api/router.py
Normal file
60
src/cpl-api/cpl/api/router.py
Normal file
@@ -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
|
||||||
13
src/cpl-api/cpl/api/typing.py
Normal file
13
src/cpl-api/cpl/api/typing.py
Normal file
@@ -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],
|
||||||
|
]
|
||||||
153
src/cpl-api/cpl/api/web_app.py
Normal file
153
src/cpl-api/cpl/api/web_app.py
Normal file
@@ -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")
|
||||||
30
src/cpl-api/pyproject.toml
Normal file
30
src/cpl-api/pyproject.toml
Normal file
@@ -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"] }
|
||||||
|
|
||||||
|
|
||||||
1
src/cpl-api/requirements.dev.txt
Normal file
1
src/cpl-api/requirements.dev.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
black==25.1.0
|
||||||
6
src/cpl-api/requirements.txt
Normal file
6
src/cpl-api/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
cpl-auth
|
||||||
|
cpl-application
|
||||||
|
cpl-core
|
||||||
|
cpl-dependency
|
||||||
|
starlette==0.48.0
|
||||||
|
python-multipart==0.0.20
|
||||||
@@ -4,7 +4,7 @@ from typing import Callable, Self
|
|||||||
from cpl.application.host import Host
|
from cpl.application.host import Host
|
||||||
from cpl.core.console.console import Console
|
from cpl.core.console.console import Console
|
||||||
from cpl.core.log import LogSettings
|
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.core.log.logger_abc import LoggerABC
|
||||||
from cpl.dependency.service_provider_abc import ServiceProviderABC
|
from cpl.dependency.service_provider_abc import ServiceProviderABC
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import asyncio
|
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_abc import ApplicationABC
|
||||||
from cpl.application.abc.application_extension_abc import ApplicationExtensionABC
|
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.application.host import Host
|
||||||
from cpl.dependency.service_collection import ServiceCollection
|
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]):
|
def __init__(self, app: Type[ApplicationABC]):
|
||||||
assert app is not None, "app must not be None"
|
assert app is not None, "app must not be None"
|
||||||
@@ -49,7 +50,7 @@ class ApplicationBuilder:
|
|||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def build(self) -> ApplicationABC:
|
def build(self) -> TApp:
|
||||||
for extension in self._startup_extensions:
|
for extension in self._startup_extensions:
|
||||||
Host.run(extension.configure_configuration)
|
Host.run(extension.configure_configuration)
|
||||||
Host.run(extension.configure_services, self._services)
|
Host.run(extension.configure_services, self._services)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import inspect
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from inspect import isclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from cpl.core.configuration.configuration_model_abc import ConfigurationModelABC
|
from cpl.core.configuration.configuration_model_abc import ConfigurationModelABC
|
||||||
@@ -126,7 +127,11 @@ class Configuration:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get(cls, key: Any, default: D = None) -> T | D:
|
def get(cls, key: Any, default: D = None) -> T | D:
|
||||||
if inspect.isclass(key):
|
key_name = key.__name__ if inspect.isclass(key) else key
|
||||||
key = key.__name__
|
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class ConfigurationModelABC(ABC):
|
|||||||
String.first_to_lower(field),
|
String.first_to_lower(field),
|
||||||
String.to_camel_case(field),
|
String.to_camel_case(field),
|
||||||
String.to_snake_case(field),
|
String.to_snake_case(field),
|
||||||
String.first_to_upper(String.to_camel_case(field)),
|
String.to_pascal_case(field),
|
||||||
]
|
]
|
||||||
|
|
||||||
value = None
|
value = None
|
||||||
@@ -64,7 +64,8 @@ class ConfigurationModelABC(ABC):
|
|||||||
env_field = field.upper()
|
env_field = field.upper()
|
||||||
if self._env_prefix:
|
if self._env_prefix:
|
||||||
env_field = f"{self._env_prefix}_{env_field}"
|
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:
|
if value is None and required:
|
||||||
raise ValueError(f"{field} is required")
|
raise ValueError(f"{field} is required")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from .logger import Logger
|
from .logger import Logger
|
||||||
from .logger_abc import LoggerABC
|
from .logger_abc import LoggerABC
|
||||||
from .log_level_enum import LogLevel
|
from .log_level import LogLevel
|
||||||
from .logging_settings import LogSettings
|
from .log_settings import LogSettings
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from cpl.core.configuration.configuration_model_abc import ConfigurationModelABC
|
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):
|
class LogSettings(ConfigurationModelABC):
|
||||||
@@ -10,9 +10,9 @@ class LogSettings(ConfigurationModelABC):
|
|||||||
self,
|
self,
|
||||||
src: Optional[dict] = None,
|
src: Optional[dict] = None,
|
||||||
):
|
):
|
||||||
ConfigurationModelABC.__init__(self, src)
|
ConfigurationModelABC.__init__(self, src, "LOG")
|
||||||
|
|
||||||
self.option("path", str, default="logs")
|
self.option("path", str, default="logs")
|
||||||
self.option("filename", str, default="app.log")
|
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)
|
self.option("level", LogLevel, default=LogLevel.info)
|
||||||
@@ -3,13 +3,12 @@ import traceback
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from cpl.core.console import Console
|
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.log.logger_abc import LoggerABC
|
||||||
from cpl.core.typing import Messages, Source
|
from cpl.core.typing import Messages, Source
|
||||||
|
|
||||||
|
|
||||||
class Logger(LoggerABC):
|
class Logger(LoggerABC):
|
||||||
_level = LogLevel.info
|
|
||||||
_levels = [x for x in LogLevel]
|
_levels = [x for x in LogLevel]
|
||||||
|
|
||||||
# ANSI color codes for different log levels
|
# ANSI color codes for different log levels
|
||||||
@@ -36,6 +35,13 @@ class Logger(LoggerABC):
|
|||||||
self._file_prefix = file_prefix
|
self._file_prefix = file_prefix
|
||||||
self._create_log_dir()
|
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
|
@property
|
||||||
def log_file(self):
|
def log_file(self):
|
||||||
return f"logs/{self._file_prefix}_{datetime.now().strftime('%Y-%m-%d')}.log"
|
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",
|
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
|
file = self.log_file
|
||||||
self._ensure_file_size(file)
|
self._ensure_file_size(file)
|
||||||
with open(file, "a") as log_file:
|
with open(file, "a") as log_file:
|
||||||
log_file.write(content + "\n")
|
log_file.write(content + "\n")
|
||||||
log_file.close()
|
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):
|
def _log(self, level: LogLevel, *messages: Messages):
|
||||||
try:
|
try:
|
||||||
if self._levels.index(level) < self._levels.index(self._level):
|
|
||||||
return
|
|
||||||
|
|
||||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
|
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||||
formatted_message = self._format_message(level.value, timestamp, *messages)
|
formatted_message = self._format_message(level.value, timestamp, *messages)
|
||||||
|
|
||||||
self._write_log_to_file(formatted_message)
|
self._write_log_to_file(level, formatted_message)
|
||||||
Console.write_line(f"{self._COLORS.get(level, '\033[0m')}{formatted_message}\033[0m")
|
self._write_to_console(level, formatted_message)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error while logging: {e} -> {traceback.format_exc()}")
|
print(f"Error while logging: {e} -> {traceback.format_exc()}")
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from abc import abstractmethod, ABC
|
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
|
from cpl.core.typing import Messages
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from typing import Type, Any
|
|||||||
|
|
||||||
from cpl.core.typing import T
|
from cpl.core.typing import T
|
||||||
|
|
||||||
|
|
||||||
def _cast_enum(value: str, enum_type: Type[Enum]) -> Enum:
|
def _cast_enum(value: str, enum_type: Type[Enum]) -> Enum:
|
||||||
try:
|
try:
|
||||||
return enum_type(value)
|
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__}'")
|
raise ValueError(f"Cannot cast value '{value}' to enum '{enum_type.__name__}'")
|
||||||
|
|
||||||
|
|
||||||
def cast(value: Any, cast_type: Type[T], list_delimiter: str = ",") -> T:
|
def cast(value: Any, cast_type: Type[T], list_delimiter: str = ",") -> T:
|
||||||
"""
|
"""
|
||||||
Cast a value to a specified type.
|
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 ",".
|
:param str list_delimiter: The delimiter to split the value into a list. Defaults to ",".
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
if cast_type == bool:
|
if cast_type == bool:
|
||||||
return value.lower() in ["true", "1", "yes", "on"]
|
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 (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:
|
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.")
|
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
|
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 [subtype(item) if subtype is not None else item for item in value]
|
||||||
|
|
||||||
return cast_type(value)
|
if isinstance(cast_type, type) and issubclass(cast_type, Enum):
|
||||||
|
return _cast_enum(value, cast_type)
|
||||||
|
|
||||||
|
return cast_type(value)
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ def get_value(
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cast(cast_type, value, list_delimiter)
|
cast(value, cast_type, list_delimiter)
|
||||||
except (ValueError, TypeError):
|
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
|
return default
|
||||||
|
|||||||
@@ -18,10 +18,35 @@ class String:
|
|||||||
String converted to CamelCase
|
String converted to CamelCase
|
||||||
"""
|
"""
|
||||||
|
|
||||||
s = String.to_snake_case(s)
|
parts = re.split(r"[^a-zA-Z0-9]+", s.strip())
|
||||||
words = s.split('_')
|
|
||||||
return words[0] + ''.join(word.title() for word in words[1:])
|
parts = [p for p in parts if p]
|
||||||
# return re.sub(r"(?<!^)(?=[A-Z])", "_", s).lower()
|
|
||||||
|
if not parts:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return parts[0].lower() + "".join(word.capitalize() for word in parts[1:])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def to_pascal_case(s: str) -> 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
|
@staticmethod
|
||||||
def to_snake_case(chars: str) -> str:
|
def to_snake_case(chars: str) -> str:
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ from enum import Enum
|
|||||||
from types import NoneType
|
from types import NoneType
|
||||||
from typing import Generic, Optional, Union, Type, List, Any
|
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.typing import T, Id
|
||||||
from cpl.core.utils.string import String
|
|
||||||
from cpl.core.utils.get_value import get_value
|
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.abc.db_context_abc import DBContextABC
|
||||||
from cpl.database.const import DATETIME_FORMAT
|
from cpl.database.const import DATETIME_FORMAT
|
||||||
from cpl.database.db_logger import DBLogger
|
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):
|
async def _get_editor_id(obj: T_DBM):
|
||||||
editor_id = obj.editor_id
|
editor_id = obj.editor_id
|
||||||
if editor_id is None:
|
if editor_id is None:
|
||||||
|
from cpl.core.ctx.user_context import get_user
|
||||||
|
|
||||||
user = get_user()
|
user = get_user()
|
||||||
if user is not None:
|
if user is not None:
|
||||||
editor_id = user.id
|
editor_id = user.id
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class ServiceProvider(ServiceProviderABC):
|
|||||||
|
|
||||||
return implementations
|
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 = []
|
params = []
|
||||||
for param in sig.parameters.items():
|
for param in sig.parameters.items():
|
||||||
parameter = param[1]
|
parameter = param[1]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import functools
|
import functools
|
||||||
from abc import abstractmethod, ABC
|
from abc import abstractmethod, ABC
|
||||||
from inspect import Signature, signature
|
from inspect import Signature, signature, iscoroutinefunction
|
||||||
from typing import Optional, Type
|
from typing import Optional, Type
|
||||||
|
|
||||||
from cpl.core.typing import T, R
|
from cpl.core.typing import T, R
|
||||||
@@ -36,7 +36,7 @@ class ServiceProviderABC(ABC):
|
|||||||
return cls._provider.get_services(instance_type, *args, **kwargs)
|
return cls._provider.get_services(instance_type, *args, **kwargs)
|
||||||
|
|
||||||
@abstractmethod
|
@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
|
@abstractmethod
|
||||||
def _build_service(self, service_type: type, *args, **kwargs) -> object:
|
def _build_service(self, service_type: type, *args, **kwargs) -> object:
|
||||||
@@ -115,11 +115,13 @@ class ServiceProviderABC(ABC):
|
|||||||
return functools.partial(cls.inject)
|
return functools.partial(cls.inject)
|
||||||
|
|
||||||
@functools.wraps(f)
|
@functools.wraps(f)
|
||||||
def inner(*args, **kwargs):
|
async def inner(*args, **kwargs):
|
||||||
if cls._provider is None:
|
if cls._provider is None:
|
||||||
raise Exception(f"{cls.__name__} not build!")
|
raise Exception(f"{cls.__name__} not build!")
|
||||||
|
|
||||||
injection = [x for x in cls._provider._build_by_signature(signature(f)) if x is not None]
|
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 f(*args, *injection, **kwargs)
|
||||||
|
|
||||||
return inner
|
return inner
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ from cpl.core.configuration.configuration_model_abc import ConfigurationModelABC
|
|||||||
class EMailClientSettings(ConfigurationModelABC):
|
class EMailClientSettings(ConfigurationModelABC):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
src: Optional[dict] = None,
|
src: Optional[dict] = None,
|
||||||
):
|
):
|
||||||
ConfigurationModelABC.__init__(self, src, "EMAIL")
|
ConfigurationModelABC.__init__(self, src, "EMAIL")
|
||||||
|
|
||||||
self.option("host", str, required=True)
|
self.option("host", str, required=True)
|
||||||
self.option("port", int, 587, required=True)
|
self.option("port", int, 587, required=True)
|
||||||
self.option("user_name", str, required=True)
|
self.option("user_name", str, required=True)
|
||||||
self.option("credentials", str, required=True)
|
self.option("credentials", str, required=True)
|
||||||
|
|||||||
23
tests/custom/api/src/main.py
Normal file
23
tests/custom/api/src/main.py
Normal file
@@ -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()
|
||||||
0
tests/custom/api/src/routes/__init__.py
Normal file
0
tests/custom/api/src/routes/__init__.py
Normal file
13
tests/custom/api/src/routes/ping.py
Normal file
13
tests/custom/api/src/routes/ping.py
Normal file
@@ -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))
|
||||||
4
tests/custom/api/src/service.py
Normal file
4
tests/custom/api/src/service.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
class PingService:
|
||||||
|
|
||||||
|
def ping(self, r):
|
||||||
|
return "pong"
|
||||||
Reference in New Issue
Block a user