Added gql base #181
Some checks failed
Test before pr merge / test-lint (pull_request) Failing after 7s

This commit is contained in:
2025-09-26 21:56:21 +02:00
parent e0f6e1c241
commit ed8a00999a
33 changed files with 500 additions and 30 deletions

View File

@@ -3,7 +3,7 @@ from starlette.responses import JSONResponse
from cpl.api.api_module import ApiModule from cpl.api.api_module import ApiModule
from cpl.api.application.web_app import WebApp from cpl.api.application.web_app import WebApp
from cpl.application.application_builder import ApplicationBuilder from cpl.application.application_builder import ApplicationBuilder
from cpl.auth import AuthModule from cpl.graphql.application.graphql_app import GraphQLApp
from cpl.auth.permission.permissions import Permissions from cpl.auth.permission.permissions import Permissions
from cpl.auth.schema import AuthUser, Role from cpl.auth.schema import AuthUser, Role
from cpl.core.configuration import Configuration from cpl.core.configuration import Configuration
@@ -11,12 +11,15 @@ from cpl.core.console import Console
from cpl.core.environment import Environment from cpl.core.environment import Environment
from cpl.core.utils.cache import Cache from cpl.core.utils.cache import Cache
from cpl.database.mysql.mysql_module import MySQLModule from cpl.database.mysql.mysql_module import MySQLModule
from cpl.graphql.graphql_module import GraphQLModule
from cpl.graphql.schema.root_query import RootQuery
from queries.hello import HelloQuery
from scoped_service import ScopedService from scoped_service import ScopedService
from service import PingService from service import PingService
def main(): def main():
builder = ApplicationBuilder[WebApp](WebApp) builder = ApplicationBuilder[GraphQLApp](GraphQLApp)
Configuration.add_json_file(f"appsettings.json") 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_environment()}.json")
@@ -27,12 +30,15 @@ def main():
builder.services.add_transient(PingService) builder.services.add_transient(PingService)
builder.services.add_module(MySQLModule) builder.services.add_module(MySQLModule)
builder.services.add_module(ApiModule) builder.services.add_module(ApiModule)
builder.services.add_module(GraphQLModule)
builder.services.add_scoped(ScopedService) builder.services.add_scoped(ScopedService)
builder.services.add_cache(AuthUser) builder.services.add_cache(AuthUser)
builder.services.add_cache(Role) builder.services.add_cache(Role)
builder.services.add_transient(HelloQuery)
app = builder.build() app = builder.build()
app.with_logging() app.with_logging()
@@ -48,6 +54,13 @@ def main():
) )
app.with_routes_directory("routes") app.with_routes_directory("routes")
schema = app.with_graphql()
schema.query.string_field("ping", resolver=lambda *_: "pong")
schema.query.with_query("hello", HelloQuery)
app.with_playground()
app.with_graphiql()
provider = builder.service_provider provider = builder.service_provider
user_cache = provider.get_service(Cache[AuthUser]) user_cache = provider.get_service(Cache[AuthUser])
role_cache = provider.get_service(Cache[Role]) role_cache = provider.get_service(Cache[Role])

View File

View File

@@ -0,0 +1,13 @@
import graphene
from cpl.graphql.schema.query import Query
class HelloQuery(Query):
def __init__(self):
Query.__init__(self)
self.string_field(
"message",
args={"name": graphene.String(default_value="world")},
resolver=lambda *_, name: f"Hello {name}",
)

View File

