WIP: dev into master #184
@@ -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 ]
|
||||
|
||||
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.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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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()}")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@@ -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"(?<!^)(?=[A-Z])", "_", s).lower()
|
||||
parts = re.split(r"[^a-zA-Z0-9]+", s.strip())
|
||||
|
||||
parts = [p for p in parts if p]
|
||||
|
||||
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
|
||||
def to_snake_case(chars: str) -> str:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,8 +6,8 @@ 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")
|
||||
|
||||
|
||||
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