Compare commits

..

13 Commits

Author SHA1 Message Date
69bbbc8cee Authorization via with_route
Some checks failed
Build on push / prepare (push) Successful in 9s
Build on push / query (push) Successful in 20s
Build on push / core (push) Successful in 20s
Build on push / dependency (push) Successful in 17s
Build on push / mail (push) Successful in 15s
Build on push / application (push) Successful in 18s
Build on push / translation (push) Successful in 18s
Build on push / database (push) Successful in 25s
Build on push / auth (push) Successful in 15s
Build on push / api (push) Successful in 14s
Test before pr merge / test-lint (pull_request) Failing after 6s
2025-09-22 22:03:42 +02:00
d6b7eb9b30 Authorization via decorator 2025-09-22 21:16:47 +02:00
12b7c62b69 Fixed formatting
All checks were successful
Build on push / prepare (push) Successful in 9s
Build on push / core (push) Successful in 17s
Build on push / query (push) Successful in 17s
Build on push / dependency (push) Successful in 17s
Build on push / translation (push) Successful in 14s
Build on push / application (push) Successful in 18s
Build on push / database (push) Successful in 17s
Build on push / mail (push) Successful in 18s
Build on push / auth (push) Successful in 13s
Build on push / api (push) Successful in 17s
Test before pr merge / test-lint (pull_request) Successful in 5s
2025-09-21 23:48:09 +02:00
7fc70747bb Added black test
Some checks failed
Test before pr merge / test-lint (pull_request) Failing after 6s
Build on push / prepare (push) Successful in 10s
Build on push / core (push) Successful in 17s
Build on push / query (push) Successful in 17s
Build on push / dependency (push) Successful in 17s
Build on push / api (push) Has been cancelled
Build on push / auth (push) Has been cancelled
Build on push / mail (push) Has started running
Build on push / translation (push) Has been cancelled
Build on push / application (push) Has been cancelled
Build on push / database (push) Has been cancelled
2025-09-21 23:47:15 +02:00
6de4f3c03a Middleware updated & Fixed mysql pool
All checks were successful
Build on push / prepare (push) Successful in 10s
Build on push / core (push) Successful in 19s
Build on push / query (push) Successful in 18s
Build on push / dependency (push) Successful in 17s
Build on push / database (push) Successful in 15s
Build on push / translation (push) Successful in 18s
Build on push / mail (push) Successful in 19s
Build on push / application (push) Successful in 21s
Build on push / auth (push) Successful in 14s
Build on push / api (push) Successful in 14s
2025-09-21 23:41:25 +02:00
ea3055527c Changed middleware to asgi 2025-09-21 21:22:19 +02:00
7b37748ca6 [WIP] validate token via keycloak 2025-09-21 21:07:09 +02:00
073b35f71a App deps check 2025-09-21 20:11:47 +02:00
eceff6128b [WIP] Authentication
All checks were successful
Build on push / prepare (push) Successful in 9s
Build on push / core (push) Successful in 19s
Build on push / query (push) Successful in 19s
Build on push / dependency (push) Successful in 17s
Build on push / application (push) Successful in 16s
Build on push / mail (push) Successful in 16s
Build on push / translation (push) Successful in 18s
Build on push / database (push) Successful in 22s
Build on push / auth (push) Successful in 15s
Build on push / api (push) Successful in 13s
2025-09-19 23:01:41 +02:00
17dfb245bf Minor cleanup 2025-09-19 21:54:08 +02:00
4f698269b5 Fixed api build
All checks were successful
Build on push / prepare (push) Successful in 9s
Build on push / core (push) Successful in 18s
Build on push / query (push) Successful in 17s
Build on push / dependency (push) Successful in 18s
Build on push / application (push) Successful in 15s
Build on push / database (push) Successful in 18s
Build on push / translation (push) Successful in 18s
Build on push / mail (push) Successful in 19s
Build on push / auth (push) Successful in 15s
Build on push / api (push) Successful in 14s
2025-09-19 21:12:33 +02:00
ddc62dfb9a Added api & route handling
Some checks failed
Build on push / prepare (push) Successful in 9s
Build on push / core (push) Successful in 19s
Build on push / query (push) Successful in 19s
Build on push / dependency (push) Successful in 17s
Build on push / application (push) Successful in 15s
Build on push / database (push) Successful in 18s
Build on push / mail (push) Successful in 19s
Build on push / translation (push) Successful in 23s
Build on push / auth (push) Successful in 16s
Build on push / api (push) Failing after 14s
2025-09-19 21:03:33 +02:00
1a67318091 Config model options handling. Closes #185
All checks were successful
Build on push / prepare (push) Successful in 10s
Build on push / core (push) Successful in 19s
Build on push / query (push) Successful in 19s
Build on push / dependency (push) Successful in 25s
Build on push / translation (push) Successful in 17s
Build on push / database (push) Successful in 20s
Build on push / application (push) Successful in 21s
Build on push / mail (push) Successful in 20s
Build on push / auth (push) Successful in 14s
2025-09-19 17:47:49 +02:00
98 changed files with 1774 additions and 500 deletions

View File

@@ -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-api
secrets: inherit
application:
uses: ./.gitea/workflows/package.yaml
needs: [ prepare, core, dependency ]

View File

@@ -0,0 +1,26 @@
name: Test before pr merge
run-name: Test before pr merge
on:
pull_request:
types:
- opened
- edited
- reopened
- synchronize
- ready_for_review
jobs:
test-lint:
runs-on: [ runner ]
container: git.sh-edraft.de/sh-edraft.de/act-runner:latest
steps:
- name: Clone Repository
uses: https://github.com/actions/checkout@v3
with:
token: ${{ secrets.CI_ACCESS_TOKEN }}
- name: Installing black
run: python3.12 -m pip install black
- name: Checking black
run: python3.12 -m black src --check

61
install.sh Normal file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail
# Find and combine requirements from src/cpl-*/requirements.txt,
# filtering out lines whose *package name* starts with "cpl-".
# Works with pinned versions, extras, markers, editable installs, and VCS refs.
shopt -s nullglob
req_files=(src/cpl-*/requirements.txt)
if ((${#req_files[@]} == 0)); then
echo "No requirements files found at src/cpl-*/requirements.txt" >&2
exit 1
fi
tmp_combined="$(mktemp)"
trap 'rm -f "$tmp_combined"' EXIT
# Concatenate, trim comments/whitespace, filter out cpl-* packages, dedupe.
# We keep non-package options/flags/constraints as-is.
awk '
function trim(s){ sub(/^[[:space:]]+/,"",s); sub(/[[:space:]]+$/,"",s); return s }
{
line=$0
# drop full-line comments and strip inline comments
if (line ~ /^[[:space:]]*#/) next
sub(/#[^!].*$/,"",line) # strip trailing comment (simple heuristic)
line=trim(line)
if (line == "") next
# Determine the package *name* even for "-e", extras, pins, markers, or VCS "@"
e = line
sub(/^-e[[:space:]]+/,"",e) # remove editable prefix
# Tokenize up to the first of these separators: space, [ < > = ! ~ ; @
token = e
sub(/\[.*/,"",token) # remove extras quickly
n = split(token, a, /[<>=!~;@[:space:]]/)
name = tolower(a[1])
# If the first token (name) starts with "cpl-", skip this requirement
if (name ~ /^cpl-/) next
print line
}
' "${req_files[@]}" | sort -u > "$tmp_combined"
if ! [ -s "$tmp_combined" ]; then
echo "Nothing to install after filtering out cpl-* packages." >&2
exit 0
fi
echo "Installing dependencies (excluding cpl-*) from:"
printf ' - %s\n' "${req_files[@]}"
echo
echo "Final set to install:"
cat "$tmp_combined"
echo
# Use python -m pip for reliability; change to python3 if needed.
python -m pip install -r "$tmp_combined"

View File

@@ -0,0 +1,32 @@
from cpl.dependency.service_collection import ServiceCollection as _ServiceCollection
def add_api(collection: _ServiceCollection):
try:
from cpl.database import mysql
collection.add_module(mysql)
except ImportError as e:
from cpl.core.errors import dependency_error
dependency_error("cpl-database", e)
try:
from cpl import auth
from cpl.auth import permission
collection.add_module(auth)
collection.add_module(permission)
except ImportError as e:
from cpl.core.errors import dependency_error
dependency_error("cpl-auth", e)
from cpl.api.registry.policy import PolicyRegistry
from cpl.api.registry.route import RouteRegistry
collection.add_singleton(PolicyRegistry)
collection.add_singleton(RouteRegistry)
_ServiceCollection.with_module(add_api, __name__)

View File

View File

@@ -0,0 +1,15 @@
from abc import ABC, abstractmethod
from starlette.types import Scope, Receive, Send
class ASGIMiddleware(ABC):
@abstractmethod
def __init__(self, app):
self._app = app
def _call_next(self, scope: Scope, receive: Receive, send: Send):
return self._app(scope, receive, send)
@abstractmethod
async def __call__(self, scope: Scope, receive: Receive, send: Send): ...

View File

@@ -0,0 +1,248 @@
import os
from enum import Enum
from typing import Mapping, Any, Callable, Self, Union
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.types import ExceptionHandler
from cpl import api, auth
from cpl.api.error import APIError
from cpl.api.logger import APILogger
from cpl.api.middleware.authentication import AuthenticationMiddleware
from cpl.api.middleware.authorization import AuthorizationMiddleware
from cpl.api.middleware.logging import LoggingMiddleware
from cpl.api.middleware.request import RequestMiddleware
from cpl.api.model.api_route import ApiRoute
from cpl.api.model.policy import Policy
from cpl.api.model.validation_match import ValidationMatch
from cpl.api.registry.policy import PolicyRegistry
from cpl.api.registry.route import RouteRegistry
from cpl.api.router import Router
from cpl.api.settings import ApiSettings
from cpl.api.typing import HTTPMethods, PartialMiddleware, PolicyResolver
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")
PolicyInput = Union[dict[str, PolicyResolver], Policy]
class WebApp(ApplicationABC):
def __init__(self, services: ServiceProviderABC):
super().__init__(services, [auth, api])
self._app: Starlette | None = None
self._api_settings = Configuration.get(ApiSettings)
self._policies = services.get_service(PolicyRegistry)
self._routes = services.get_service(RouteRegistry)
self._middleware: list[Middleware] = [
Middleware(RequestMiddleware),
Middleware(LoggingMiddleware),
]
self._exception_handlers: Mapping[Any, ExceptionHandler] = {
Exception: self._handle_exception,
APIError: self._handle_exception,
}
@staticmethod
async def _handle_exception(request: Request, exc: Exception):
if isinstance(exc, APIError):
_logger.error(exc)
return JSONResponse({"error": str(exc)}, status_code=exc.status_code)
if hasattr(request.state, "request_id"):
_logger.error(f"Request {request.state.request_id}", exc)
else:
_logger.error("Request unknown", exc)
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_database(self) -> Self:
self.with_migrations()
self.with_seeders()
return self
def with_app(self, app: Starlette) -> Self:
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) -> Self:
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[ApiRoute],
method: HTTPMethods,
authentication: bool = False,
roles: list[str | Enum] = None,
permissions: list[str | Enum] = None,
policies: list[str] = None,
match: ValidationMatch = None,
) -> Self:
self._check_for_app()
assert self._routes is not None, "routes must not be None"
assert all(isinstance(route, ApiRoute) for route in routes), "all routes must be of type ApiRoute"
for route in routes:
self.with_route(
route.path,
route.fn,
method,
authentication,
roles,
permissions,
policies,
match,
)
return self
def with_route(
self,
path: str,
fn: Callable[[Request], Any],
method: HTTPMethods,
authentication: bool = False,
roles: list[str | Enum] = None,
permissions: list[str | Enum] = None,
policies: list[str] = None,
match: ValidationMatch = None,
) -> Self:
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",
"HEAD",
"POST",
"PUT",
"PATCH",
"DELETE",
"OPTIONS",
], "method must be a valid HTTP method"
Router.route(path, method, registry=self._routes)(fn)
if authentication:
Router.authenticate()(fn)
if roles or permissions or policies:
Router.authorize(roles, permissions, policies, match)(fn)
return self
def with_middleware(self, middleware: PartialMiddleware) -> Self:
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 with_authentication(self) -> Self:
self.with_middleware(AuthenticationMiddleware)
return self
def with_authorization(self, *policies: list[PolicyInput] | PolicyInput) -> Self:
if policies:
_policies = []
if not isinstance(policies, list):
policies = list(policies)
for i, policy in enumerate(policies):
if isinstance(policy, dict):
for name, resolver in policy.items():
if not isinstance(name, str):
_logger.warning(f"Skipping policy at index {i}, name must be a string")
continue
if not callable(resolver):
_logger.warning(f"Skipping policy {name}, resolver must be callable")
continue
_policies.append(Policy(name, resolver))
continue
_policies.append(policy)
self._policies.extend_policies(_policies)
self.with_middleware(AuthorizationMiddleware)
return self
def _validate_policies(self):
for rule in Router.get_authorization_rules():
for policy_name in rule["policies"]:
policy = self._policies.get(policy_name)
if not policy:
_logger.fatal(f"Authorization policy '{policy_name}' not found")
async def main(self):
_logger.debug(f"Preparing API")
self._validate_policies()
if self._app is None:
routes = [route.to_starlette(self._services.inject) for route in self._routes.all()]
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}")
config = uvicorn.Config(
app, host=self._api_settings.host, port=self._api_settings.port, log_config=None, loop="asyncio"
)
server = uvicorn.Server(config)
await server.serve()
_logger.info("Shutdown API")

View File

@@ -0,0 +1,46 @@
from http.client import HTTPException
from starlette.responses import JSONResponse
from starlette.types import Scope, Receive, Send
class APIError(HTTPException):
status_code = 500
def __init__(self, message: str = ""):
super().__init__(self.status_code, message)
self._message = message
@property
def error_message(self) -> str:
if self._message:
return f"{type(self).__name__}: {self._message}"
return f"{type(self).__name__}"
async def asgi_response(self, scope: Scope, receive: Receive, send: Send):
r = JSONResponse({"error": self.error_message}, status_code=self.status_code)
return await r(scope, receive, send)
def response(self):
return JSONResponse({"error": self.error_message}, status_code=self.status_code)
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

View 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")

View File

@@ -0,0 +1,80 @@
from keycloak import KeycloakAuthenticationError
from starlette.types import Scope, Receive, Send
from cpl.api.abc.asgi_middleware_abc import ASGIMiddleware
from cpl.api.logger import APILogger
from cpl.api.error import Unauthorized
from cpl.api.middleware.request import get_request
from cpl.api.router import Router
from cpl.auth.keycloak import KeycloakClient
from cpl.auth.schema import AuthUserDao, AuthUser
from cpl.core.ctx import set_user
from cpl.dependency import ServiceProviderABC
_logger = APILogger(__name__)
class AuthenticationMiddleware(ASGIMiddleware):
@ServiceProviderABC.inject
def __init__(self, app, keycloak: KeycloakClient, user_dao: AuthUserDao):
ASGIMiddleware.__init__(self, app)
self._keycloak = keycloak
self._user_dao = user_dao
async def __call__(self, scope: Scope, receive: Receive, send: Send):
request = get_request()
url = request.url.path
if url not in Router.get_auth_required_routes():
_logger.trace(f"No authentication required for {url}")
return await self._app(scope, receive, send)
if not request.headers.get("Authorization"):
_logger.debug(f"Unauthorized access to {url}, missing Authorization header")
return await Unauthorized(f"Missing header Authorization").asgi_response(scope, receive, send)
auth_header = request.headers.get("Authorization", None)
if not auth_header or not auth_header.startswith("Bearer "):
return await Unauthorized("Invalid Authorization header").asgi_response(scope, receive, send)
token = auth_header.split("Bearer ")[1]
if not await self._verify_login(token):
_logger.debug(f"Unauthorized access to {url}, invalid token")
return await Unauthorized("Invalid token").asgi_response(scope, receive, send)
# check user exists in db, if not create
keycloak_id = self._keycloak.get_user_id(token)
if keycloak_id is None:
return await Unauthorized("Failed to get user id from token").asgi_response(scope, receive, send)
user = await self._get_or_crate_user(keycloak_id)
if user.deleted:
_logger.debug(f"Unauthorized access to {url}, user is deleted")
return await Unauthorized("User is deleted").asgi_response(scope, receive, send)
request.state.user = user
set_user(user)
return await self._call_next(scope, receive, send)
async def _get_or_crate_user(self, keycloak_id: str) -> AuthUser:
existing = await self._user_dao.find_by_keycloak_id(keycloak_id)
if existing is not None:
return existing
user = AuthUser(0, keycloak_id)
uid = await self._user_dao.create(user)
return await self._user_dao.get_by_id(uid)
async def _verify_login(self, token: str) -> bool:
try:
token_info = self._keycloak.introspect(token)
return token_info.get("active", False)
except KeycloakAuthenticationError as e:
_logger.debug(f"Keycloak authentication error: {e}")
return False
except Exception as e:
_logger.error(f"Unexpected error during token verification: {e}")
return False

View File

@@ -0,0 +1,69 @@
from starlette.types import Scope, Receive, Send
from cpl.api.abc.asgi_middleware_abc import ASGIMiddleware
from cpl.api.error import Unauthorized, Forbidden
from cpl.api.logger import APILogger
from cpl.api.middleware.request import get_request
from cpl.api.model.validation_match import ValidationMatch
from cpl.api.registry.policy import PolicyRegistry
from cpl.api.router import Router
from cpl.auth.schema._administration.auth_user_dao import AuthUserDao
from cpl.core.ctx.user_context import get_user
from cpl.dependency.service_provider_abc import ServiceProviderABC
_logger = APILogger(__name__)
class AuthorizationMiddleware(ASGIMiddleware):
@ServiceProviderABC.inject
def __init__(self, app, policies: PolicyRegistry, user_dao: AuthUserDao):
ASGIMiddleware.__init__(self, app)
self._policies = policies
self._user_dao = user_dao
async def __call__(self, scope: Scope, receive: Receive, send: Send):
request = get_request()
url = request.url.path
if url not in Router.get_authorization_rules_paths():
_logger.trace(f"No authorization required for {url}")
return await self._app(scope, receive, send)
user = get_user()
if not user:
return await Unauthorized(f"Unknown user").asgi_response(scope, receive, send)
roles = await user.roles
request.state.roles = roles
role_names = [r.name for r in roles]
perms = await user.permissions
request.state.permissions = perms
perm_names = [p.name for p in perms]
for rule in Router.get_authorization_rules():
match = rule["match"]
if rule["roles"]:
if match == ValidationMatch.all and not all(r in role_names for r in rule["roles"]):
return await Forbidden(f"missing roles: {rule["roles"]}").asgi_response(scope, receive, send)
if match == ValidationMatch.any and not any(r in role_names for r in rule["roles"]):
return await Forbidden(f"missing roles: {rule["roles"]}").asgi_response(scope, receive, send)
if rule["permissions"]:
if match == ValidationMatch.all and not all(p in perm_names for p in rule["permissions"]):
return await Forbidden(f"missing permissions: {rule["permissions"]}").asgi_response(scope, receive, send)
if match == ValidationMatch.any and not any(p in perm_names for p in rule["permissions"]):
return await Forbidden(f"missing permissions: {rule["permissions"]}").asgi_response(scope, receive, send)
for policy_name in rule["policies"]:
policy = self._policies.get(policy_name)
if not policy:
_logger.warning(f"Authorization policy '{policy_name}' not found")
continue
if not await policy.resolve(user):
return await Forbidden(f"policy {policy.name} failed").asgi_response(scope, receive, send)
return await self._call_next(scope, receive, send)

View File

@@ -0,0 +1,87 @@
import time
from starlette.requests import Request
from starlette.types import Receive, Scope, Send
from cpl.api.abc.asgi_middleware_abc import ASGIMiddleware
from cpl.api.logger import APILogger
from cpl.api.middleware.request import get_request
_logger = APILogger(__name__)
class LoggingMiddleware(ASGIMiddleware):
def __init__(self, app):
ASGIMiddleware.__init__(self, app)
async def __call__(self, scope: Scope, receive: Receive, send: Send):
if scope["type"] != "http":
await self._call_next(scope, receive, send)
return
request = get_request()
await self._log_request(request)
start_time = time.time()
response_body = b""
status_code = 500
async def send_wrapper(message):
nonlocal response_body, status_code
if message["type"] == "http.response.start":
status_code = message["status"]
if message["type"] == "http.response.body":
response_body += message.get("body", b"")
await send(message)
await self._call_next(scope, receive, send_wrapper)
duration = (time.time() - start_time) * 1000
await self._log_after_request(request, status_code, duration)
@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 {getattr(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 {getattr(request.state, 'request_id', '-')}: {request_info}")
@staticmethod
async def _log_after_request(request: Request, status_code: int, duration: float):
_logger.info(
f"Request finished {getattr(request.state, 'request_id', '-')}: {status_code}-{request.method}@{request.url.path} from {request.client.host} in {duration:.2f}ms"
)

View File

@@ -0,0 +1,53 @@
import time
from contextvars import ContextVar
from typing import Optional, Union
from uuid import uuid4
from starlette.requests import Request
from starlette.types import Scope, Receive, Send
from cpl.api.abc.asgi_middleware_abc import ASGIMiddleware
from cpl.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(ASGIMiddleware):
def __init__(self, app):
ASGIMiddleware.__init__(self, app)
self._ctx_token = None
async def __call__(self, scope: Scope, receive: Receive, send: Send):
request = Request(scope, receive, send)
await self.set_request_data(request)
try:
await self._app(scope, receive, send)
finally:
await self.clean_request_data()
async def set_request_data(self, request: TRequest):
request.state.request_id = uuid4()
request.state.start_time = time.time()
_logger.trace(f"Set new current request: {request.state.request_id}")
self._ctx_token = _request_context.set(request)
async def clean_request_data(self):
request = get_request()
if request is None:
return
if self._ctx_token is None:
return
_logger.trace(f"Clearing current request: {request.state.request_id}")
_request_context.reset(self._ctx_token)
def get_request() -> Optional[TRequest]:
return _request_context.get()

View File

View File

@@ -0,0 +1,49 @@
from typing import Callable
from starlette.routing import Route
from cpl.api.typing import HTTPMethods
class ApiRoute:
def __init__(
self,
path: str,
fn: Callable,
method: HTTPMethods,
**kwargs
):
self._path = path
self._fn = fn
self._method = method
self._kwargs = kwargs
@property
def name(self) -> str:
return self._fn.__name__
@property
def fn(self) -> Callable:
return self._fn
@property
def path(self) -> str:
return self._path
@property
def method(self) -> HTTPMethods:
return self._method
@property
def kwargs(self) -> dict:
return self._kwargs
def to_starlette(self, wrap_endpoint: Callable = None) -> Route:
return Route(
self._path,
self._fn if not wrap_endpoint else wrap_endpoint(self._fn),
methods=[self._method],
**self._kwargs,
)

View File

@@ -0,0 +1,34 @@
from asyncio import iscoroutinefunction
from typing import Optional, Any, Coroutine, Awaitable
from cpl.api.typing import PolicyResolver
from cpl.core.ctx import get_user
class Policy:
def __init__(
self,
name: str,
resolver: PolicyResolver = None,
):
self._name = name
self._resolver: Optional[PolicyResolver] = resolver
@property
def name(self) -> str:
return self._name
@property
def resolvers(self) -> PolicyResolver:
return self._resolver
async def resolve(self, *args, **kwargs) -> bool:
if not self._resolver:
return True
if callable(self._resolver):
if iscoroutinefunction(self._resolver):
return await self._resolver(get_user())
return self._resolver(get_user())
return False

View File

@@ -0,0 +1,6 @@
from enum import Enum
class ValidationMatch(Enum):
any = "any"
all = "all"

View File

View File

@@ -0,0 +1,28 @@
from typing import Optional
from cpl.api.model.policy import Policy
from cpl.core.abc.registry_abc import RegistryABC
class PolicyRegistry(RegistryABC):
def __init__(self):
RegistryABC.__init__(self)
def extend(self, items: list[Policy]):
for policy in items:
self.add(policy)
def add(self, item: Policy):
assert isinstance(item, Policy), "policy must be an instance of Policy"
if item.name in self._items:
raise ValueError(f"Policy {item.name} is already registered")
self._items[item.name] = item
def get(self, key: str) -> Optional[Policy]:
return self._items.get(key)
def all(self) -> list[Policy]:
return list(self._items.values())

View File

@@ -0,0 +1,33 @@
from typing import Optional
from cpl.api.model.policy import Policy
from cpl.api.model.api_route import ApiRoute
from cpl.core.abc.registry_abc import RegistryABC
class RouteRegistry(RegistryABC):
def __init__(self):
RegistryABC.__init__(self)
def extend(self, items: list[ApiRoute]):
for policy in items:
self.add(policy)
def add(self, item: ApiRoute):
assert isinstance(item, ApiRoute), "route must be an instance of ApiRoute"
if item.path in self._items:
raise ValueError(f"ApiRoute {item.path} is already registered")
self._items[item.path] = item
def set(self, item: ApiRoute):
assert isinstance(item, ApiRoute), "route must be an instance of ApiRoute"
self._items[item.path] = item
def get(self, key: str) -> Optional[ApiRoute]:
return self._items.get(key)
def all(self) -> list[ApiRoute]:
return list(self._items.values())

View File

@@ -0,0 +1,155 @@
from enum import Enum
from cpl.api.model.validation_match import ValidationMatch
from cpl.api.registry.route import RouteRegistry
from cpl.api.typing import HTTPMethods
class Router:
_auth_required: list[str] = []
_authorization_rules: dict[str, dict] = {}
@classmethod
def get_auth_required_routes(cls) -> list[str]:
return cls._auth_required
@classmethod
def get_authorization_rules_paths(cls) -> list[str]:
return list(cls._authorization_rules.keys())
@classmethod
def get_authorization_rules(cls) -> list[dict]:
return list(cls._authorization_rules.values())
@classmethod
def authenticate(cls):
"""
Decorator to mark a route as requiring authentication.
Usage:
@Route.authenticate()
@Route.get("/example")
async def example_endpoint(request: TRequest):
...
"""
def inner(fn):
route_path = getattr(fn, "_route_path", None)
if route_path and route_path not in cls._auth_required:
cls._auth_required.append(route_path)
return fn
return inner
@classmethod
def authorize(cls, roles: list[str | Enum]=None, permissions: list[str | Enum]=None, policies: list[str]=None, match: ValidationMatch=None):
"""
Decorator to mark a route as requiring authorization.
Usage:
@Route.authorize()
@Route.get("/example")
async def example_endpoint(request: TRequest):
...
"""
assert roles is None or isinstance(roles, list), "roles must be a list of strings"
assert permissions is None or isinstance(permissions, list), "permissions must be a list of strings"
assert policies is None or isinstance(policies, list), "policies must be a list of strings"
assert match is None or isinstance(match, ValidationMatch), "match must be an instance of ValidationMatch"
if roles is not None:
for role in roles:
if isinstance(role, Enum):
roles[roles.index(role)] = role.value
if permissions is not None:
for perm in permissions:
if isinstance(perm, Enum):
permissions[permissions.index(perm)] = perm.value
def inner(fn):
path = getattr(fn, "_route_path", None)
if not path:
return fn
if path in cls._authorization_rules:
raise ValueError(f"Route {path} is already registered for authorization")
cls._authorization_rules[path] = {
"roles": roles or [],
"permissions": permissions or [],
"policies": policies or [],
"match": match or ValidationMatch.all,
}
return fn
return inner
@classmethod
def route(cls, path: str, method: HTTPMethods, registry: RouteRegistry=None, **kwargs):
if not registry:
from cpl.api.model.api_route import ApiRoute
from cpl.dependency.service_provider_abc import ServiceProviderABC
routes = ServiceProviderABC.get_global_service(RouteRegistry)
else:
routes = registry
def inner(fn):
routes.add(ApiRoute(path, fn, method, **kwargs))
setattr(fn, "_route_path", path)
return fn
return inner
@classmethod
def get(cls, path: str, **kwargs):
return cls.route(path, "GET", **kwargs)
@classmethod
def head(cls, path: str, **kwargs):
return cls.route(path, "HEAD", **kwargs)
@classmethod
def post(cls, path: str, **kwargs):
return cls.route(path, "POST", **kwargs)
@classmethod
def put(cls, path: str, **kwargs):
return cls.route(path, "PUT", **kwargs)
@classmethod
def patch(cls, path: str, **kwargs):
return cls.route(path, "PATCH", **kwargs)
@classmethod
def delete(cls, path: str, **kwargs):
return cls.route(path, "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):
...
"""
from cpl.api.model.api_route import ApiRoute
from cpl.dependency.service_provider_abc import ServiceProviderABC
routes = ServiceProviderABC.get_global_service(RouteRegistry)
def inner(fn):
path = getattr(fn, "_route_path", None)
if path is None:
raise ValueError("Cannot override a route that has not been registered yet")
route = routes.get(path)
if route is None:
raise ValueError(f"Cannot override a route that does not exist: {path}")
routes.add(ApiRoute(path, fn, route.method, **route.kwargs))
setattr(fn, "_route_path", path)
return fn
return inner

View 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])

View File

@@ -0,0 +1,19 @@
from typing import Union, Literal, Callable, Type, Awaitable
from urllib.request import Request
from starlette.middleware import Middleware
from starlette.types import ASGIApp
from starlette.websockets import WebSocket
from cpl.api.abc.asgi_middleware_abc import ASGIMiddleware
from cpl.auth.schema import AuthUser
TRequest = Union[Request, WebSocket]
HTTPMethods = Literal["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
PartialMiddleware = Union[
ASGIMiddleware,
Type[ASGIMiddleware],
Middleware,
Callable[[ASGIApp], ASGIApp],
]
PolicyResolver = Callable[[AuthUser], bool | Awaitable[bool]]

View File

@@ -0,0 +1,30 @@
[build-system]
requires = ["setuptools>=70.1.0", "wheel>=0.43.0"]
build-backend = "setuptools.build_meta"
[project]
name = "cpl-api"
version = "2024.7.0"
description = "CPL api"
readme ="CPL api package"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [
{ name = "Sven Heidemann", email = "sven.heidemann@sh-edraft.de" }
]
keywords = ["cpl", "api", "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"] }

View File

@@ -0,0 +1 @@
black==25.1.0

View File

@@ -0,0 +1,7 @@
cpl-auth
cpl-application
cpl-core
cpl-dependency
starlette==0.48.0
python-multipart==0.0.20
uvicorn==0.35.0

View File

@@ -3,9 +3,9 @@ from typing import Callable, Self
from cpl.application.host import Host
from cpl.core.console.console import Console
from cpl.core.environment.environment import Environment
from cpl.core.log import LogSettings
from cpl.core.log.log_level import LogLevel
from cpl.core.log.logger_abc import LoggerABC
from cpl.core.log.log_level_enum import LogLevel
from cpl.dependency.service_provider_abc import ServiceProviderABC
@@ -22,8 +22,15 @@ class ApplicationABC(ABC):
"""
@abstractmethod
def __init__(self, services: ServiceProviderABC):
def __init__(self, services: ServiceProviderABC, required_modules: list[str | object] = None):
self._services = services
self._required_modules = (
[x.__name__ if not isinstance(x, str) else x for x in required_modules] if required_modules else []
)
@property
def required_modules(self) -> list[str]:
return self._required_modules
@classmethod
def extend(cls, name: str | Callable, func: Callable[[Self], Self]):
@@ -43,7 +50,10 @@ class ApplicationABC(ABC):
def with_logging(self, level: LogLevel = None):
if level is None:
level = Environment.get("LOG_LEVEL", LogLevel, LogLevel.info)
from cpl.core.configuration.configuration import Configuration
settings = Configuration.get(LogSettings)
level = settings.level if settings else LogLevel.info
logger = self._services.get_service(LoggerABC)
logger.set_level(level)
@@ -77,7 +87,7 @@ class ApplicationABC(ABC):
try:
Host.run(self.main)
except KeyboardInterrupt:
Console.close()
pass
@abstractmethod
def main(self): ...

View File

@@ -1,16 +1,18 @@
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
from cpl.application.abc.startup_abc import StartupABC
from cpl.application.abc.startup_extension_abc import StartupExtensionABC
from cpl.application.host import Host
from cpl.core.errors import dependency_error
from cpl.dependency.service_collection import ServiceCollection
TApp = TypeVar("TApp", bound=ApplicationABC)
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"
@@ -34,6 +36,18 @@ class ApplicationBuilder:
def service_provider(self):
return self._services.build()
def validate_app_required_modules(self, app: ApplicationABC):
for module in app.required_modules:
if module in self._services.loaded_modules:
continue
dependency_error(
module,
ImportError(
f"Required module '{module}' for application '{app.__class__.__name__}' is not loaded. Load using 'add_module({module})' method."
),
)
def with_startup(self, startup: Type[StartupABC]) -> "ApplicationBuilder":
self._startup = startup
return self
@@ -49,7 +63,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)
@@ -61,4 +75,6 @@ class ApplicationBuilder:
for extension in self._app_extensions:
Host.run(extension.run, self.service_provider)
return self._app(self.service_provider)
app = self._app(self.service_provider)
self.validate_app_required_modules(app)
return app

View File

@@ -40,11 +40,10 @@ def _add_daos(collection: _ServiceCollection):
def add_auth(collection: _ServiceCollection):
import os
from cpl.core.console import Console
from cpl.database.service.migration_service import MigrationService
from cpl.database.model.server_type import ServerType, ServerTypes
try:
from cpl.database.service.migration_service import MigrationService
from cpl.database.model.server_type import ServerType, ServerTypes
collection.add_singleton(_KeycloakClient)
collection.add_singleton(_KeycloakAdmin)
@@ -59,22 +58,25 @@ def add_auth(collection: _ServiceCollection):
elif ServerType.server_type == ServerTypes.MYSQL:
migration_service.with_directory(os.path.join(os.path.dirname(os.path.realpath(__file__)), "scripts/mysql"))
except ImportError as e:
Console.error("cpl-auth is not installed", str(e))
from cpl.core.console import Console
Console.error("cpl-database is not installed", str(e))
def add_permission(collection: _ServiceCollection):
from cpl.auth.permission_seeder import PermissionSeeder
from cpl.database.abc.data_seeder_abc import DataSeederABC
from cpl.auth.permission.permissions_registry import PermissionsRegistry
from cpl.auth.permission.permissions import Permissions
from .permission_seeder import PermissionSeeder
from .permission.permissions_registry import PermissionsRegistry
from .permission.permissions import Permissions
try:
from cpl.database.abc.data_seeder_abc import DataSeederABC
collection.add_singleton(DataSeederABC, PermissionSeeder)
PermissionsRegistry.with_enum(Permissions)
except ImportError as e:
from cpl.core.console import Console
Console.error("cpl-auth is not installed", str(e))
Console.error("cpl-database is not installed", str(e))
_ServiceCollection.with_module(add_auth, __name__)

View File

@@ -1,4 +1,6 @@
from keycloak import KeycloakOpenID, KeycloakAdmin, KeycloakOpenIDConnection
from typing import Optional
from keycloak import KeycloakOpenID
from cpl.auth.auth_logger import AuthLogger
from cpl.auth.keycloak_settings import KeycloakSettings
@@ -17,10 +19,7 @@ class KeycloakClient(KeycloakOpenID):
client_secret_key=settings.client_secret,
)
_logger.info("Initializing Keycloak client")
connection = KeycloakOpenIDConnection(
server_url=settings.url,
client_id=settings.client_id,
realm_name=settings.realm,
client_secret_key=settings.client_secret,
)
self._admin = KeycloakAdmin(connection=connection)
def get_user_id(self, token: str) -> Optional[str]:
info = self.introspect(token)
return info.get("sub", None)

View File

@@ -1,37 +1,17 @@
from typing import Optional
from cpl.core.configuration.configuration_model_abc import ConfigurationModelABC
from cpl.core.environment import Environment
class KeycloakSettings(ConfigurationModelABC):
def __init__(
self,
url: str = Environment.get("KEYCLOAK_URL", str),
client_id: str = Environment.get("KEYCLOAK_CLIENT_ID", str),
realm: str = Environment.get("KEYCLOAK_REALM", str),
client_secret: str = Environment.get("KEYCLOAK_CLIENT_SECRET", str),
src: Optional[dict] = None,
):
ConfigurationModelABC.__init__(self)
ConfigurationModelABC.__init__(self, src, "KEYCLOAK")
self._url: Optional[str] = url
self._client_id: Optional[str] = client_id
self._realm: Optional[str] = realm
self._client_secret: Optional[str] = client_secret
@property
def url(self) -> Optional[str]:
return self._url
@property
def client_id(self) -> Optional[str]:
return self._client_id
@property
def realm(self) -> Optional[str]:
return self._realm
@property
def client_secret(self) -> Optional[str]:
return self._client_secret
self.option("url", str, required=True)
self.option("client_id", str, required=True)
self.option("realm", str, required=True)
self.option("client_secret", str, required=True)

View File

@@ -16,7 +16,7 @@ class AuthUserDao(DbModelDaoABC[AuthUser]):
def __init__(self):
DbModelDaoABC.__init__(self, __name__, AuthUser, TableManager.get("auth_users"))
self.attribute(AuthUser.keycloak_id, str, aliases=["keycloakId"])
self.attribute(AuthUser.keycloak_id, str, db_name="keycloakId")
async def get_users():
return [(x.id, x.username, x.email) for x in await self.get_all()]
@@ -43,9 +43,9 @@ class AuthUserDao(DbModelDaoABC[AuthUser]):
p = await permission_dao.get_by_name(permission if isinstance(permission, str) else permission.value)
result = await self._db.select_map(
f"""
SELECT COUNT(*)
FROM permission.role_users ru
JOIN permission.role_permissions rp ON ru.roleId = rp.roleId
SELECT COUNT(*) as count
FROM {TableManager.get("role_users")} ru
JOIN {TableManager.get("role_permissions")} rp ON ru.roleId = rp.roleId
WHERE ru.userId = {user_id}
AND rp.permissionId = {p.id}
AND ru.deleted = FALSE
@@ -61,9 +61,9 @@ class AuthUserDao(DbModelDaoABC[AuthUser]):
result = await self._db.select_map(
f"""
SELECT p.*
FROM permission.permissions p
JOIN permission.role_permissions rp ON p.id = rp.permissionId
JOIN permission.role_users ru ON rp.roleId = ru.roleId
FROM {TableManager.get("permissions")} p
JOIN {TableManager.get("role_permissions")} rp ON p.id = rp.permissionId
JOIN {TableManager.get("role_users")} ru ON rp.roleId = ru.roleId
WHERE ru.userId = {user_id}
AND rp.deleted = FALSE
AND ru.deleted = FALSE;

View File

@@ -14,7 +14,7 @@ CREATE TABLE IF NOT EXISTS administration_auth_users
CREATE TABLE IF NOT EXISTS administration_auth_users_history
(
id INT AUTO_INCREMENT PRIMARY KEY,
id INT NOT NULL,
keycloakId CHAR(36) NOT NULL,
-- for history
deleted BOOL NOT NULL,

View File

@@ -15,7 +15,7 @@ CREATE TABLE IF NOT EXISTS administration_api_keys
CREATE TABLE IF NOT EXISTS administration_api_keys_history
(
id INT AUTO_INCREMENT PRIMARY KEY,
id INT NOT NULL,
identifier VARCHAR(255) NOT NULL,
keyString VARCHAR(255) NOT NULL,
deleted BOOL NOT NULL,

View File

@@ -13,7 +13,7 @@ CREATE TABLE IF NOT EXISTS permission_permissions
CREATE TABLE IF NOT EXISTS permission_permissions_history
(
id INT AUTO_INCREMENT PRIMARY KEY,
id INT NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT NULL,
deleted BOOL NOT NULL,
@@ -57,7 +57,7 @@ CREATE TABLE IF NOT EXISTS permission_roles
CREATE TABLE IF NOT EXISTS permission_roles_history
(
id INT AUTO_INCREMENT PRIMARY KEY,
id INT NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT NULL,
deleted BOOL NOT NULL,
@@ -103,7 +103,7 @@ CREATE TABLE IF NOT EXISTS permission_role_permissions
CREATE TABLE IF NOT EXISTS permission_role_permissions_history
(
id INT AUTO_INCREMENT PRIMARY KEY,
id INT NOT NULL,
RoleId INT NOT NULL,
permissionId INT NOT NULL,
deleted BOOL NOT NULL,
@@ -149,7 +149,7 @@ CREATE TABLE IF NOT EXISTS permission_role_auth_users
CREATE TABLE IF NOT EXISTS permission_role_auth_users_history
(
id INT AUTO_INCREMENT PRIMARY KEY,
id INT NOT NULL,
RoleId INT NOT NULL,
UserId INT NOT NULL,
deleted BOOL NOT NULL,

View File

@@ -15,7 +15,7 @@ CREATE TABLE IF NOT EXISTS permission_api_key_permissions
CREATE TABLE IF NOT EXISTS permission_api_key_permissions_history
(
id INT AUTO_INCREMENT PRIMARY KEY,
id INT NOT NULL,
apiKeyId INT NOT NULL,
permissionId INT NOT NULL,
deleted BOOL NOT NULL,

View File

@@ -1,4 +1,4 @@
cpl-core
cpl-dependency
cpl-database
python-keycloak-5.8.1
python-keycloak==5.8.1

View File

View File

@@ -0,0 +1,23 @@
from abc import abstractmethod, ABC
from typing import Generic
from cpl.core.typing import T
class RegistryABC(ABC, Generic[T]):
@abstractmethod
def __init__(self):
self._items: dict[str, T] = {}
@abstractmethod
def extend(self, items: list[T]) -> None: ...
@abstractmethod
def add(self, item: T) -> None: ...
@abstractmethod
def get(self, key: str) -> T | None: ...
@abstractmethod
def all(self) -> list[T]: ...

View File

@@ -2,13 +2,13 @@ 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
from cpl.core.console.console import Console
from cpl.core.console.foreground_color_enum import ForegroundColorEnum
from cpl.core.typing import D, T
from cpl.core.utils.json_processor import JSONProcessor
class Configuration:
@@ -116,9 +116,7 @@ class Configuration:
if sub.__name__ != key and sub.__name__.replace("Settings", "") != key:
continue
configuration = JSONProcessor.process(sub, value)
cls.set(sub, configuration)
cls.set(sub, sub(value))
@classmethod
def set(cls, key: Any, value: T):
@@ -129,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 isclass(key) and issubclass(key, ConfigurationModelABC) and result == default:
result = key()
cls.set(key, result)
return result

View File

@@ -1,7 +1,82 @@
from abc import ABC
from abc import ABC, abstractmethod
from typing import Optional, Type, Any
from cpl.core.typing import T
from cpl.core.utils.cast import cast
from cpl.core.utils.get_value import get_value
from cpl.core.utils.string import String
class ConfigurationModelABC(ABC):
r"""
ABC for configuration model classes
"""
@abstractmethod
def __init__(
self,
src: Optional[dict] = None,
env_prefix: Optional[str] = None,
readonly: bool = True,
):
ABC.__init__(self)
self._src = src or {}
self._options: dict[str, Any] = {}
self._env_prefix = env_prefix
self._readonly = readonly
def __setattr__(self, attr: str, value: Any):
if hasattr(self, "_readonly") and self._readonly:
raise AttributeError(f"Cannot set attribute: {attr}. {type(self).__name__} is read-only")
super().__setattr__(attr, value)
def __getattr__(self, attr: str) -> Any:
options = super().__getattribute__("_options")
if attr in options:
return options[attr]
return super().__getattribute__(attr)
def option(self, field: str, cast_type: Type[T], default=None, required=False, from_env=True):
value = None
field_variants = [
field,
String.first_to_upper(field),
String.first_to_lower(field),
String.to_camel_case(field),
String.to_snake_case(field),
String.to_pascal_case(field),
]
value = None
for variant in field_variants:
if variant in self._src:
value = self._src[variant]
break
if value is None and from_env:
from cpl.core.environment import Environment
env_field = field.upper()
if self._env_prefix:
env_field = f"{self._env_prefix}_{env_field}"
value = cast(Environment.get(env_field, str), cast_type)
if value is None and required:
raise ValueError(f"{type(self).__name__}.{field} is required")
elif value is None:
self._options[field] = default
return
self._options[field] = cast(value, cast_type)
def get(self, field: str, default=None) -> Optional[T]:
return get_value(self._src, field, self._options[field].type, default)
def to_dict(self) -> dict:
return {field: self.get(field) for field in self._options.keys()}

View File

@@ -0,0 +1,15 @@
import traceback
from cpl.core.console import Console
def dependency_error(package_name: str, e: ImportError) -> None:
Console.error(f"'{package_name}' is required to use this feature. Please install it and try again.")
tb = traceback.format_exc()
if not tb.startswith("NoneType: None"):
Console.write_line("->", tb)
elif e is not None:
Console.write_line("->", str(e))
exit(1)

View File

@@ -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

View File

@@ -0,0 +1,18 @@
from typing import Optional
from cpl.core.configuration.configuration_model_abc import ConfigurationModelABC
from cpl.core.log.log_level import LogLevel
class LogSettings(ConfigurationModelABC):
def __init__(
self,
src: Optional[dict] = None,
):
ConfigurationModelABC.__init__(self, src, "LOG")
self.option("path", str, default="logs")
self.option("filename", str, default="app.log")
self.option("console", LogLevel, default=LogLevel.info)
self.option("level", LogLevel, default=LogLevel.info)

View File

@@ -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()}")

View File

@@ -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

View File

@@ -1,53 +0,0 @@
from typing import Optional
from cpl.core.configuration.configuration_model_abc import ConfigurationModelABC
from cpl.core.log.log_level_enum import LogLevel
class LogSettings(ConfigurationModelABC):
r"""Representation of logging settings"""
def __init__(
self,
path: str = None,
filename: str = None,
console_log_level: LogLevel = None,
file_log_level: LogLevel = None,
):
ConfigurationModelABC.__init__(self)
self._path: Optional[str] = path
self._filename: Optional[str] = filename
self._console: Optional[LogLevel] = console_log_level
self._level: Optional[LogLevel] = file_log_level
@property
def path(self) -> str:
return self._path
@path.setter
def path(self, path: str) -> None:
self._path = path
@property
def filename(self) -> str:
return self._filename
@filename.setter
def filename(self, filename: str) -> None:
self._filename = filename
@property
def console(self) -> LogLevel:
return self._console
@console.setter
def console(self, console: LogLevel) -> None:
self._console = console
@property
def level(self) -> LogLevel:
return self._level
@level.setter
def level(self, level: LogLevel) -> None:
self._level = level

View File

@@ -3,3 +3,4 @@ from .credential_manager import CredentialManager
from .json_processor import JSONProcessor
from .pip import Pip
from .string import String
from .get_value import get_value

View File

@@ -0,0 +1,69 @@
from enum import Enum
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)
except ValueError:
pass
try:
return enum_type(value.lower())
except ValueError:
pass
try:
return enum_type(value.upper())
except ValueError:
pass
try:
return enum_type[value]
except KeyError:
pass
try:
return enum_type[value.lower()]
except KeyError:
pass
try:
return enum_type[value.upper()]
except KeyError:
pass
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.
:param Any value: Value to be casted.
:param Type[T] cast_type: A callable to cast the variable's value.
: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 (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.")
if value.startswith("[") and value.endswith("]"):
value = value[1:-1]
value = value.split(list_delimiter)
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)

View File

@@ -6,8 +6,10 @@ from cpl.core.log.logger import Logger
_logger = Logger(__name__)
class CredentialManager:
r"""Handles credential encryption and decryption"""
_secret: str = None
@classmethod

View File

@@ -1,7 +1,7 @@
from enum import Enum
from typing import Type, Optional
from cpl.core.typing import T
from cpl.core.utils.cast import cast
def get_value(
@@ -38,33 +38,9 @@ def get_value(
return value
try:
if cast_type == bool:
return value.lower() in ["true", "1"]
if issubclass(cast_type, Enum):
try:
return cast_type(value)
except ValueError:
pass
try:
return cast_type[value]
except KeyError:
pass
return default
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.")
if value.startswith("[") and value.endswith("]"):
value = value[1:-1]
value = value.split(list_delimiter)
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)
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

View File

@@ -17,7 +17,36 @@ class String:
Returns:
String converted to CamelCase
"""
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:

View File

@@ -2,5 +2,4 @@ art==6.5
colorama==0.4.6
tabulate==0.9.0
termcolor==3.1.0
mysql-connector-python==9.4.0
pynput==1.8.1

View File

@@ -1,3 +1,4 @@
import os
from typing import Type
from cpl.application.abc import ApplicationABC as _ApplicationABC
@@ -7,13 +8,19 @@ from . import postgres as _postgres
from .table_manager import TableManager
def _with_migrations(self: _ApplicationABC, *paths: list[str]) -> _ApplicationABC:
def _with_migrations(self: _ApplicationABC, *paths: str | list[str]) -> _ApplicationABC:
from cpl.application.host import Host
from cpl.database.service.migration_service import MigrationService
migration_service = self._services.get_service(MigrationService)
migration_service.with_directory("./scripts")
migration_service.with_directory(os.path.join(os.path.dirname(os.path.abspath(__file__)), "scripts"))
if isinstance(paths, str):
paths = [paths]
for path in paths:
migration_service.with_directory(path)
Host.run(migration_service.migrate)
return self

View File

@@ -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
@@ -157,13 +156,16 @@ class DataAccessObjectABC(ABC, Generic[T_DBM]):
:param dict result: Result from the database
:return:
"""
value_map: dict[str, T] = {}
value_map: dict[str, Any] = {}
db_names = self.__db_names.items()
for db_name, value in result.items():
# Find the attribute name corresponding to the db_name
attr_name = next((k for k, v in self.__db_names.items() if v == db_name), None)
if attr_name:
value_map[attr_name] = self._get_value_from_sql(self.__attributes[attr_name], value)
attr_name = next((k for k, v in db_names if v == db_name), None)
if not attr_name:
continue
value_map[attr_name] = self._get_value_from_sql(self.__attributes[attr_name], value)
return self._model_type(**value_map)
@@ -485,7 +487,7 @@ class DataAccessObjectABC(ABC, Generic[T_DBM]):
builder.with_temp_table(self._external_fields[temp])
if for_count:
builder.with_attribute("COUNT(*)", ignore_table_name=True)
builder.with_attribute("COUNT(*) as count", ignore_table_name=True)
else:
builder.with_attribute("*")
@@ -869,6 +871,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

View File

@@ -2,74 +2,23 @@ from typing import Optional
from cpl.core.configuration import Configuration
from cpl.core.configuration.configuration_model_abc import ConfigurationModelABC
from cpl.core.environment import Environment
class DatabaseSettings(ConfigurationModelABC):
r"""Represents settings for the database connection"""
def __init__(
self,
host: str = Environment.get("DB_HOST", str),
port: int = Environment.get("DB_PORT", str, Configuration.get("DB_DEFAULT_PORT", 0)),
user: str = Environment.get("DB_USER", str),
password: str = Environment.get("DB_PASSWORD", str),
database: str = Environment.get("DB_DATABASE", str),
charset: str = Environment.get("DB_CHARSET", str, "utf8mb4"),
use_unicode: bool = Environment.get("DB_USE_UNICODE", bool, False),
buffered: bool = Environment.get("DB_BUFFERED", bool, False),
auth_plugin: str = Environment.get("DB_AUTH_PLUGIN", str, "caching_sha2_password"),
ssl_disabled: bool = Environment.get("DB_SSL_DISABLED", bool, False),
src: Optional[dict] = None,
):
ConfigurationModelABC.__init__(self)
ConfigurationModelABC.__init__(self, src, "DB")
self._host: Optional[str] = host
self._port: Optional[int] = port
self._user: Optional[str] = user
self._password: Optional[str] = password
self._database: Optional[str] = database
self._charset: Optional[str] = charset
self._use_unicode: Optional[bool] = use_unicode
self._buffered: Optional[bool] = buffered
self._auth_plugin: Optional[str] = auth_plugin
self._ssl_disabled: Optional[bool] = ssl_disabled
@property
def host(self) -> Optional[str]:
return self._host
@property
def port(self) -> Optional[int]:
return self._port
@property
def user(self) -> Optional[str]:
return self._user
@property
def password(self) -> Optional[str]:
return self._password
@property
def database(self) -> Optional[str]:
return self._database
@property
def charset(self) -> Optional[str]:
return self._charset
@property
def use_unicode(self) -> Optional[bool]:
return self._use_unicode
@property
def buffered(self) -> Optional[bool]:
return self._buffered
@property
def auth_plugin(self) -> Optional[str]:
return self._auth_plugin
@property
def ssl_disabled(self) -> Optional[bool]:
return self._ssl_disabled
self.option("host", str, required=True)
self.option("port", int, Configuration.get("DB_DEFAULT_PORT"), required=True)
self.option("user", str, required=True)
self.option("password", str, required=True)
self.option("database", str, required=True)
self.option("charset", str, "utf8mb4")
self.option("use_unicode", bool, False)
self.option("buffered", bool, False)
self.option("auth_plugin", str, "caching_sha2_password")
self.option("ssl_disabled", bool, False)

View File

@@ -1,7 +1,7 @@
from typing import Optional, Any
import sqlparse
import aiomysql
from mysql.connector.aio import MySQLConnectionPool
from cpl.core.environment import Environment
from cpl.database.db_logger import DBLogger
@@ -11,95 +11,82 @@ _logger = DBLogger(__name__)
class MySQLPool:
"""
Create a pool when connecting to MySQL, which will decrease the time spent in
requesting connection, creating connection, and closing connection.
"""
def __init__(self, database_settings: DatabaseSettings):
self._db_settings = database_settings
self.pool: Optional[aiomysql.Pool] = None
self._dbconfig = {
"host": database_settings.host,
"port": database_settings.port,
"user": database_settings.user,
"password": database_settings.password,
"database": database_settings.database,
"ssl_disabled": True,
}
self._pool: Optional[MySQLConnectionPool] = None
async def _get_pool(self):
if self.pool is None or self.pool._closed:
if self._pool is None:
self._pool = MySQLConnectionPool(
pool_name="mypool", pool_size=Environment.get("DB_POOL_SIZE", int, 1), **self._dbconfig
)
await self._pool.initialize_pool()
con = await self._pool.get_connection()
try:
self.pool = await aiomysql.create_pool(
host=self._db_settings.host,
port=self._db_settings.port,
user=self._db_settings.user,
password=self._db_settings.password,
db=self._db_settings.database,
minsize=1,
maxsize=Environment.get("DB_POOL_SIZE", int, 1),
)
async with await con.cursor() as cursor:
await cursor.execute("SELECT 1")
await cursor.fetchall()
except Exception as e:
_logger.fatal("Failed to connect to the database", e)
raise
return self.pool
_logger.fatal(f"Error connecting to the database: {e}")
finally:
await con.close()
return self._pool
@staticmethod
async def _exec_sql(cursor: Any, query: str, args=None, multi=True):
result = []
if multi:
queries = [str(stmt).strip() for stmt in sqlparse.parse(query) if str(stmt).strip()]
for q in queries:
if q.strip() == "":
continue
await cursor.execute(q, args)
if cursor.description is not None:
result = await cursor.fetchall()
else:
await cursor.execute(query, args)
if cursor.description is not None:
result = await cursor.fetchall()
return result
async def execute(self, query: str, args=None, multi=True) -> list[list]:
"""
Execute a SQL statement, it could be with args and without args. The usage is
similar to the execute() function in aiomysql.
:param query: SQL clause
:param args: args needed by the SQL clause
:param multi: if the query is a multi-statement
:return: return result
"""
pool = await self._get_pool()
async with pool.acquire() as con:
async with con.cursor() as cursor:
await self._exec_sql(cursor, query, args, multi)
con = await pool.get_connection()
try:
async with await con.cursor() as cursor:
result = await self._exec_sql(cursor, query, args, multi)
await con.commit()
if cursor.description is not None: # Query returns rows
res = await cursor.fetchall()
if res is None:
return []
return [list(row) for row in res]
else:
return []
return result
finally:
await con.close()
async def select(self, query: str, args=None, multi=True) -> list[str]:
"""
Execute a SQL statement, it could be with args and without args. The usage is
similar to the execute() function in aiomysql.
:param query: SQL clause
:param args: args needed by the SQL clause
:param multi: if the query is a multi-statement
:return: return result
"""
pool = await self._get_pool()
async with pool.acquire() as con:
async with con.cursor() as cursor:
await self._exec_sql(cursor, query, args, multi)
res = await cursor.fetchall()
con = await pool.get_connection()
try:
async with await con.cursor() as cursor:
res = await self._exec_sql(cursor, query, args, multi)
return list(res)
finally:
await con.close()
async def select_map(self, query: str, args=None, multi=True) -> list[dict]:
"""
Execute a SQL statement, it could be with args and without args. The usage is
similar to the execute() function in aiomysql.
:param query: SQL clause
:param args: args needed by the SQL clause
:param multi: if the query is a multi-statement
:return: return result
"""
pool = await self._get_pool()
async with pool.acquire() as con:
async with con.cursor(aiomysql.DictCursor) as cursor:
await self._exec_sql(cursor, query, args, multi)
res = await cursor.fetchall()
con = await pool.get_connection()
try:
async with await con.cursor(dictionary=True) as cursor:
res = await self._exec_sql(cursor, query, args, multi)
return list(res)
finally:
await con.close()

View File

@@ -25,21 +25,23 @@ class PostgresPool:
f"password={database_settings.password} "
f"dbname={database_settings.database}"
)
self.pool: Optional[AsyncConnectionPool] = None
self._pool: Optional[AsyncConnectionPool] = None
async def _get_pool(self):
pool = AsyncConnectionPool(
conninfo=self._conninfo, open=False, min_size=1, max_size=Environment.get("DB_POOL_SIZE", int, 1)
)
await pool.open()
try:
async with pool.connection() as con:
await pool.check_connection(con)
except PoolTimeout as e:
await pool.close()
_logger.fatal(f"Failed to connect to the database", e)
return pool
if self._pool is None:
pool = AsyncConnectionPool(
conninfo=self._conninfo, open=False, min_size=1, max_size=Environment.get("DB_POOL_SIZE", int, 1)
)
await pool.open()
try:
async with pool.connection() as con:
await pool.check_connection(con)
except PoolTimeout as e:
await pool.close()
_logger.fatal(f"Failed to connect to the database", e)
self._pool = pool
return self._pool
@staticmethod
async def _exec_sql(cursor: Any, query: str, args=None, multi=True):

View File

@@ -33,7 +33,7 @@ class TableManager:
},
"role_users": {
ServerTypes.POSTGRES: "permission.role_users",
ServerTypes.MYSQL: "permission_role_users",
ServerTypes.MYSQL: "permission_role_auth_users",
},
}

View File

@@ -1,4 +1,4 @@
from typing import Union, Type, Callable
from typing import Union, Type, Callable, Self
from cpl.core.log.logger import Logger
from cpl.core.log.logger_abc import LoggerABC
@@ -15,12 +15,17 @@ class ServiceCollection:
_modules: dict[str, Callable] = {}
@classmethod
def with_module(cls, func: Callable, name: str = None):
def with_module(cls, func: Callable, name: str = None) -> type[Self]:
cls._modules[func.__name__ if name is None else name] = func
return cls
def __init__(self):
self._service_descriptors: list[ServiceDescriptor] = []
self._loaded_modules: set[str] = set()
@property
def loaded_modules(self) -> set[str]:
return self._loaded_modules
def _add_descriptor(self, service: Union[type, object], lifetime: ServiceLifetimeEnum, base_type: Callable = None):
found = False
@@ -45,15 +50,15 @@ class ServiceCollection:
return self
def add_singleton(self, service_type: T, service: Service = None):
def add_singleton(self, service_type: T, service: Service = None) -> Self:
self._add_descriptor_by_lifetime(service_type, ServiceLifetimeEnum.singleton, service)
return self
def add_scoped(self, service_type: T, service: Service = None):
def add_scoped(self, service_type: T, service: Service = None) -> Self:
self._add_descriptor_by_lifetime(service_type, ServiceLifetimeEnum.scoped, service)
return self
def add_transient(self, service_type: T, service: Service = None):
def add_transient(self, service_type: T, service: Service = None) -> Self:
self._add_descriptor_by_lifetime(service_type, ServiceLifetimeEnum.transient, service)
return self
@@ -62,7 +67,7 @@ class ServiceCollection:
ServiceProviderABC.set_global_provider(sp)
return sp
def add_module(self, module: str | object):
def add_module(self, module: str | object) -> Self:
if not isinstance(module, str):
module = module.__name__
@@ -70,7 +75,10 @@ class ServiceCollection:
raise ValueError(f"Module {module} not found")
self._modules[module](self)
if module not in self._loaded_modules:
self._loaded_modules.add(module)
return self
def add_logging(self):
def add_logging(self) -> Self:
self.add_transient(LoggerABC, Logger)
return self

View File

@@ -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]

View File

@@ -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
@@ -24,19 +24,19 @@ class ServiceProviderABC(ABC):
return cls._provider
@classmethod
def get_global_service(cls, instance_type: T, *args, **kwargs) -> Optional[R]:
def get_global_service(cls, instance_type: Type[T], *args, **kwargs) -> Optional[T]:
if cls._provider is None:
return None
return cls._provider.get_service(instance_type, *args, **kwargs)
@classmethod
def get_global_services(cls, instance_type: T, *args, **kwargs) -> list[Optional[R]]:
def get_global_services(cls, instance_type: Type[T], *args, **kwargs) -> list[Optional[T]]:
if cls._provider is None:
return []
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[T]: ...
@abstractmethod
def _build_service(self, service_type: type, *args, **kwargs) -> object:
@@ -114,6 +114,18 @@ class ServiceProviderABC(ABC):
if f is None:
return functools.partial(cls.inject)
if iscoroutinefunction(f):
@functools.wraps(f)
async def async_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]
return await f(*args, *injection, **kwargs)
return async_inner
@functools.wraps(f)
def inner(*args, **kwargs):
if cls._provider is None:

View File

@@ -2,7 +2,6 @@ from cpl.dependency import ServiceCollection as _ServiceCollection
from .abc.email_client_abc import EMailClientABC
from .email_client import EMailClient
from .email_client_settings import EMailClientSettings
from .email_client_settings_name_enum import EMailClientSettingsNameEnum
from .email_model import EMail
from .mail_logger import MailLogger

View File

@@ -1,51 +1,17 @@
from typing import Optional
from cpl.core.configuration.configuration_model_abc import ConfigurationModelABC
class EMailClientSettings(ConfigurationModelABC):
r"""Representation of mailing settings"""
def __init__(
self,
host: str = None,
port: int = None,
user_name: str = None,
credentials: str = None,
src: Optional[dict] = None,
):
ConfigurationModelABC.__init__(self)
ConfigurationModelABC.__init__(self, src, "EMAIL")
self._host: str = host
self._port: int = port
self._user_name: str = user_name
self._credentials: str = credentials
@property
def host(self) -> str:
return self._host
@host.setter
def host(self, host: str) -> None:
self._host = host
@property
def port(self) -> int:
return self._port
@port.setter
def port(self, port: int) -> None:
self._port = port
@property
def user_name(self) -> str:
return self._user_name
@user_name.setter
def user_name(self, user_name: str) -> None:
self._user_name = user_name
@property
def credentials(self) -> str:
return self._credentials
@credentials.setter
def credentials(self, credentials: str) -> None:
self._credentials = credentials
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)

View File

@@ -1,8 +0,0 @@
from enum import Enum
class EMailClientSettingsNameEnum(Enum):
host = "Host"
port = "Port"
user_name = "UserName"
credentials = "Credentials"

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"Path": "logs/",
"Filename": "log_$start_time.log",
"ConsoleLevel": "TRACE",
"Level": "TRACE"
}
}

View File

@@ -0,0 +1,26 @@
{
"TimeFormat": {
"DateFormat": "%Y-%m-%d",
"TimeFormat": "%H:%M:%S",
"DateTimeFormat": "%Y-%m-%d %H:%M:%S.%f",
"DateTimeLogFormat": "%Y-%m-%d_%H-%M-%S"
},
"Log": {
"Path": "logs/",
"Filename": "log_$start_time.log",
"ConsoleLevel": "TRACE",
"Level": "TRACE"
},
"Database": {
"Host": "localhost",
"User": "cpl",
"Port": 3306,
"Password": "cpl",
"Database": "cpl",
"Charset": "utf8mb4",
"UseUnicode": "true",
"Buffered": "true"
}
}

View File

@@ -0,0 +1,15 @@
{
"TimeFormat": {
"DateFormat": "%Y-%m-%d",
"TimeFormat": "%H:%M:%S",
"DateTimeFormat": "%Y-%m-%d %H:%M:%S.%f",
"DateTimeLogFormat": "%Y-%m-%d_%H-%M-%S"
},
"Log": {
"Path": "logs/",
"Filename": "log_$start_time.log",
"ConsoleLevel": "ERROR",
"Level": "WARNING"
}
}

View File

@@ -0,0 +1,37 @@
from starlette.responses import JSONResponse
from cpl import api
from cpl.api.application.web_app import WebApp
from cpl.application import ApplicationBuilder
from cpl.auth.permission.permissions import Permissions
from cpl.core.configuration import Configuration
from cpl.core.environment import Environment
from service import PingService
def main():
builder = ApplicationBuilder[WebApp](WebApp)
Configuration.add_json_file(f"appsettings.json")
Configuration.add_json_file(f"appsettings.{Environment.get_environment()}.json")
Configuration.add_json_file(f"appsettings.{Environment.get_host_name()}.json", optional=True)
builder.services.add_logging()
builder.services.add_transient(PingService)
builder.services.add_module(api)
app = builder.build()
app.with_logging()
app.with_database()
app.with_authentication()
app.with_authorization()
app.with_route(path="/route1", fn=lambda r: JSONResponse("route1"), method="GET", authentication=True, permissions=[Permissions.administrator])
app.with_routes_directory("routes")
app.run()
if __name__ == "__main__":
main()

View File

View File

@@ -0,0 +1,17 @@
from urllib.request import Request
from starlette.responses import JSONResponse
from cpl.api.router import Router
from cpl.auth.permission.permissions import Permissions
from cpl.core.log import Logger
from service import PingService
@Router.authenticate()
@Router.authorize(permissions=[Permissions.administrator])
# @Router.authorize(policies=["test"])
@Router.get(f"/ping")
async def ping(r: Request, ping: PingService, logger: Logger):
logger.info(f"Ping: {ping}")
return JSONResponse(ping.ping(r))

View File

@@ -0,0 +1,4 @@
class PingService:
def ping(self, r):
return "pong"

View File

@@ -1,5 +1,5 @@
{
"ProjectSettings": {
"Project": {
"Name": "database",
"Version": {
"Major": "0",
@@ -22,7 +22,7 @@
"PythonPath": {},
"Classifiers": []
},
"BuildSettings": {
"Build": {
"ProjectType": "console",
"SourcePath": "src",
"OutputPath": "dist",

View File

@@ -1,8 +1,8 @@
{
"LoggingSettings": {
"Logging": {
"Path": "logs/",
"Filename": "log_$start_time.log",
"ConsoleLogLevel": "TRACE",
"FileLogLevel": "TRACE"
"ConsoleLevel": "TRACE",
"Level": "TRACE"
}
}

View File

@@ -1,19 +1,19 @@
{
"TimeFormatSettings": {
"TimeFormat": {
"DateFormat": "%Y-%m-%d",
"TimeFormat": "%H:%M:%S",
"DateTimeFormat": "%Y-%m-%d %H:%M:%S.%f",
"DateTimeLogFormat": "%Y-%m-%d_%H-%M-%S"
},
"LoggingSettings": {
"Log": {
"Path": "logs/",
"Filename": "log_$start_time.log",
"ConsoleLogLevel": "TRACE",
"FileLogLevel": "TRACE"
"ConsoleLevel": "TRACE",
"Level": "TRACE"
},
"DatabaseSettings": {
"Database": {
"AuthPlugin": "mysql_native_password",
"ConnectionString": "mysql+mysqlconnector://cpl:$credentials@localhost/cpl",
"Credentials": "Y3Bs",

View File

@@ -1,19 +1,19 @@
{
"TimeFormatSettings": {
"TimeFormat": {
"DateFormat": "%Y-%m-%d",
"TimeFormat": "%H:%M:%S",
"DateTimeFormat": "%Y-%m-%d %H:%M:%S.%f",
"DateTimeLogFormat": "%Y-%m-%d_%H-%M-%S"
},
"LoggingSettings": {
"Log": {
"Path": "logs/",
"Filename": "log_$start_time.log",
"ConsoleLogLevel": "TRACE",
"FileLogLevel": "TRACE"
"ConsoleLevel": "TRACE",
"Level": "TRACE"
},
"DatabaseSettings": {
"Database": {
"Host": "localhost",
"User": "cpl",
"Port": 3306,

View File

@@ -1,15 +1,15 @@
{
"TimeFormatSettings": {
"TimeFormat": {
"DateFormat": "%Y-%m-%d",
"TimeFormat": "%H:%M:%S",
"DateTimeFormat": "%Y-%m-%d %H:%M:%S.%f",
"DateTimeLogFormat": "%Y-%m-%d_%H-%M-%S"
},
"LoggingSettings": {
"Log": {
"Path": "logs/",
"Filename": "log_$start_time.log",
"ConsoleLogLevel": "ERROR",
"FileLogLevel": "WARN"
"ConsoleLevel": "ERROR",
"Level": "WARNING"
}
}

View File

@@ -1,15 +1,15 @@
{
"TimeFormatSettings": {
"TimeFormat": {
"DateFormat": "%Y-%m-%d",
"TimeFormat": "%H:%M:%S",
"DateTimeFormat": "%Y-%m-%d %H:%M:%S.%f",
"DateTimeLogFormat": "%Y-%m-%d_%H-%M-%S"
},
"LoggingSettings": {
"Logging": {
"Path": "logs/",
"Filename": "log_$start_time.log",
"ConsoleLogLevel": "ERROR",
"FileLogLevel": "WARN"
"ConsoleLevel": "ERROR",
"Level": "WARN"
}
}

View File

@@ -1,5 +1,5 @@
{
"WorkspaceSettings": {
"Workspace": {
"DefaultProject": "di",
"Projects": {
"di": "src/di/di.json"

View File

@@ -1,5 +1,5 @@
{
"ProjectSettings": {
"Project": {
"Name": "di",
"Version": {
"Major": "0",
@@ -25,7 +25,7 @@
"PythonPath": {},
"Classifiers": []
},
"BuildSettings": {
"Build": {
"ProjectType": "console",
"SourcePath": "",
"OutputPath": "../../dist",

View File

@@ -1,8 +1,8 @@
{
"LoggingSettings": {
"Logging": {
"Path": "logs/",
"Filename": "log_$start_time.log",
"ConsoleLogLevel": "TRACE",
"FileLogLevel": "TRACE"
"ConsoleLevel": "TRACE",
"Level": "TRACE"
}
}

View File

@@ -1,63 +1,20 @@
{
"TimeFormatSettings": {
"TimeFormat": {
"DateFormat": "%Y-%m-%d",
"TimeFormat": "%H:%M:%S",
"DateTimeFormat": "%Y-%m-%d %H:%M:%S.%f",
"DateTimeLogFormat": "%Y-%m-%d_%H-%M-%S"
},
"LoggingSettings": {
"Logging": {
"Path": "logs/",
"Filename": "log_$start_time.log",
"ConsoleLogLevel": "TRACE",
"FileLogLevel": "TRACE"
"ConsoleLevel": "TRACE",
"Level": "TRACE"
},
"EMailClientSettings": {
"EMailClient": {
"Host": "mail.sh-edraft.de",
"Port": "587",
"UserName": "dev-srv@sh-edraft.de",
"Credentials": "RmBOQX1eNFYiYjgsSid3fV1nelc2WA=="
},
"PublishSettings": {
"SourcePath": "../",
"DistPath": "../../dist",
"Templates": [
{
"TemplatePath": "../../publish_templates/all_template.txt",
"Name": "all",
"Description": "",
"LongDescription": "",
"CopyrightDate": "2020",
"CopyrightName": "sh-edraft.de",
"LicenseName": "MIT",
"LicenseDescription": ", see LICENSE for more details.",
"Title": "",
"Author": "Sven Heidemann",
"Version": {
"Major": 2020,
"Minor": 12,
"Micro": 9
}
},
{
"TemplatePath": "../../publish_templates/all_template.txt",
"Name": "sh_edraft",
"Description": "common python library",
"LongDescription": "Library to share common classes and models used at sh-edraft.de",
"CopyrightDate": "2020",
"CopyrightName": "sh-edraft.de",
"LicenseName": "MIT",
"LicenseDescription": ", see LICENSE for more details.",
"Title": "",
"Author": "Sven Heidemann",
"Version": {
"Major": 2020,
"Minor": 12,
"Micro": 9
}
}
],
"IncludedFiles": [],
"ExcludedFiles": [],
"TemplateEnding": "_template.txt"
}
}

View File

@@ -1,26 +1,26 @@
{
"TimeFormatSettings": {
"TimeFormat": {
"DateFormat": "%Y-%m-%d",
"TimeFormat": "%H:%M:%S",
"DateTimeFormat": "%Y-%m-%d %H:%M:%S.%f",
"DateTimeLogFormat": "%Y-%m-%d_%H-%M-%S"
},
"LoggingSettings": {
"Logging": {
"Path": "logs/",
"Filename": "log_$start_time.log",
"ConsoleLogLevel": "TRACE",
"FileLogLevel": "TRACE"
"ConsoleLevel": "TRACE",
"Level": "TRACE"
},
"EMailClientSettings": {
"EMailClient": {
"Host": "mail.sh-edraft.de",
"Port": "587",
"UserName": "dev-srv@sh-edraft.de",
"Credentials": "RmBOQX1eNFYiYjgsSid3fV1nelc2WA=="
},
"DatabaseSettings": {
"Database": {
"Host": "localhost",
"User": "sh_cpl",
"Password": "MHZhc0Y2bjhKc1VUMWV0Qw==",
@@ -31,7 +31,7 @@
"AuthPlugin": "mysql_native_password"
},
"TestSettings": {
"Test": {
"Value": 20
}
}

View File

@@ -1,15 +1,15 @@
{
"TimeFormatSettings": {
"TimeFormat": {
"DateFormat": "%Y-%m-%d",
"TimeFormat": "%H:%M:%S",
"DateTimeFormat": "%Y-%m-%d %H:%M:%S.%f",
"DateTimeLogFormat": "%Y-%m-%d_%H-%M-%S"
},
"LoggingSettings": {
"Logging": {
"Path": "logs/",
"Filename": "log_$start_time.log",
"ConsoleLogLevel": "ERROR",
"FileLogLevel": "WARN"
"ConsoleLevel": "ERROR",
"Level": "WARN"
}
}

View File

@@ -1,5 +1,5 @@
{
"ProjectSettings": {
"Project": {
"Name": "general",
"Version": {
"Major": "2021",
@@ -30,7 +30,7 @@
},
"Classifiers": []
},
"BuildSettings": {
"Build": {
"ProjectType": "console",
"SourcePath": "",
"OutputPath": "dist",

View File

@@ -1,5 +1,5 @@
{
"WorkspaceSettings": {
"Workspace": {
"DefaultProject": "translation",
"Projects": {
"translation": "src/translation/translation.json"

View File

@@ -1,16 +1,16 @@
{
"TimeFormatSettings": {
"TimeFormat": {
"DateFormat": "%Y-%m-%d",
"TimeFormat": "%H:%M:%S",
"DateTimeFormat": "%Y-%m-%d %H:%M:%S.%f",
"DateTimeLogFormat": "%Y-%m-%d_%H-%M-%S"
},
"LoggingSettings": {
"Logging": {
"Path": "logs/",
"Filename": "log_$start_time.log",
"ConsoleLogLevel": "ERROR",
"FileLogLevel": "WARN"
"ConsoleLevel": "ERROR",
"Level": "WARN"
},
"Translation": {

View File

@@ -1,5 +1,5 @@
{
"ProjectSettings": {
"Project": {
"Name": "translation",
"Version": {
"Major": "0",
@@ -25,7 +25,7 @@
"PythonPath": {},
"Classifiers": []
},
"BuildSettings": {
"Build": {
"ProjectType": "console",
"SourcePath": "",
"OutputPath": "../../dist",

View File

@@ -1,5 +1,5 @@
{
"ProjectSettings": {
"Project": {
"Name": "unittests",
"Version": {
"Major": "2024",
@@ -23,7 +23,7 @@
"Classifiers": [],
"DevDependencies": []
},
"BuildSettings": {
"Build": {
"ProjectType": "unittest",
"SourcePath": "",
"OutputPath": "../../dist",

View File

@@ -1,5 +1,5 @@
{
"ProjectSettings": {
"Project": {
"Name": "unittest_cli",
"Version": {
"Major": "2024",
@@ -24,7 +24,7 @@
"Classifiers": [],
"DevDependencies": []
},
"BuildSettings": {
"Build": {
"ProjectType": "library",
"SourcePath": "",
"OutputPath": "../../dist",

View File

@@ -1,17 +1,17 @@
{
"TimeFormatSettings": {
"TimeFormat": {
"DateFormat": "%Y-%m-%d",
"TimeFormat": "%H:%M:%S",
"DateTimeFormat": "%Y-%m-%d %H:%M:%S.%f",
"DateTimeLogFormat": "%Y-%m-%d_%H-%M-%S"
},
"LoggingSettings": {
"Logging": {
"Path": "logs/$date_now/",
"Filename": "bot.log",
"ConsoleLogLevel": "TRACE",
"FileLogLevel": "TRACE"
"ConsoleLevel": "TRACE",
"Level": "TRACE"
},
"DatabaseSettings": {
"Database": {
"Host": "localhost",
"User": "local",
"Password": "bG9jYWw=",

View File

@@ -1,5 +1,5 @@
{
"ProjectSettings": {
"Project": {
"Name": "unittest_core",
"Version": {
"Major": "2024",
@@ -23,7 +23,7 @@
"Classifiers": [],
"DevDependencies": []
},
"BuildSettings": {
"Build": {
"ProjectType": "library",
"SourcePath": "",
"OutputPath": "../../dist",

View File

@@ -1,5 +1,5 @@
{
"ProjectSettings": {
"Project": {
"Name": "unittest_query",
"Version": {
"Major": "2024",
@@ -24,7 +24,7 @@
"Classifiers": [],
"DevDependencies": []
},
"BuildSettings": {
"Build": {
"ProjectType": "library",
"SourcePath": "",
"OutputPath": "../../dist",

View File

@@ -1,5 +1,5 @@
{
"ProjectSettings": {
"Project": {
"Name": "unittest_shared",
"Version": {
"Major": "2024",
@@ -23,7 +23,7 @@
"Classifiers": [],
"DevDependencies": []
},
"BuildSettings": {
"Build": {
"ProjectType": "library",
"SourcePath": "",
"OutputPath": "../../dist",

View File

@@ -1,5 +1,5 @@
{
"ProjectSettings": {
"Project": {
"Name": "unittests_translation",
"Version": {
"Major": "2024",
@@ -26,7 +26,7 @@
"PythonPath": {},
"Classifiers": []
},
"BuildSettings": {
"Build": {
"ProjectType": "unittest",
"SourcePath": "",
"OutputPath": "../../dist",