@@ -0,0 +1,45 @@
from abc import ABC
from enum import Enum
from typing import Self
from starlette.applications import Starlette
from cpl.api.model.api_route import ApiRoute
from cpl.api.model.validation_match import ValidationMatch
from cpl.api.typing import HTTPMethods, PartialMiddleware, TEndpoint, PolicyInput
from cpl.application.abc.application_abc import ApplicationABC
from cpl.dependency.service_provider import ServiceProvider
from cpl.dependency.typing import Modules
class WebAppABC(ApplicationABC, ABC):
def __init__(self, services: ServiceProvider, modules: Modules, required_modules: list[str | object] = None):
ApplicationABC.__init__(self, services, modules, required_modules)
def with_routes_directory(self, directory: str) -> Self: ...
def with_app(self, app: Starlette) -> 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: ...
def with_route(
self,
path: str,
fn: TEndpoint,
method: HTTPMethods,
authentication: bool = False,
roles: list[str | Enum] = None,
permissions: list[str | Enum] = None,
policies: list[str] = None,
match: ValidationMatch = None,
) -> Self: ...
def with_middleware(self, middleware: PartialMiddleware) -> Self: ...
def with_authentication(self) -> Self: ...
def with_authorization(self, *policies: list[PolicyInput] | PolicyInput) -> Self: ...

View File

@@ -1,6 +1,6 @@
import os import os
from enum import Enum from enum import Enum
from typing import Mapping, Any, Callable, Self, Union from typing import Mapping, Any, Self
import uvicorn import uvicorn
from starlette.applications import Starlette from starlette.applications import Starlette
@@ -10,6 +10,7 @@ from starlette.requests import Request
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
from starlette.types import ExceptionHandler from starlette.types import ExceptionHandler
from cpl.api.abc.web_app_abc import WebAppABC
from cpl.api.api_module import ApiModule from cpl.api.api_module import ApiModule
from cpl.api.error import APIError from cpl.api.error import APIError
from cpl.api.logger import APILogger from cpl.api.logger import APILogger
@@ -24,8 +25,7 @@ from cpl.api.registry.policy import PolicyRegistry
from cpl.api.registry.route import RouteRegistry from cpl.api.registry.route import RouteRegistry
from cpl.api.router import Router from cpl.api.router import Router
from cpl.api.settings import ApiSettings from cpl.api.settings import ApiSettings
from cpl.api.typing import HTTPMethods, PartialMiddleware, PolicyResolver from cpl.api.typing import HTTPMethods, PartialMiddleware, TEndpoint, PolicyInput
from cpl.application.abc.application_abc import ApplicationABC
from cpl.auth.auth_module import AuthModule from cpl.auth.auth_module import AuthModule
from cpl.auth.permission.permission_module import PermissionsModule from cpl.auth.permission.permission_module import PermissionsModule
from cpl.core.configuration.configuration import Configuration from cpl.core.configuration.configuration import Configuration
@@ -33,12 +33,10 @@ from cpl.dependency.inject import inject
from cpl.dependency.service_provider import ServiceProvider from cpl.dependency.service_provider import ServiceProvider
from cpl.dependency.typing import Modules from cpl.dependency.typing import Modules
PolicyInput = Union[dict[str, PolicyResolver], Policy]
class WebApp(WebAppABC):
class WebApp(ApplicationABC): def __init__(self, services: ServiceProvider, modules: Modules, required_modules: list[str | object] = None):
def __init__(self, services: ServiceProvider, modules: Modules): WebAppABC.__init__(self, services, modules, [AuthModule, PermissionsModule, ApiModule] + (required_modules or []))
super().__init__(services, modules, [AuthModule, PermissionsModule, ApiModule])
self._app: Starlette | None = None self._app: Starlette | None = None
self._logger = services.get_service(APILogger) self._logger = services.get_service(APILogger)
@@ -78,16 +76,17 @@ class WebApp(ApplicationABC):
self._logger.debug(f"Allowed origins: {origins}") self._logger.debug(f"Allowed origins: {origins}")
return origins.split(",") return origins.split(",")
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): def _check_for_app(self):
if self._app is not None: if self._app is not None:
raise ValueError("App is already set, cannot add routes or middleware") raise ValueError("App is already set, cannot add routes or middleware")
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:
self._logger.fatal(f"Authorization policy '{policy_name}' not found")
def with_routes_directory(self, directory: str) -> Self: def with_routes_directory(self, directory: str) -> Self:
self._check_for_app() self._check_for_app()
assert directory is not None, "directory must not be None" assert directory is not None, "directory must not be None"
@@ -102,6 +101,12 @@ class WebApp(ApplicationABC):
return self 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 with_routes( def with_routes(
self, self,
routes: list[ApiRoute], routes: list[ApiRoute],
@@ -131,7 +136,7 @@ class WebApp(ApplicationABC):
def with_route( def with_route(
self, self,
path: str, path: str,
fn: Callable[[Request], Any], fn: TEndpoint,
method: HTTPMethods, method: HTTPMethods,
authentication: bool = False, authentication: bool = False,
roles: list[str | Enum] = None, roles: list[str | Enum] = None,
@@ -179,6 +184,7 @@ class WebApp(ApplicationABC):
return self return self
def with_authorization(self, *policies: list[PolicyInput] | PolicyInput) -> Self: def with_authorization(self, *policies: list[PolicyInput] | PolicyInput) -> Self:
self._check_for_app()
if policies: if policies:
_policies = [] _policies = []
@@ -206,13 +212,6 @@ class WebApp(ApplicationABC):
self.with_middleware(AuthorizationMiddleware) self.with_middleware(AuthorizationMiddleware)
return self 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:
self._logger.fatal(f"Authorization policy '{policy_name}' not found")
async def main(self): async def main(self):
self._logger.debug(f"Preparing API") self._logger.debug(f"Preparing API")
self._validate_policies() self._validate_policies()

View File

@@ -8,7 +8,7 @@ class APIError(HTTPException):
status_code = 500 status_code = 500
def __init__(self, message: str = ""): def __init__(self, message: str = ""):
super().__init__(self.status_code, message) HTTPException.__init__(self, self.status_code, message)
self._message = message self._message = message
@property @property

View File

@@ -6,7 +6,7 @@ from cpl.core.configuration import ConfigurationModelABC
class ApiSettings(ConfigurationModelABC): class ApiSettings(ConfigurationModelABC):
def __init__(self, src: Optional[dict] = None): def __init__(self, src: Optional[dict] = None):
super().__init__(src) ConfigurationModelABC.__init__(self, src)
self.option("host", str, "0.0.0.0") self.option("host", str, "0.0.0.0")
self.option("port", int, 5000) self.option("port", int, 5000)

View File

@@ -2,6 +2,7 @@ from typing import Union, Literal, Callable, Type, Awaitable
from urllib.request import Request from urllib.request import Request
from starlette.middleware import Middleware from starlette.middleware import Middleware
from starlette.responses import Response
from starlette.types import ASGIApp from starlette.types import ASGIApp
from starlette.websockets import WebSocket from starlette.websockets import WebSocket
@@ -9,6 +10,7 @@ from cpl.api.abc.asgi_middleware_abc import ASGIMiddleware
from cpl.auth.schema import AuthUser from cpl.auth.schema import AuthUser
TRequest = Union[Request, WebSocket] TRequest = Union[Request, WebSocket]
TEndpoint = Callable[[TRequest, ...], Awaitable[Response]] | Callable[[TRequest, ...], Response]
HTTPMethods = Literal["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] HTTPMethods = Literal["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
PartialMiddleware = Union[ PartialMiddleware = Union[
ASGIMiddleware, ASGIMiddleware,
@@ -17,3 +19,4 @@ PartialMiddleware = Union[
Callable[[ASGIApp], ASGIApp], Callable[[ASGIApp], ASGIApp],
] ]
PolicyResolver = Callable[[AuthUser], bool | Awaitable[bool]] PolicyResolver = Callable[[AuthUser], bool | Awaitable[bool]]
PolicyInput = Union[dict[str, PolicyResolver], "Policy"]

View File

@@ -56,7 +56,7 @@ class ApplicationABC(ABC):
module_dependency_error( module_dependency_error(
type(self).__name__, type(self).__name__,
module.__name__, module.__name__ if not isinstance(module, str) else module,
ImportError( ImportError(
f"Required module '{module}' for application '{self.__class__.__name__}' is not loaded. Load using 'add_module({module})' method." f"Required module '{module}' for application '{self.__class__.__name__}' is not loaded. Load using 'add_module({module})' method."
), ),

View File

@@ -10,7 +10,7 @@ from cpl.auth.permission.permissions import Permissions
from cpl.core.typing import SerialId from cpl.core.typing import SerialId
from cpl.database.abc import DbModelABC from cpl.database.abc import DbModelABC
from cpl.database.logger import DBLogger from cpl.database.logger import DBLogger
from cpl.dependency import ServiceProvider from cpl.dependency import get_provider
class AuthUser(DbModelABC): class AuthUser(DbModelABC):
@@ -87,3 +87,4 @@ class AuthUser(DbModelABC):
self._keycloak_id = str(uuid.UUID(int=0)) self._keycloak_id = str(uuid.UUID(int=0))
await auth_user_dao.update(self) await auth_user_dao.update(self)

View File

@@ -8,7 +8,7 @@ class ModuleABC(ABC):
__OPTIONAL_VARS = ["dependencies", "configuration", "singleton", "scoped", "transient", "hosted"] __OPTIONAL_VARS = ["dependencies", "configuration", "singleton", "scoped", "transient", "hosted"]
def __init_subclass__(cls): def __init_subclass__(cls):
super().__init_subclass__() ABC.__init_subclass__()
if f"{cls.__module__}.{cls.__name__}" == "cpl.dependency.module.module.Module": if f"{cls.__module__}.{cls.__name__}" == "cpl.dependency.module.module.Module":
return return

View File

View File

@@ -0,0 +1,37 @@
from starlette.responses import HTMLResponse
async def graphiql_endpoint(request):
return HTMLResponse("""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>GraphiQL</title>
<link href="https://unpkg.com/graphiql@2.4.0/graphiql.min.css" rel="stylesheet" />
</head>
<body style="margin:0;overflow:hidden;">
<div id="graphiql" style="height:100vh;"></div>
<!-- React + ReactDOM -->
<script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
<!-- GraphiQL -->
<script src="https://unpkg.com/graphiql@2.4.0/graphiql.min.js"></script>
<script>
const graphQLFetcher = graphQLParams =>
fetch('/api/graphql', {
method: 'post',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(graphQLParams),
}).then(response => response.json()).catch(() => response.text());
ReactDOM.render(
React.createElement(GraphiQL, { fetcher: graphQLFetcher }),
document.getElementById('graphiql'),
);
</script>
</body>
</html>
""")

View File

@@ -0,0 +1,13 @@
from starlette.requests import Request
from starlette.responses import Response, JSONResponse
from cpl.graphql.service.service import GraphQLService
async def graphql_endpoint(request: Request, service: GraphQLService) -> Response:
body = await request.json()
query = body.get("query")
variables = body.get("variables")
response_data = await service.execute(query, variables, request)
return JSONResponse(response_data)

View File

@@ -0,0 +1,27 @@
from starlette.requests import Request
from starlette.responses import Response, HTMLResponse
async def playground_endpoint(request: Request) -> Response:
return HTMLResponse("""
<!DOCTYPE html>
<html>
<head>
<meta charset=utf-8/>
<title>GraphQL Playground</title>
<link rel="stylesheet" href="https://unpkg.com/graphql-playground-react/build/static/css/index.css" />
<link rel="shortcut icon" href="https://raw.githubusercontent.com/graphql/graphql-playground/master/packages/graphql-playground-react/public/favicon.png" />
<script src="https://unpkg.com/graphql-playground-react/build/static/js/middleware.js"></script>
</head>
<body>
<div id="root"/>
<script>
window.addEventListener('load', function () {
GraphQLPlayground.init(document.getElementById('root'), {
endpoint: '/api/graphql'
})
})
</script>
</body>
</html>
""")

View File

@@ -0,0 +1,54 @@
from typing import Callable, Any, Type
from graphene import ObjectType
class QueryBase(ObjectType):
def __init__(self):
from cpl.graphql.schema.field import Field
ObjectType.__init__(self)
self._fields: dict[str, Field] = {}
def get_fields(self) -> dict[str, "Field"]:
return self._fields
def field(
self,
name: str,
t: type,
args: dict[str, Any] | None = None,
resolver: Callable | None = None,
):
gql_type_map: dict[object, str] = {
str: "String",
int: "Int",
float: "Float",
bool: "Boolean",
}
if t not in gql_type_map:
raise ValueError(f"Unsupported field type: {t}")
from cpl.graphql.schema.field import Field
self._fields[name] = Field(name, "String", resolver, args)
def with_query(self, name: str, subquery: Type["QueryBase"]):
from cpl.graphql.schema.field import Field
f = Field(name=name, gql_type="Object", resolver=lambda root, info, **kwargs: {}, subquery=subquery)
self._fields[name] = f
def string_field(self, name: str, args: dict[str, Any] | None = None, resolver: Callable | None = None):
self.field(name, str, args, resolver)
def int_field(self, name: str, args: dict[str, Any] | None = None, resolver: Callable | None = None):
self.field(name, int, args, resolver)
def float_field(self, name: str, args: dict[str, Any] | None = None, resolver: Callable | None = None):
self.field(name, float, args, resolver)
def bool_field(self, name: str, args: dict[str, Any] | None = None, resolver: Callable | None = None):
self.field(name, bool, args, resolver)

View File

@@ -0,0 +1 @@
from .graphql_app import WebApp

View File

@@ -0,0 +1,80 @@
from enum import Enum
from typing import Self
from cpl.api.application import WebApp
from cpl.api.model.validation_match import ValidationMatch
from cpl.dependency.service_provider import ServiceProvider
from cpl.dependency.typing import Modules
from .._endpoints.graphiql import graphiql_endpoint
from .._endpoints.graphql import graphql_endpoint
from .._endpoints.playground import playground_endpoint
from ..graphql_module import GraphQLModule
from ..service.schema import Schema
class GraphQLApp(WebApp):
def __init__(self, services: ServiceProvider, modules: Modules):
WebApp.__init__(self, services, modules, [GraphQLModule])
def with_graphql(
self,
authentication: bool = False,
roles: list[str | Enum] = None,
permissions: list[str | Enum] = None,
policies: list[str] = None,
match: ValidationMatch = None,
) -> Schema:
self.with_route(
path="/api/graphql",
fn=graphql_endpoint,
method="POST",
authentication=authentication,
roles=roles,
permissions=permissions,
policies=policies,
match=match,
)
schema = self._services.get_service(Schema)
if schema is None:
self._logger.fatal("Could not resolve RootQuery. Make sure GraphQLModule is registered.")
return schema
def with_graphiql(
self,
authentication: bool = False,
roles: list[str | Enum] = None,
permissions: list[str | Enum] = None,
policies: list[str] = None,
match: ValidationMatch = None,
) -> Self:
self.with_route(
path="/api/graphiql",
fn=graphiql_endpoint,
method="GET",
authentication=authentication,
roles=roles,
permissions=permissions,
policies=policies,
match=match,
)
return self
def with_playground(
self,
authentication: bool = False,
roles: list[str | Enum] = None,
permissions: list[str | Enum] = None,
policies: list[str] = None,
match: ValidationMatch = None,
) -> Self:
self.with_route(
path="/api/playground",
fn=playground_endpoint,
method="GET",
authentication=authentication,
roles=roles,
permissions=permissions,
policies=policies,
match=match,
)
return self

View File

@@ -0,0 +1,17 @@
from cpl.api import ApiModule
from cpl.dependency.module.module import Module
from cpl.dependency.service_provider import ServiceProvider
from cpl.graphql.schema.root_query import RootQuery
from cpl.graphql.service.schema import Schema
from cpl.graphql.service.service import GraphQLService
class GraphQLModule(Module):
dependencies = [ApiModule]
singleton = [Schema, RootQuery]
scoped = [GraphQLService]
@staticmethod
def configure(services: ServiceProvider) -> None:
schema = services.get_service(Schema)
schema.build()

View File

@@ -0,0 +1,30 @@
from cpl.graphql.abc.query_base import QueryBase
class Field:
def __init__(self, name: str, gql_type: str, resolver: callable, args: dict | None = None, subquery: QueryBase | None = None):
self._name = name
self._gql_type = gql_type
self._resolver = resolver
self._args = args or {}
self._subquery: QueryBase | None = subquery
@property
def name(self) -> str:
return self._name
@property
def type(self) -> str:
return self._gql_type
@property
def resolver(self) -> callable:
return self._resolver
@property
def args(self) -> dict:
return self._args
@property
def subquery(self) -> QueryBase | None:
return self._subquery

View File

@@ -0,0 +1,6 @@
from cpl.graphql.abc.query_base import QueryBase
class Query(QueryBase):
def __init__(self):
QueryBase.__init__(self)

View File

@@ -0,0 +1,6 @@
from cpl.graphql.schema.query import Query
class RootQuery(Query):
def __init__(self):
Query.__init__(self)

View File

@@ -0,0 +1,56 @@
import graphene
from cpl.api import APILogger
from cpl.dependency.service_provider import ServiceProvider
from cpl.graphql.schema.query import Query
from cpl.graphql.schema.root_query import RootQuery
class Schema:
def __init__(self, logger: APILogger, query: RootQuery, provider: ServiceProvider):
self._logger = logger
self._provider = provider
self._query = query
self._schema = None
@property
def schema(self) -> graphene.Schema | None:
return self._schema
@property
def query(self) -> RootQuery:
return self._query
def build(self) -> graphene.Schema:
self._schema = graphene.Schema(
query=self.to_graphene(self._query),
mutation=None,
subscription=None,
)
return self._schema
def to_graphene(self, query: Query, name: str | None = None):
assert query is not None, "Query cannot be None"
attrs = {}
for field in query.get_fields().values():
if field.type == "String":
attrs[field.name] = graphene.Field(
graphene.String,
**field.args,
resolver=field.resolver
)
elif field.type == "Object" and field.subquery is not None:
subquery = self._provider.get_service(field.subquery)
sub = self.to_graphene(subquery, name=field.name.capitalize())
attrs[field.name] = graphene.Field(
sub,
**field.args,
resolver=field.resolver
)
class_name = name or query.__class__.__name__
return type(class_name, (graphene.ObjectType,), attrs)

View File

@@ -0,0 +1,31 @@
from typing import Any, Dict, Optional
from cpl.api.typing import TRequest
from cpl.graphql.service.schema import Schema
class GraphQLService:
def __init__(self, schema: Schema):
self._schema = schema.schema
if self._schema is None:
raise ValueError("Schema has not been built. Call schema.build() before using the service.")
async def execute(
self,
query: str,
variables: Optional[Dict[str, Any]],
request: TRequest,
) -> Dict[str, Any]:
result = await self._schema.execute_async(
query,
variable_values=variables,
context_value={"request": request},
)
response_data: Dict[str, Any] = {}
if result.errors:
response_data["errors"] = [str(e) for e in result.errors]
if result.data:
response_data["data"] = result.data
return response_data

View File

@@ -0,0 +1,5 @@
from typing import Type
from cpl.graphql.schema.query import Query
TQuery = Type[Query]

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-database"
version = "2024.7.0"
description = "CPL database"
readme ="CPL database package"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [
{ name = "Sven Heidemann", email = "sven.heidemann@sh-edraft.de" }
]
keywords = ["cpl", "database", "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,2 @@
cpl-api
graphene==3.4.3

View File

@@ -6,7 +6,7 @@ from cpl.query.typing import K
class OrderedEnumerable(Enumerable[T]): class OrderedEnumerable(Enumerable[T]):
def __init__(self, source, key_selectors: List[tuple[Callable[[T], K], bool]]): def __init__(self, source, key_selectors: List[tuple[Callable[[T], K], bool]]):
super().__init__(source) Enumerable.__init__(self, source)
self._key_selectors = key_selectors self._key_selectors = key_selectors
def __iter__(self) -> Iterator[T]: def __iter__(self) -> Iterator[T]: