muffin-rest 7.3.5__py3-none-any.whl → 8.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- muffin_rest/api.py +4 -4
- muffin_rest/errors.py +2 -2
- muffin_rest/filters.py +5 -5
- muffin_rest/handler.py +25 -24
- muffin_rest/limits.py +73 -0
- muffin_rest/marshmallow.py +6 -2
- muffin_rest/mongo/__init__.py +9 -9
- muffin_rest/mongo/filters.py +3 -3
- muffin_rest/mongo/types.py +2 -2
- muffin_rest/mongo/utils.py +9 -6
- muffin_rest/openapi.py +10 -10
- muffin_rest/options.py +34 -8
- muffin_rest/peewee/filters.py +4 -4
- muffin_rest/peewee/handler.py +6 -6
- muffin_rest/peewee/openapi.py +2 -2
- muffin_rest/peewee/options.py +5 -7
- muffin_rest/peewee/sorting.py +2 -2
- muffin_rest/schemas.py +3 -1
- muffin_rest/sorting.py +5 -17
- muffin_rest/sqlalchemy/__init__.py +12 -13
- muffin_rest/sqlalchemy/filters.py +5 -5
- muffin_rest/sqlalchemy/types.py +2 -2
- muffin_rest/types.py +3 -6
- muffin_rest/utils.py +3 -3
- {muffin_rest-7.3.5.dist-info → muffin_rest-8.1.0.dist-info}/METADATA +4 -5
- muffin_rest-8.1.0.dist-info/RECORD +39 -0
- muffin_rest-7.3.5.dist-info/RECORD +0 -38
- {muffin_rest-7.3.5.dist-info → muffin_rest-8.1.0.dist-info}/LICENSE +0 -0
- {muffin_rest-7.3.5.dist-info → muffin_rest-8.1.0.dist-info}/WHEEL +0 -0
muffin_rest/api.py
CHANGED
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import dataclasses as dc
|
|
6
6
|
from pathlib import Path
|
|
7
|
-
from typing import TYPE_CHECKING, Any, Callable,
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Callable, Optional, Union, overload
|
|
8
8
|
|
|
9
9
|
from http_router import Router
|
|
10
10
|
from muffin.utils import TV, to_awaitable
|
|
@@ -31,7 +31,7 @@ class API:
|
|
|
31
31
|
prefix: str = "",
|
|
32
32
|
*,
|
|
33
33
|
openapi: bool = True,
|
|
34
|
-
servers: Optional[
|
|
34
|
+
servers: Optional[list] = None,
|
|
35
35
|
**openapi_info,
|
|
36
36
|
):
|
|
37
37
|
"""Post initialize the API if we have an application already."""
|
|
@@ -39,7 +39,7 @@ class API:
|
|
|
39
39
|
self.prefix = prefix
|
|
40
40
|
|
|
41
41
|
self.openapi = openapi
|
|
42
|
-
self.openapi_options:
|
|
42
|
+
self.openapi_options: dict[str, Any] = {"info": openapi_info}
|
|
43
43
|
if servers:
|
|
44
44
|
self.openapi_options["servers"] = servers
|
|
45
45
|
|
|
@@ -67,7 +67,7 @@ class API:
|
|
|
67
67
|
*,
|
|
68
68
|
prefix: str = "",
|
|
69
69
|
openapi: Optional[bool] = None,
|
|
70
|
-
servers: Optional[
|
|
70
|
+
servers: Optional[list] = None,
|
|
71
71
|
**openapi_info,
|
|
72
72
|
):
|
|
73
73
|
"""Initialize the API."""
|
muffin_rest/errors.py
CHANGED
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
import json
|
|
5
5
|
from http import HTTPStatus
|
|
6
|
-
from typing import TYPE_CHECKING,
|
|
6
|
+
from typing import TYPE_CHECKING, Optional
|
|
7
7
|
|
|
8
8
|
from muffin import ResponseError
|
|
9
9
|
|
|
@@ -24,7 +24,7 @@ class APIError(ResponseError):
|
|
|
24
24
|
"""Create JSON with errors information."""
|
|
25
25
|
response = {"error": True, "message": HTTPStatus(status_code).description}
|
|
26
26
|
|
|
27
|
-
if isinstance(content,
|
|
27
|
+
if isinstance(content, dict):
|
|
28
28
|
response = content
|
|
29
29
|
|
|
30
30
|
elif content is not None:
|
muffin_rest/filters.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import operator
|
|
5
|
-
from typing import TYPE_CHECKING, Any, Callable,
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Iterable, Mapping, Optional # py39
|
|
6
6
|
|
|
7
7
|
import marshmallow as ma
|
|
8
8
|
from asgi_tools._compat import json_loads # type: ignore[]
|
|
@@ -20,7 +20,7 @@ FILTERS_PARAM = "where"
|
|
|
20
20
|
class Filter(Mutate):
|
|
21
21
|
"""Base filter class."""
|
|
22
22
|
|
|
23
|
-
operators:
|
|
23
|
+
operators: ClassVar[dict[str, Callable]] = {
|
|
24
24
|
"$lt": operator.lt,
|
|
25
25
|
"$le": operator.le,
|
|
26
26
|
"$gt": operator.gt,
|
|
@@ -87,7 +87,7 @@ class Filter(Mutate):
|
|
|
87
87
|
|
|
88
88
|
return ops, collection
|
|
89
89
|
|
|
90
|
-
async def filter(self, collection, *ops:
|
|
90
|
+
async def filter(self, collection, *ops: tuple[Callable, Any], **_) -> Any:
|
|
91
91
|
"""Apply the filter to collection."""
|
|
92
92
|
|
|
93
93
|
def validator(obj):
|
|
@@ -129,7 +129,7 @@ class Filters(Mutator):
|
|
|
129
129
|
|
|
130
130
|
async def apply(
|
|
131
131
|
self, request: Request, collection: TVCollection
|
|
132
|
-
) ->
|
|
132
|
+
) -> tuple[TVCollection, dict[str, Any]]:
|
|
133
133
|
"""Filter the given collection."""
|
|
134
134
|
raw_data = request.url.query.get(FILTERS_PARAM)
|
|
135
135
|
filters = {}
|
|
@@ -161,7 +161,7 @@ class Filters(Mutator):
|
|
|
161
161
|
return self.MUTATE_CLASS(obj, field=field, schema_field=schema_field, **meta)
|
|
162
162
|
|
|
163
163
|
@property
|
|
164
|
-
def openapi(self) ->
|
|
164
|
+
def openapi(self) -> dict:
|
|
165
165
|
"""Prepare OpenAPI params."""
|
|
166
166
|
return {
|
|
167
167
|
"name": FILTERS_PARAM,
|
muffin_rest/handler.py
CHANGED
|
@@ -1,19 +1,14 @@
|
|
|
1
1
|
"""Base class for API REST Handlers."""
|
|
2
|
-
|
|
3
2
|
import abc
|
|
4
3
|
import inspect
|
|
5
4
|
from typing import (
|
|
6
5
|
Any,
|
|
7
|
-
Dict,
|
|
8
6
|
Generator,
|
|
9
7
|
Generic,
|
|
10
8
|
Iterable,
|
|
11
|
-
List,
|
|
12
9
|
Literal,
|
|
13
10
|
Optional,
|
|
14
11
|
Sequence,
|
|
15
|
-
Tuple,
|
|
16
|
-
Type,
|
|
17
12
|
Union,
|
|
18
13
|
cast,
|
|
19
14
|
overload,
|
|
@@ -42,7 +37,7 @@ class RESTHandlerMeta(HandlerMeta):
|
|
|
42
37
|
|
|
43
38
|
def __new__(mcs, name, bases, params):
|
|
44
39
|
"""Prepare options for the handler."""
|
|
45
|
-
kls = cast(
|
|
40
|
+
kls = cast(type["RESTBase"], super().__new__(mcs, name, bases, params))
|
|
46
41
|
kls.meta = kls.meta_class(kls)
|
|
47
42
|
|
|
48
43
|
if getattr(kls.meta, kls.meta_class.base_property, None) is not None:
|
|
@@ -60,22 +55,23 @@ class RESTBase(Generic[TVResource], Handler, metaclass=RESTHandlerMeta):
|
|
|
60
55
|
resource: Any
|
|
61
56
|
|
|
62
57
|
meta: RESTOptions
|
|
63
|
-
meta_class:
|
|
58
|
+
meta_class: type[RESTOptions] = RESTOptions
|
|
64
59
|
_api: Optional[API] = None
|
|
65
|
-
|
|
66
|
-
|
|
60
|
+
|
|
61
|
+
filters: Optional[dict[str, Any]] = None
|
|
62
|
+
sorting: Optional[dict[str, Any]] = None
|
|
67
63
|
|
|
68
64
|
class Meta:
|
|
69
65
|
"""Tune the handler."""
|
|
70
66
|
|
|
71
67
|
# Resource filters
|
|
72
|
-
filters: Sequence[Union[str,
|
|
68
|
+
filters: Sequence[Union[str, tuple[str, str], Filter]] = ()
|
|
73
69
|
|
|
74
70
|
# Define allowed resource sorting params
|
|
75
|
-
sorting: Sequence[Union[str,
|
|
71
|
+
sorting: Sequence[Union[str, tuple[str, dict], Sort]] = ()
|
|
76
72
|
|
|
77
73
|
# Serialize/Deserialize Schema class
|
|
78
|
-
Schema: Optional[
|
|
74
|
+
Schema: Optional[type[ma.Schema]] = None
|
|
79
75
|
|
|
80
76
|
@classmethod
|
|
81
77
|
def __route__(cls, router, *paths, **params):
|
|
@@ -87,10 +83,7 @@ class RESTBase(Generic[TVResource], Handler, metaclass=RESTHandlerMeta):
|
|
|
87
83
|
else:
|
|
88
84
|
router.bind(cls, f"/{ cls.meta.name }", methods=methods, **params)
|
|
89
85
|
router.bind(
|
|
90
|
-
cls,
|
|
91
|
-
f"/{ cls.meta.name }/{{{ cls.meta.name_id }}}",
|
|
92
|
-
methods=methods,
|
|
93
|
-
**params,
|
|
86
|
+
cls, f"/{ cls.meta.name }/{{{ cls.meta.name_id }}}", methods=methods, **params
|
|
94
87
|
)
|
|
95
88
|
|
|
96
89
|
for _, method in inspect.getmembers(cls, lambda m: hasattr(m, "__route__")):
|
|
@@ -102,14 +95,17 @@ class RESTBase(Generic[TVResource], Handler, metaclass=RESTHandlerMeta):
|
|
|
102
95
|
async def __call__(self, request: Request, *, method_name: Optional[str] = None, **_) -> Any:
|
|
103
96
|
"""Dispatch the given request by HTTP method."""
|
|
104
97
|
method = getattr(self, method_name or request.method.lower())
|
|
98
|
+
meta = self.meta
|
|
105
99
|
self.auth = await self.authorize(request)
|
|
100
|
+
if meta.rate_limit:
|
|
101
|
+
await self.rate_limit(request)
|
|
102
|
+
|
|
106
103
|
self.collection = await self.prepare_collection(request)
|
|
107
104
|
resource = await self.prepare_resource(request)
|
|
108
105
|
if not (request.method == "GET" and resource is None):
|
|
109
106
|
return await method(request, resource=resource)
|
|
110
107
|
|
|
111
108
|
headers = None
|
|
112
|
-
meta = self.meta
|
|
113
109
|
|
|
114
110
|
# Filter collection
|
|
115
111
|
if meta.filters:
|
|
@@ -148,6 +144,11 @@ class RESTBase(Generic[TVResource], Handler, metaclass=RESTHandlerMeta):
|
|
|
148
144
|
raise APIError.UNAUTHORIZED()
|
|
149
145
|
return auth
|
|
150
146
|
|
|
147
|
+
async def rate_limit(self, request: Request):
|
|
148
|
+
"""Default rate limit method. Proxy rate limit to self.api."""
|
|
149
|
+
if not await self.meta.rate_limiter.check(f"{self.auth}"):
|
|
150
|
+
raise APIError.TOO_MANY_REQUESTS()
|
|
151
|
+
|
|
151
152
|
# Prepare data
|
|
152
153
|
# ------------
|
|
153
154
|
@abc.abstractmethod
|
|
@@ -168,7 +169,7 @@ class RESTBase(Generic[TVResource], Handler, metaclass=RESTHandlerMeta):
|
|
|
168
169
|
headers["x-total"] = total
|
|
169
170
|
return headers
|
|
170
171
|
|
|
171
|
-
def paginate_prepare_params(self, request: Request) ->
|
|
172
|
+
def paginate_prepare_params(self, request: Request) -> tuple[int, int]:
|
|
172
173
|
"""Prepare pagination params."""
|
|
173
174
|
meta = self.meta
|
|
174
175
|
query = request.url.query
|
|
@@ -181,7 +182,7 @@ class RESTBase(Generic[TVResource], Handler, metaclass=RESTHandlerMeta):
|
|
|
181
182
|
@abc.abstractmethod
|
|
182
183
|
async def paginate(
|
|
183
184
|
self, request: Request, *, limit: int = 0, offset: int = 0
|
|
184
|
-
) ->
|
|
185
|
+
) -> tuple[Any, Optional[int]]:
|
|
185
186
|
"""Paginate the results."""
|
|
186
187
|
raise NotImplementedError
|
|
187
188
|
|
|
@@ -193,8 +194,8 @@ class RESTBase(Generic[TVResource], Handler, metaclass=RESTHandlerMeta):
|
|
|
193
194
|
return resource
|
|
194
195
|
|
|
195
196
|
async def save_many(
|
|
196
|
-
self, request: Request, data:
|
|
197
|
-
) ->
|
|
197
|
+
self, request: Request, data: list[TVResource], *, update=False
|
|
198
|
+
) -> list[TVResource]:
|
|
198
199
|
"""Save many resources."""
|
|
199
200
|
return [await self.save(request, item, update=update) for item in data]
|
|
200
201
|
|
|
@@ -226,7 +227,7 @@ class RESTBase(Generic[TVResource], Handler, metaclass=RESTHandlerMeta):
|
|
|
226
227
|
@overload
|
|
227
228
|
async def dump( # type: ignore[misc]
|
|
228
229
|
self, request, data: TVData, *, many: Literal[True]
|
|
229
|
-
) ->
|
|
230
|
+
) -> list[TSchemaRes]:
|
|
230
231
|
...
|
|
231
232
|
|
|
232
233
|
@overload
|
|
@@ -239,7 +240,7 @@ class RESTBase(Generic[TVResource], Handler, metaclass=RESTHandlerMeta):
|
|
|
239
240
|
data: Union[TVResource, Iterable[TVResource]],
|
|
240
241
|
*,
|
|
241
242
|
many: bool = False,
|
|
242
|
-
) -> Union[TSchemaRes,
|
|
243
|
+
) -> Union[TSchemaRes, list[TSchemaRes]]:
|
|
243
244
|
"""Serialize the given response."""
|
|
244
245
|
schema = self.get_schema(request)
|
|
245
246
|
return schema.dump(data, many=many)
|
|
@@ -293,7 +294,7 @@ class RESTHandler(RESTBase[TVResource], openapi.OpenAPIMixin):
|
|
|
293
294
|
"""Basic Handler Class."""
|
|
294
295
|
|
|
295
296
|
|
|
296
|
-
def to_sort(sort_params: Sequence[str]) -> Generator[
|
|
297
|
+
def to_sort(sort_params: Sequence[str]) -> Generator[tuple[str, bool], None, None]:
|
|
297
298
|
"""Generate sort params."""
|
|
298
299
|
for name in sort_params:
|
|
299
300
|
n, desc = name.strip("-"), name.startswith("-")
|
muffin_rest/limits.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
from time import time
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class RateLimiter(abc.ABC):
|
|
6
|
+
"""Rate limiter."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, limit: int, period: int, **opts):
|
|
9
|
+
"""Initialize the rate limiter.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
limit (int): The limit of requests.
|
|
13
|
+
period (int): The period of time in seconds.
|
|
14
|
+
"""
|
|
15
|
+
self.limit = limit
|
|
16
|
+
self.period = period
|
|
17
|
+
|
|
18
|
+
@abc.abstractmethod
|
|
19
|
+
async def check(self, key: str) -> bool:
|
|
20
|
+
"""Check the request."""
|
|
21
|
+
raise NotImplementedError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
RATE_LIMITS = {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MemoryRateLimiter(RateLimiter):
|
|
28
|
+
"""Memory rate limiter. Do not use in production."""
|
|
29
|
+
|
|
30
|
+
async def check(self, key: str) -> bool:
|
|
31
|
+
"""Check the request."""
|
|
32
|
+
now = time()
|
|
33
|
+
if key not in RATE_LIMITS:
|
|
34
|
+
RATE_LIMITS[key] = (now, 1)
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
last, count = RATE_LIMITS[key]
|
|
38
|
+
if now - last > self.period:
|
|
39
|
+
RATE_LIMITS[key] = (now, 1)
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
if count < self.limit:
|
|
43
|
+
RATE_LIMITS[key] = (last, count + 1)
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RedisRateLimiter(RateLimiter):
|
|
50
|
+
"""Redis rate limiter."""
|
|
51
|
+
|
|
52
|
+
# TODO: Asyncio lock
|
|
53
|
+
|
|
54
|
+
def __init__(self, limit: int, period: int, *, redis, **opts):
|
|
55
|
+
"""Initialize the rate limiter.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
limit (int): The limit of requests.
|
|
59
|
+
period (int): The period of time in seconds.
|
|
60
|
+
redis (aioredis.Redis): The Redis connection.
|
|
61
|
+
"""
|
|
62
|
+
super().__init__(limit, period)
|
|
63
|
+
self.redis = redis
|
|
64
|
+
|
|
65
|
+
async def check(self, key: str) -> bool:
|
|
66
|
+
"""Check the request."""
|
|
67
|
+
value = await self.redis.get(key)
|
|
68
|
+
if value is None:
|
|
69
|
+
await self.redis.setex(key, self.period, 1)
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
await self.redis.incr(key)
|
|
73
|
+
return int(value) < self.limit
|
muffin_rest/marshmallow.py
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
from collections.abc import Mapping
|
|
2
|
-
from typing import Optional, Union, cast
|
|
4
|
+
from typing import TYPE_CHECKING, Optional, Union, cast
|
|
3
5
|
|
|
4
|
-
from asgi_tools import Request
|
|
5
6
|
from marshmallow import Schema, ValidationError
|
|
6
7
|
|
|
7
8
|
from muffin_rest.errors import APIError
|
|
8
9
|
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from asgi_tools import Request
|
|
12
|
+
|
|
9
13
|
|
|
10
14
|
async def load_data(request: Request, schema: Optional[Schema] = None, **params):
|
|
11
15
|
try:
|
muffin_rest/mongo/__init__.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Mongo DB support."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from typing import TYPE_CHECKING,
|
|
4
|
+
from typing import TYPE_CHECKING, Optional, cast
|
|
5
5
|
|
|
6
6
|
import bson
|
|
7
7
|
from bson.errors import InvalidId
|
|
@@ -24,17 +24,17 @@ if TYPE_CHECKING:
|
|
|
24
24
|
class MongoRESTOptions(RESTOptions):
|
|
25
25
|
"""Support Mongo DB."""
|
|
26
26
|
|
|
27
|
-
filters_cls:
|
|
28
|
-
sorting_cls:
|
|
29
|
-
schema_base:
|
|
27
|
+
filters_cls: type[MongoFilters] = MongoFilters
|
|
28
|
+
sorting_cls: type[MongoSorting] = MongoSorting
|
|
29
|
+
schema_base: type[MongoSchema] = MongoSchema
|
|
30
30
|
|
|
31
|
-
aggregate: Optional[
|
|
31
|
+
aggregate: Optional[list] = None # Support aggregation. Set to pipeline.
|
|
32
32
|
collection_id: str = "_id"
|
|
33
33
|
collection: motor.AsyncIOMotorCollection
|
|
34
34
|
|
|
35
35
|
base_property: str = "collection"
|
|
36
36
|
|
|
37
|
-
Schema:
|
|
37
|
+
Schema: type[MongoSchema]
|
|
38
38
|
|
|
39
39
|
def setup(self, cls):
|
|
40
40
|
"""Prepare meta options."""
|
|
@@ -49,7 +49,7 @@ class MongoRESTHandler(RESTHandler[TVResource]):
|
|
|
49
49
|
"""Support Mongo DB."""
|
|
50
50
|
|
|
51
51
|
meta: MongoRESTOptions
|
|
52
|
-
meta_class:
|
|
52
|
+
meta_class: type[MongoRESTOptions] = MongoRESTOptions
|
|
53
53
|
|
|
54
54
|
async def prepare_collection(self, _: Request) -> MongoChain:
|
|
55
55
|
"""Initialize Peeewee QuerySet for a binded to the resource model."""
|
|
@@ -57,7 +57,7 @@ class MongoRESTHandler(RESTHandler[TVResource]):
|
|
|
57
57
|
|
|
58
58
|
async def paginate(
|
|
59
59
|
self, _: Request, *, limit: int = 0, offset: int = 0
|
|
60
|
-
) ->
|
|
60
|
+
) -> tuple[motor.AsyncIOMotorCursor, Optional[int]]:
|
|
61
61
|
"""Paginate collection."""
|
|
62
62
|
if self.meta.aggregate:
|
|
63
63
|
pipeline_all = [*self.meta.aggregate, {"$skip": offset}, {"$limit": limit}]
|
|
@@ -118,7 +118,7 @@ class MongoRESTHandler(RESTHandler[TVResource]):
|
|
|
118
118
|
async def delete(self, request: Request, resource: Optional[TVResource] = None):
|
|
119
119
|
"""Remove the given resource(s)."""
|
|
120
120
|
meta = self.meta
|
|
121
|
-
oids = [resource[meta.collection_id]] if resource else cast(
|
|
121
|
+
oids = [resource[meta.collection_id]] if resource else cast(list[str], await request.data())
|
|
122
122
|
if not oids:
|
|
123
123
|
raise APIError.NOT_FOUND()
|
|
124
124
|
|
muffin_rest/mongo/filters.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Support filters for Mongo."""
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
from typing import Any, Callable, ClassVar
|
|
4
4
|
|
|
5
5
|
from muffin_rest.filters import Filter, Filters
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@ from muffin_rest.filters import Filter, Filters
|
|
|
8
8
|
class MongoFilter(Filter):
|
|
9
9
|
"""Custom filter for sqlalchemy."""
|
|
10
10
|
|
|
11
|
-
operators = {
|
|
11
|
+
operators: ClassVar = {
|
|
12
12
|
"$eq": lambda _, v: ("$eq", v),
|
|
13
13
|
"$ge": lambda _, v: ("$ge", v),
|
|
14
14
|
"$gt": lambda _, v: ("$gt", v),
|
|
@@ -21,7 +21,7 @@ class MongoFilter(Filter):
|
|
|
21
21
|
"$ends": lambda _, v: ("$regex", f"{ v }$"),
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
async def filter(self, collection, *ops:
|
|
24
|
+
async def filter(self, collection, *ops: tuple[Callable, Any], **_):
|
|
25
25
|
"""Apply the filter."""
|
|
26
26
|
return collection.find({self.field: dict(op(self.name, v) for op, v in ops)})
|
|
27
27
|
|
muffin_rest/mongo/types.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
from typing import Any,
|
|
1
|
+
from typing import Any, TypeVar
|
|
2
2
|
|
|
3
3
|
from .utils import MongoChain
|
|
4
4
|
|
|
5
|
+
TResource = dict[str, Any]
|
|
5
6
|
TVCollection = TypeVar("TVCollection", bound=MongoChain)
|
|
6
|
-
TResource = Dict[str, Any]
|
|
7
7
|
TVResource = TypeVar("TVResource", bound=TResource)
|
muffin_rest/mongo/utils.py
CHANGED
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from typing import TYPE_CHECKING, Awaitable,
|
|
5
|
+
from typing import TYPE_CHECKING, Awaitable, Union
|
|
6
6
|
|
|
7
7
|
if TYPE_CHECKING:
|
|
8
8
|
from motor import motor_asyncio as motor
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class MongoChain:
|
|
12
|
-
|
|
13
12
|
"""Support query chains.
|
|
14
13
|
|
|
15
14
|
Only for `find` and `find_one` methods.
|
|
@@ -53,12 +52,14 @@ class MongoChain:
|
|
|
53
52
|
def __init__(self, collection: motor.AsyncIOMotorCollection):
|
|
54
53
|
"""Initialize the resource."""
|
|
55
54
|
self.collection = collection
|
|
56
|
-
self.query:
|
|
55
|
+
self.query: list = []
|
|
57
56
|
self.projection = None
|
|
58
|
-
self.sorting:
|
|
57
|
+
self.sorting: list[tuple[str, int]] = []
|
|
59
58
|
|
|
60
59
|
def find(
|
|
61
|
-
self,
|
|
60
|
+
self,
|
|
61
|
+
query: Union[list, dict, None] = None,
|
|
62
|
+
projection=None,
|
|
62
63
|
) -> MongoChain:
|
|
63
64
|
"""Store filters in self."""
|
|
64
65
|
self.query = self.__update__(query)
|
|
@@ -66,7 +67,9 @@ class MongoChain:
|
|
|
66
67
|
return self
|
|
67
68
|
|
|
68
69
|
def find_one(
|
|
69
|
-
self,
|
|
70
|
+
self,
|
|
71
|
+
query: Union[list, dict, None] = None,
|
|
72
|
+
projection=None,
|
|
70
73
|
) -> Awaitable:
|
|
71
74
|
"""Apply filters and return cursor."""
|
|
72
75
|
query = self.__update__(query)
|
muffin_rest/openapi.py
CHANGED
|
@@ -6,7 +6,7 @@ import re
|
|
|
6
6
|
from contextlib import suppress
|
|
7
7
|
from functools import partial
|
|
8
8
|
from http import HTTPStatus
|
|
9
|
-
from typing import TYPE_CHECKING, Any, Callable,
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Callable, cast
|
|
10
10
|
|
|
11
11
|
from apispec import utils
|
|
12
12
|
from apispec.core import APISpec
|
|
@@ -81,9 +81,9 @@ def render_openapi(api, request=None):
|
|
|
81
81
|
return spec.to_dict()
|
|
82
82
|
|
|
83
83
|
|
|
84
|
-
def route_to_spec(route: Route, spec: APISpec, tags:
|
|
84
|
+
def route_to_spec(route: Route, spec: APISpec, tags: dict) -> dict:
|
|
85
85
|
"""Convert the given router to openapi operations."""
|
|
86
|
-
results:
|
|
86
|
+
results: dict[str, Any] = {"parameters": [], "operations": {}}
|
|
87
87
|
if isinstance(route, DynamicRoute):
|
|
88
88
|
for param in route.params:
|
|
89
89
|
results["parameters"].append({"in": "path", "name": param})
|
|
@@ -109,7 +109,7 @@ def route_to_spec(route: Route, spec: APISpec, tags: Dict) -> Dict:
|
|
|
109
109
|
return results
|
|
110
110
|
|
|
111
111
|
|
|
112
|
-
def parse_docs(cb: Callable) ->
|
|
112
|
+
def parse_docs(cb: Callable) -> tuple[str, str, dict]:
|
|
113
113
|
"""Parse docs from the given callback."""
|
|
114
114
|
if yaml_utils is None:
|
|
115
115
|
return "", "", {}
|
|
@@ -122,7 +122,7 @@ def parse_docs(cb: Callable) -> Tuple[str, str, Dict]:
|
|
|
122
122
|
return summary, description.strip(), schema
|
|
123
123
|
|
|
124
124
|
|
|
125
|
-
def merge_dicts(source:
|
|
125
|
+
def merge_dicts(source: dict, merge: dict) -> dict:
|
|
126
126
|
"""Merge dicts."""
|
|
127
127
|
return dict(
|
|
128
128
|
source,
|
|
@@ -145,15 +145,15 @@ def merge_dicts(source: Dict, merge: Dict) -> Dict:
|
|
|
145
145
|
)
|
|
146
146
|
|
|
147
147
|
|
|
148
|
-
def route_to_methods(route: Route) ->
|
|
148
|
+
def route_to_methods(route: Route) -> list[str]:
|
|
149
149
|
"""Get sorted methods from the route."""
|
|
150
150
|
methods = [m for m in HTTP_METHODS if m in (route.methods or [])]
|
|
151
151
|
return [m.lower() for m in methods or DEFAULT_METHODS]
|
|
152
152
|
|
|
153
153
|
|
|
154
|
-
def return_type_to_response(fn: Callable) ->
|
|
154
|
+
def return_type_to_response(fn: Callable) -> dict:
|
|
155
155
|
"""Generate reponses specs based on the given function's return type."""
|
|
156
|
-
responses:
|
|
156
|
+
responses: dict[int, dict] = {}
|
|
157
157
|
return_type = fn.__annotations__.get("return")
|
|
158
158
|
if return_type is None:
|
|
159
159
|
return responses
|
|
@@ -180,13 +180,13 @@ class OpenAPIMixin:
|
|
|
180
180
|
meta: RESTOptions
|
|
181
181
|
|
|
182
182
|
@classmethod
|
|
183
|
-
def openapi(cls, route: Route, spec: APISpec, tags:
|
|
183
|
+
def openapi(cls, route: Route, spec: APISpec, tags: dict) -> dict: # noqa: C901
|
|
184
184
|
"""Get openapi specs for the endpoint."""
|
|
185
185
|
meta = cls.meta
|
|
186
186
|
if getattr(meta, meta.base_property, None) is None:
|
|
187
187
|
return {}
|
|
188
188
|
|
|
189
|
-
operations:
|
|
189
|
+
operations: dict = {}
|
|
190
190
|
summary, desc, schema = parse_docs(cls)
|
|
191
191
|
if cls not in tags:
|
|
192
192
|
tags[cls] = meta.name
|
muffin_rest/options.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
"""REST Options."""
|
|
2
2
|
|
|
3
|
-
from typing import
|
|
3
|
+
from typing import Any, ClassVar
|
|
4
4
|
|
|
5
5
|
import marshmallow as ma
|
|
6
6
|
|
|
7
|
+
from muffin_rest.limits import MemoryRateLimiter, RateLimiter
|
|
8
|
+
|
|
7
9
|
from .filters import Filters
|
|
8
10
|
from .sorting import Sorting
|
|
9
11
|
|
|
@@ -13,6 +15,10 @@ class RESTOptions:
|
|
|
13
15
|
|
|
14
16
|
name: str = ""
|
|
15
17
|
name_id: str = "id"
|
|
18
|
+
base_property: str = "name"
|
|
19
|
+
|
|
20
|
+
# Pagination
|
|
21
|
+
# ----------
|
|
16
22
|
|
|
17
23
|
# limit: Paginate results (set to None for disable pagination)
|
|
18
24
|
limit: int = 0
|
|
@@ -23,22 +29,37 @@ class RESTOptions:
|
|
|
23
29
|
# limit_total: Return total count of results
|
|
24
30
|
limit_total: bool = True
|
|
25
31
|
|
|
32
|
+
# Filters
|
|
33
|
+
# -------
|
|
34
|
+
|
|
26
35
|
# Base class for filters
|
|
27
36
|
filters: Filters
|
|
28
|
-
filters_cls:
|
|
37
|
+
filters_cls: type[Filters] = Filters
|
|
38
|
+
|
|
39
|
+
# Sorting
|
|
40
|
+
# -------
|
|
29
41
|
|
|
30
42
|
# Base class for sorting
|
|
31
43
|
sorting: Sorting
|
|
32
|
-
sorting_cls:
|
|
44
|
+
sorting_cls: type[Sorting] = Sorting
|
|
45
|
+
|
|
46
|
+
# Serialization/Deserialization
|
|
47
|
+
# -----------------------------
|
|
33
48
|
|
|
34
49
|
# Auto generation for schemas
|
|
35
|
-
Schema:
|
|
36
|
-
schema_base:
|
|
37
|
-
schema_fields:
|
|
38
|
-
schema_meta:
|
|
50
|
+
Schema: type[ma.Schema]
|
|
51
|
+
schema_base: type[ma.Schema] = ma.Schema
|
|
52
|
+
schema_fields: ClassVar[dict] = {}
|
|
53
|
+
schema_meta: ClassVar[dict] = {}
|
|
39
54
|
schema_unknown: str = ma.EXCLUDE
|
|
40
55
|
|
|
41
|
-
|
|
56
|
+
# Rate Limiting
|
|
57
|
+
# -------------
|
|
58
|
+
|
|
59
|
+
rate_limit: int = 0
|
|
60
|
+
rate_limit_period: int = 60
|
|
61
|
+
rate_limit_cls: type[RateLimiter] = MemoryRateLimiter
|
|
62
|
+
rate_limit_cls_opts: ClassVar[dict[str, Any]] = {}
|
|
42
63
|
|
|
43
64
|
def __init__(self, cls):
|
|
44
65
|
"""Inherit meta options."""
|
|
@@ -65,6 +86,11 @@ class RESTOptions:
|
|
|
65
86
|
if not self.limit_max:
|
|
66
87
|
self.limit_max = self.limit
|
|
67
88
|
|
|
89
|
+
if self.rate_limit:
|
|
90
|
+
self.rate_limiter = self.rate_limit_cls(
|
|
91
|
+
self.rate_limit, self.rate_limit_period, **self.rate_limit_cls_opts
|
|
92
|
+
)
|
|
93
|
+
|
|
68
94
|
def setup_schema_meta(self, _):
|
|
69
95
|
"""Generate meta for schemas."""
|
|
70
96
|
return type(
|
muffin_rest/peewee/filters.py
CHANGED
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
import operator
|
|
5
5
|
from functools import reduce
|
|
6
|
-
from typing import Any, Callable,
|
|
6
|
+
from typing import Any, Callable, ClassVar, Union, cast
|
|
7
7
|
|
|
8
8
|
from peewee import ColumnBase, Field, ModelSelect
|
|
9
9
|
|
|
@@ -15,7 +15,7 @@ from .utils import get_model_field_by_name
|
|
|
15
15
|
class PWFilter(Filter):
|
|
16
16
|
"""Support Peewee."""
|
|
17
17
|
|
|
18
|
-
operators = dict(Filter.operators)
|
|
18
|
+
operators: ClassVar = dict(Filter.operators)
|
|
19
19
|
operators["$in"] = operator.lshift
|
|
20
20
|
operators["$none"] = operator.rshift
|
|
21
21
|
operators["$like"] = operator.mod
|
|
@@ -34,7 +34,7 @@ class PWFilter(Filter):
|
|
|
34
34
|
list_ops = (*Filter.list_ops, "$between")
|
|
35
35
|
|
|
36
36
|
async def filter(
|
|
37
|
-
self, collection: ModelSelect, *ops:
|
|
37
|
+
self, collection: ModelSelect, *ops: tuple[Callable, Any], **kwargs
|
|
38
38
|
) -> ModelSelect:
|
|
39
39
|
"""Apply the filters to Peewee QuerySet.."""
|
|
40
40
|
column = self.field
|
|
@@ -46,7 +46,7 @@ class PWFilter(Filter):
|
|
|
46
46
|
class PWFilters(Filters):
|
|
47
47
|
"""Bind Peewee filter class."""
|
|
48
48
|
|
|
49
|
-
MUTATE_CLASS:
|
|
49
|
+
MUTATE_CLASS: type[PWFilter] = PWFilter
|
|
50
50
|
|
|
51
51
|
def convert(self, obj: Union[str, Field, PWFilter], **meta):
|
|
52
52
|
"""Convert params to filters."""
|
muffin_rest/peewee/handler.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from typing import TYPE_CHECKING, Any, Optional,
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Optional, Union, cast, overload
|
|
6
6
|
|
|
7
7
|
import marshmallow as ma
|
|
8
8
|
import peewee as pw
|
|
@@ -23,7 +23,7 @@ if TYPE_CHECKING:
|
|
|
23
23
|
from peewee_aio.types import TVAIOModel
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
#
|
|
26
|
+
# TODO: Patch apispec.MarshmallowPlugin to support ForeignKeyField
|
|
27
27
|
MarshmallowPlugin.Converter.field_mapping[ForeignKey] = ("integer", None)
|
|
28
28
|
|
|
29
29
|
assert issubclass(EnumField, ma.fields.Field) # just register EnumField
|
|
@@ -37,7 +37,7 @@ class PWRESTBase(RESTBase[TVModel], PeeweeOpenAPIMixin):
|
|
|
37
37
|
collection: Union[AIOModelSelect, pw.ModelSelect]
|
|
38
38
|
|
|
39
39
|
meta: PWRESTOptions
|
|
40
|
-
meta_class:
|
|
40
|
+
meta_class: type[PWRESTOptions] = PWRESTOptions
|
|
41
41
|
|
|
42
42
|
@overload
|
|
43
43
|
async def prepare_collection(
|
|
@@ -70,7 +70,7 @@ class PWRESTBase(RESTBase[TVModel], PeeweeOpenAPIMixin):
|
|
|
70
70
|
resource = await meta.manager.fetchone(
|
|
71
71
|
self.collection.where(meta.model_pk == pk),
|
|
72
72
|
)
|
|
73
|
-
except Exception: # noqa:
|
|
73
|
+
except Exception: # noqa: BLE001
|
|
74
74
|
resource = None
|
|
75
75
|
|
|
76
76
|
if resource is None:
|
|
@@ -81,13 +81,13 @@ class PWRESTBase(RESTBase[TVModel], PeeweeOpenAPIMixin):
|
|
|
81
81
|
@overload
|
|
82
82
|
async def paginate(
|
|
83
83
|
self: PWRESTBase[TVAIOModel], _: Request, *, limit: int = 0, offset: int = 0
|
|
84
|
-
) ->
|
|
84
|
+
) -> tuple[AIOModelSelect[TVAIOModel], int | None]:
|
|
85
85
|
...
|
|
86
86
|
|
|
87
87
|
@overload
|
|
88
88
|
async def paginate(
|
|
89
89
|
self: PWRESTBase[pw.Model], _: Request, *, limit: int = 0, offset: int = 0
|
|
90
|
-
) ->
|
|
90
|
+
) -> tuple[pw.ModelSelect, int | None]:
|
|
91
91
|
...
|
|
92
92
|
|
|
93
93
|
async def paginate(self, _: Request, *, limit: int = 0, offset: int = 0):
|
muffin_rest/peewee/openapi.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from typing import TYPE_CHECKING
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
6
|
|
|
7
7
|
from muffin_rest.openapi import OpenAPIMixin
|
|
8
8
|
|
|
@@ -19,7 +19,7 @@ class PeeweeOpenAPIMixin(OpenAPIMixin):
|
|
|
19
19
|
meta: PWRESTOptions
|
|
20
20
|
|
|
21
21
|
@classmethod
|
|
22
|
-
def openapi(cls, route: Route, spec: APISpec, tags:
|
|
22
|
+
def openapi(cls, route: Route, spec: APISpec, tags: dict) -> dict:
|
|
23
23
|
"""Get openapi specs for the endpoint."""
|
|
24
24
|
operations = super(PeeweeOpenAPIMixin, cls).openapi(route, spec, tags)
|
|
25
25
|
is_resource_route = getattr(route, "params", {}).get(cls.meta.name_id)
|
muffin_rest/peewee/options.py
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
from typing import Type
|
|
2
|
-
|
|
3
1
|
import peewee as pw
|
|
4
2
|
from marshmallow_peewee import ModelSchema
|
|
5
3
|
from peewee_aio import Manager
|
|
@@ -14,19 +12,19 @@ class PWRESTOptions(RESTOptions):
|
|
|
14
12
|
"""Support Peewee."""
|
|
15
13
|
|
|
16
14
|
# Base filters class
|
|
17
|
-
filters_cls:
|
|
15
|
+
filters_cls: type[PWFilters] = PWFilters
|
|
18
16
|
|
|
19
17
|
# Base sorting class
|
|
20
|
-
sorting_cls:
|
|
18
|
+
sorting_cls: type[PWSorting] = PWSorting
|
|
21
19
|
|
|
22
|
-
Schema:
|
|
20
|
+
Schema: type[ModelSchema]
|
|
23
21
|
|
|
24
22
|
# Schema auto generation params
|
|
25
|
-
schema_base:
|
|
23
|
+
schema_base: type[ModelSchema] = ModelSchema
|
|
26
24
|
|
|
27
25
|
base_property: str = "model"
|
|
28
26
|
|
|
29
|
-
model:
|
|
27
|
+
model: type[pw.Model]
|
|
30
28
|
model_pk: pw.Field
|
|
31
29
|
|
|
32
30
|
manager: Manager
|
muffin_rest/peewee/sorting.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from typing import TYPE_CHECKING,
|
|
5
|
+
from typing import TYPE_CHECKING, Union, cast
|
|
6
6
|
|
|
7
7
|
from peewee import Field
|
|
8
8
|
|
|
@@ -28,7 +28,7 @@ class PWSort(Sort):
|
|
|
28
28
|
class PWSorting(Sorting):
|
|
29
29
|
"""Sort Peewee ORM Queries."""
|
|
30
30
|
|
|
31
|
-
MUTATE_CLASS:
|
|
31
|
+
MUTATE_CLASS: type[PWSort] = PWSort
|
|
32
32
|
|
|
33
33
|
def prepare(self, collection: TVCollection) -> TVCollection:
|
|
34
34
|
"""Prepare collection for sorting."""
|
muffin_rest/schemas.py
CHANGED
muffin_rest/sorting.py
CHANGED
|
@@ -1,19 +1,7 @@
|
|
|
1
1
|
"""Implement sorting."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from typing import
|
|
5
|
-
TYPE_CHECKING,
|
|
6
|
-
Any,
|
|
7
|
-
Dict,
|
|
8
|
-
Generator,
|
|
9
|
-
Iterable,
|
|
10
|
-
List,
|
|
11
|
-
Mapping,
|
|
12
|
-
Sequence,
|
|
13
|
-
Tuple,
|
|
14
|
-
Type,
|
|
15
|
-
cast,
|
|
16
|
-
)
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Generator, Iterable, Mapping, Sequence, cast
|
|
17
5
|
|
|
18
6
|
from .types import TVCollection
|
|
19
7
|
from .utils import Mutate, Mutator
|
|
@@ -40,14 +28,14 @@ class Sorting(Mutator):
|
|
|
40
28
|
MUTATE_CLASS = Sort
|
|
41
29
|
mutations: Mapping[str, Sort]
|
|
42
30
|
|
|
43
|
-
def __init__(self, handler:
|
|
31
|
+
def __init__(self, handler: type[RESTBase], params: Iterable):
|
|
44
32
|
"""Initialize the sorting."""
|
|
45
|
-
self.default:
|
|
33
|
+
self.default: list[Sort] = []
|
|
46
34
|
super(Sorting, self).__init__(handler, params)
|
|
47
35
|
|
|
48
36
|
async def apply(
|
|
49
37
|
self, request: Request, collection: TVCollection
|
|
50
|
-
) ->
|
|
38
|
+
) -> tuple[TVCollection, dict[str, Any]]:
|
|
51
39
|
"""Sort the given collection."""
|
|
52
40
|
data = request.url.query.get(SORT_PARAM)
|
|
53
41
|
sorting = {}
|
|
@@ -97,7 +85,7 @@ class Sorting(Mutator):
|
|
|
97
85
|
|
|
98
86
|
def to_sort(
|
|
99
87
|
sort_params: Sequence[str],
|
|
100
|
-
) -> Generator[
|
|
88
|
+
) -> Generator[tuple[str, bool], None, None]:
|
|
101
89
|
"""Generate sort params."""
|
|
102
90
|
for name in sort_params:
|
|
103
91
|
n, desc = name.strip("-"), name.startswith("-")
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from typing import TYPE_CHECKING, Any, Optional,
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Optional, cast
|
|
6
6
|
|
|
7
7
|
import marshmallow as ma
|
|
8
8
|
import sqlalchemy as sa
|
|
@@ -22,8 +22,7 @@ if TYPE_CHECKING:
|
|
|
22
22
|
|
|
23
23
|
from .types import TVResource
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
ModelConverter._get_field_name = lambda _, prop_or_column: str(prop_or_column.key) # type: ignore[assignment] # noqa:
|
|
25
|
+
ModelConverter._get_field_name = lambda _, prop_or_column: str(prop_or_column.key) # type: ignore[method-assign]
|
|
27
26
|
|
|
28
27
|
|
|
29
28
|
class SQLAlchemyAutoSchema(BaseSQLAlchemyAutoSchema):
|
|
@@ -69,12 +68,12 @@ class SQLAlchemyAutoSchema(BaseSQLAlchemyAutoSchema):
|
|
|
69
68
|
class SARESTOptions(RESTOptions):
|
|
70
69
|
"""Support SQLAlchemy Core."""
|
|
71
70
|
|
|
72
|
-
filters_cls:
|
|
73
|
-
sorting_cls:
|
|
71
|
+
filters_cls: type[SAFilters] = SAFilters
|
|
72
|
+
sorting_cls: type[SASorting] = SASorting
|
|
74
73
|
|
|
75
74
|
# Schema auto generation params
|
|
76
|
-
Schema:
|
|
77
|
-
schema_base:
|
|
75
|
+
Schema: type[SQLAlchemyAutoSchema]
|
|
76
|
+
schema_base: type[SQLAlchemyAutoSchema] = SQLAlchemyAutoSchema
|
|
78
77
|
|
|
79
78
|
table: sa.Table
|
|
80
79
|
table_pk: sa.Column
|
|
@@ -113,7 +112,7 @@ class SARESTHandler(RESTHandler[TVResource]):
|
|
|
113
112
|
"""Support SQLAlchemy Core."""
|
|
114
113
|
|
|
115
114
|
meta: SARESTOptions
|
|
116
|
-
meta_class:
|
|
115
|
+
meta_class: type[SARESTOptions] = SARESTOptions
|
|
117
116
|
collection: sa.sql.Select
|
|
118
117
|
|
|
119
118
|
async def prepare_collection(self, _: Request) -> sa.sql.Select:
|
|
@@ -126,10 +125,10 @@ class SARESTHandler(RESTHandler[TVResource]):
|
|
|
126
125
|
*,
|
|
127
126
|
limit: int = 0,
|
|
128
127
|
offset: int = 0,
|
|
129
|
-
) ->
|
|
128
|
+
) -> tuple[sa.sql.Select, Optional[int]]:
|
|
130
129
|
"""Paginate the collection."""
|
|
131
130
|
sqs = self.collection.order_by(None).subquery()
|
|
132
|
-
qs = sa.select(
|
|
131
|
+
qs = sa.select(sa.func.count()).select_from(sqs)
|
|
133
132
|
total = None
|
|
134
133
|
if self.meta.limit_total:
|
|
135
134
|
total = await self.meta.database.fetch_val(qs)
|
|
@@ -167,11 +166,11 @@ class SARESTHandler(RESTHandler[TVResource]):
|
|
|
167
166
|
insert_query = meta.table.insert()
|
|
168
167
|
table_pk = cast(sa.Column, meta.table_pk)
|
|
169
168
|
if update:
|
|
170
|
-
update_query = self.meta.table.update().where(table_pk == resource[table_pk.name])
|
|
169
|
+
update_query = self.meta.table.update().where(table_pk == resource[table_pk.name]) # type: ignore[call-overload]
|
|
171
170
|
await meta.database.execute(update_query, resource)
|
|
172
171
|
|
|
173
172
|
else:
|
|
174
|
-
resource[table_pk.name] = await meta.database.execute(insert_query, resource)
|
|
173
|
+
resource[table_pk.name] = await meta.database.execute(insert_query, resource) # type: ignore[call-overload]
|
|
175
174
|
|
|
176
175
|
return resource
|
|
177
176
|
|
|
@@ -182,7 +181,7 @@ class SARESTHandler(RESTHandler[TVResource]):
|
|
|
182
181
|
if not pks:
|
|
183
182
|
raise APIError.NOT_FOUND()
|
|
184
183
|
|
|
185
|
-
delete = self.meta.table.delete(table_pk.in_(pks))
|
|
184
|
+
delete = self.meta.table.delete().where(table_pk.in_(cast(list[Any], pks)))
|
|
186
185
|
await self.meta.database.execute(delete)
|
|
187
186
|
|
|
188
187
|
delete = remove
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from typing import TYPE_CHECKING, Any, Callable,
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Union, cast
|
|
6
6
|
|
|
7
7
|
from sqlalchemy import Column
|
|
8
8
|
|
|
@@ -15,7 +15,7 @@ if TYPE_CHECKING:
|
|
|
15
15
|
class SAFilter(Filter):
|
|
16
16
|
"""Custom filter for sqlalchemy."""
|
|
17
17
|
|
|
18
|
-
operators = dict(Filter.operators)
|
|
18
|
+
operators: ClassVar = dict(Filter.operators)
|
|
19
19
|
operators["$between"] = lambda c, v: c.between(*v)
|
|
20
20
|
operators["$ends"] = lambda c, v: c.endswith(v)
|
|
21
21
|
operators["$ilike"] = lambda c, v: c.ilike(v)
|
|
@@ -27,10 +27,10 @@ class SAFilter(Filter):
|
|
|
27
27
|
operators["$notlike"] = lambda c, v: c.notlike(v)
|
|
28
28
|
operators["$starts"] = lambda c, v: c.startswith(v)
|
|
29
29
|
|
|
30
|
-
list_ops =
|
|
30
|
+
list_ops = (*Filter.list_ops, "$between")
|
|
31
31
|
|
|
32
32
|
async def filter(
|
|
33
|
-
self, collection: TVCollection, *ops:
|
|
33
|
+
self, collection: TVCollection, *ops: tuple[Callable, Any], **kwargs
|
|
34
34
|
) -> TVCollection:
|
|
35
35
|
"""Apply the filters to SQLAlchemy Select."""
|
|
36
36
|
column = self.field
|
|
@@ -62,7 +62,7 @@ class SAFilters(Filters):
|
|
|
62
62
|
|
|
63
63
|
if isinstance(obj, Column):
|
|
64
64
|
name = obj.name
|
|
65
|
-
field = obj
|
|
65
|
+
field: Any = obj
|
|
66
66
|
|
|
67
67
|
else:
|
|
68
68
|
name = obj
|
muffin_rest/sqlalchemy/types.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
from typing import Any,
|
|
1
|
+
from typing import Any, TypeVar
|
|
2
2
|
|
|
3
3
|
from sqlalchemy import sql
|
|
4
4
|
|
|
5
5
|
TVCollection = TypeVar("TVCollection", bound=sql.Select)
|
|
6
6
|
|
|
7
|
-
TResource =
|
|
7
|
+
TResource = dict[str, Any]
|
|
8
8
|
TVResource = TypeVar("TVResource", bound=TResource)
|
muffin_rest/types.py
CHANGED
|
@@ -3,9 +3,6 @@ from typing import (
|
|
|
3
3
|
Any,
|
|
4
4
|
Awaitable,
|
|
5
5
|
Callable,
|
|
6
|
-
Dict,
|
|
7
|
-
List,
|
|
8
|
-
Type,
|
|
9
6
|
TypeVar,
|
|
10
7
|
Union,
|
|
11
8
|
)
|
|
@@ -17,8 +14,8 @@ from muffin import Request
|
|
|
17
14
|
|
|
18
15
|
TVCollection = TypeVar("TVCollection", bound=Any)
|
|
19
16
|
TVResource = TypeVar("TVResource", bound=Any)
|
|
20
|
-
TVData = Union[TVResource,
|
|
17
|
+
TVData = Union[TVResource, list[TVResource]]
|
|
21
18
|
TAuth = Callable[[Request], Awaitable]
|
|
22
19
|
TVAuth = TypeVar("TVAuth", bound=TAuth)
|
|
23
|
-
TVHandler = TypeVar("TVHandler", bound=
|
|
24
|
-
TSchemaRes =
|
|
20
|
+
TVHandler = TypeVar("TVHandler", bound=type["RESTBase"])
|
|
21
|
+
TSchemaRes = dict[str, Any]
|
muffin_rest/utils.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import abc
|
|
5
|
-
from typing import TYPE_CHECKING, Any,
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Iterable, Mapping
|
|
6
6
|
|
|
7
7
|
if TYPE_CHECKING:
|
|
8
8
|
from muffin import Request
|
|
@@ -36,7 +36,7 @@ class Mutate(abc.ABC):
|
|
|
36
36
|
class Mutator(abc.ABC):
|
|
37
37
|
"""Mutate collections."""
|
|
38
38
|
|
|
39
|
-
MUTATE_CLASS:
|
|
39
|
+
MUTATE_CLASS: type[Mutate]
|
|
40
40
|
mutations: Mapping[str, Mutate]
|
|
41
41
|
|
|
42
42
|
def __init__(self, handler, params: Iterable):
|
|
@@ -73,6 +73,6 @@ class Mutator(abc.ABC):
|
|
|
73
73
|
@abc.abstractmethod
|
|
74
74
|
async def apply(
|
|
75
75
|
self, request: Request, collection: TVCollection
|
|
76
|
-
) ->
|
|
76
|
+
) -> tuple[TVCollection, dict[str, Any]]:
|
|
77
77
|
"""Mutate a collection."""
|
|
78
78
|
raise NotImplementedError
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: muffin-rest
|
|
3
|
-
Version:
|
|
3
|
+
Version: 8.1.0
|
|
4
4
|
Summary: The package provides enhanced support for writing REST APIs with Muffin framework
|
|
5
5
|
Home-page: https://github.com/klen/muffin-rest
|
|
6
6
|
License: MIT
|
|
7
7
|
Keywords: rest,api,muffin,asgi,asyncio,trio
|
|
8
8
|
Author: Kirill Klenov
|
|
9
9
|
Author-email: horneds@gmail.com
|
|
10
|
-
Requires-Python: >=3.
|
|
10
|
+
Requires-Python: >=3.9,<4.0
|
|
11
11
|
Classifier: Development Status :: 5 - Production/Stable
|
|
12
12
|
Classifier: Framework :: AsyncIO
|
|
13
13
|
Classifier: Framework :: Trio
|
|
@@ -15,7 +15,6 @@ Classifier: Intended Audience :: Developers
|
|
|
15
15
|
Classifier: License :: OSI Approved :: MIT License
|
|
16
16
|
Classifier: Programming Language :: Python
|
|
17
17
|
Classifier: Programming Language :: Python :: 3
|
|
18
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
19
18
|
Classifier: Programming Language :: Python :: 3.9
|
|
20
19
|
Classifier: Programming Language :: Python :: 3.10
|
|
21
20
|
Classifier: Programming Language :: Python :: 3.11
|
|
@@ -27,7 +26,7 @@ Provides-Extra: yaml
|
|
|
27
26
|
Requires-Dist: apispec (>=6,<7)
|
|
28
27
|
Requires-Dist: marshmallow (>=3,<4)
|
|
29
28
|
Requires-Dist: marshmallow-peewee (>=4,<5) ; extra == "peewee"
|
|
30
|
-
Requires-Dist: marshmallow-sqlalchemy
|
|
29
|
+
Requires-Dist: marshmallow-sqlalchemy ; extra == "sqlalchemy"
|
|
31
30
|
Requires-Dist: muffin (>=0,<1)
|
|
32
31
|
Requires-Dist: muffin-databases (>=0.5.0,<0.6.0) ; extra == "sqlalchemy"
|
|
33
32
|
Requires-Dist: muffin-peewee-aio (>=0,<1) ; extra == "peewee"
|
|
@@ -79,7 +78,7 @@ Features
|
|
|
79
78
|
Requirements
|
|
80
79
|
=============
|
|
81
80
|
|
|
82
|
-
- python >= 3.
|
|
81
|
+
- python >= 3.9
|
|
83
82
|
|
|
84
83
|
.. note:: Trio is only supported with Peewee ORM
|
|
85
84
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
muffin_rest/__init__.py,sha256=NBZeOEJgQHtFFhVgd9d0fpApFRgU405sbm0cu1y1MOU,1242
|
|
2
|
+
muffin_rest/api.py,sha256=zssoHjqTsa8UCAyxj6TxQVluYPX9Sigyiph6SRxBY6I,3870
|
|
3
|
+
muffin_rest/errors.py,sha256=mxEBhNPo3pwpG2em6zaQonbfRgHFBJ3I8WunVYWDjvM,1163
|
|
4
|
+
muffin_rest/filters.py,sha256=MARP_WsJslncahlgZLTImigl0RTxBtb0jmZbXfJ0c6Y,5637
|
|
5
|
+
muffin_rest/handler.py,sha256=7DjPEtWmq7laWELKLJ6yZUwSdtxFIOps-699N_5_viQ,10277
|
|
6
|
+
muffin_rest/limits.py,sha256=pA5hnDQgrP-euDGjAoczlT_b7Dxzw5btQ-3okkHYKSA,1855
|
|
7
|
+
muffin_rest/marshmallow.py,sha256=jWsZeMj3KslbGGgzEMo8-e5eeuGhmqFolL0mIEquylc,789
|
|
8
|
+
muffin_rest/mongo/__init__.py,sha256=SiYSbX6ySJl43fw9aGREIs8ZsS8Qk_ieizoPOj4DjJc,4656
|
|
9
|
+
muffin_rest/mongo/filters.py,sha256=4TgExaUhJiHkogiZLqjtT13A0O-Uvwfd5AesVfTY2Io,949
|
|
10
|
+
muffin_rest/mongo/schema.py,sha256=y4OEPQnlV_COTIIQ3cKmpqDpD2r18eAWn0rijQldWm0,1205
|
|
11
|
+
muffin_rest/mongo/sorting.py,sha256=iJBnaFwE7g_JMwpGpQkoqSqbQK9XULx1K3skiRRgLgY,870
|
|
12
|
+
muffin_rest/mongo/types.py,sha256=jaODScgwwYbzHis3DY4bPzU1ahiMJMSwquH5_Thi-Gg,200
|
|
13
|
+
muffin_rest/mongo/utils.py,sha256=mNkLM-D6gqOA9YW2Qdw0DvE2N4LRmxLAiPMKH9WLttM,3958
|
|
14
|
+
muffin_rest/openapi.py,sha256=0QU7qrfBjGl0vl378SJC5boZZI2ogddl45fS9WL4Axw,8751
|
|
15
|
+
muffin_rest/options.py,sha256=reHbd2o-F6bKEKc8bznzj0TMY2vzjK6Yt1qOny7kt_w,2691
|
|
16
|
+
muffin_rest/peewee/__init__.py,sha256=94DSj_ftT6fbPksHlBv40AH2HWaiZommUFOMN2jd9a4,129
|
|
17
|
+
muffin_rest/peewee/filters.py,sha256=oghjKwurNCyFUYT0r2TVu2Nd1SIalRsmbU4_RbaoXLs,2440
|
|
18
|
+
muffin_rest/peewee/handler.py,sha256=tfmRzCWCV41x_k9Ysma735MPsNOx-o4Bnp5hFIbSxu0,5354
|
|
19
|
+
muffin_rest/peewee/openapi.py,sha256=lDnLnoXi33p0YeFVwRgaVrndyrG2XL93RH-BzbxinOY,1105
|
|
20
|
+
muffin_rest/peewee/options.py,sha256=TimJtErC9e8B7BRiEkHiBZd71_bZbYr-FE2PIlQvfH0,1455
|
|
21
|
+
muffin_rest/peewee/schemas.py,sha256=w6jBziUp40mOOjkz_4RCXuY0x5ZDIe9Ob25k1FnZSfc,469
|
|
22
|
+
muffin_rest/peewee/sorting.py,sha256=aTLL2zYeNfkamfbGuKkIClOsJktdZZZlzZKafccWxyQ,1977
|
|
23
|
+
muffin_rest/peewee/types.py,sha256=cgCXhpGHkImKwudA1lulZHz5oJswHH168AiW5MhZRCM,155
|
|
24
|
+
muffin_rest/peewee/utils.py,sha256=wXeneVE1IZl1ROnY28re73H62Y1_tEmoEQYzPhuOyBI,702
|
|
25
|
+
muffin_rest/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
26
|
+
muffin_rest/redoc.html,sha256=GtuHIMvTuSi8Ro6bgI-G8VB94AljMyfjcZseqtBmGCY,559
|
|
27
|
+
muffin_rest/schemas.py,sha256=BW3dF82C6Q6STs4tZjej1x8Ii1rI3EZUJZR4mNNKmu4,875
|
|
28
|
+
muffin_rest/sorting.py,sha256=7k7dxElnEGiwvdfDivzcoLnAOXqpQoK52H-ss79Gw0g,2813
|
|
29
|
+
muffin_rest/sqlalchemy/__init__.py,sha256=9MSvOXWP6665LiA5O1Icl2V05bpz6Ex5sUHB9YWKLvE,6393
|
|
30
|
+
muffin_rest/sqlalchemy/filters.py,sha256=oVz3he9P_KiYE4fM3Wjka2IUmhkf4gHDOVRzKlz0SA0,2484
|
|
31
|
+
muffin_rest/sqlalchemy/sorting.py,sha256=YlFKpIet4TUy7fJ2UBLC8b9lAOwY66QBpPDDApbyh8M,1643
|
|
32
|
+
muffin_rest/sqlalchemy/types.py,sha256=Exm-zAQCtPAwXvYcCTtPRqSa-wTEWRcH_v2YSsJkB6s,198
|
|
33
|
+
muffin_rest/swagger.html,sha256=2uGLu_KpkYf925KnDKHBJmV9pm6OHn5C3BWScESsUS8,1736
|
|
34
|
+
muffin_rest/types.py,sha256=lU0tAaDu9sKm-30RM1_qOx-hOYEkNRaA72LdWDvg72g,491
|
|
35
|
+
muffin_rest/utils.py,sha256=c08E4HJ4SLYC-91GKPEbsyKTZ4sZbTN4qDqJbNg_HTE,2076
|
|
36
|
+
muffin_rest-8.1.0.dist-info/LICENSE,sha256=xHPkOZhjyKBMOwXpWn9IB_BVLjrrMxv2M9slKkHj2hM,1082
|
|
37
|
+
muffin_rest-8.1.0.dist-info/METADATA,sha256=nsCP8WeRU29Tdlfno68igQTZNIaZin39bWIfY2spMZI,4118
|
|
38
|
+
muffin_rest-8.1.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
|
39
|
+
muffin_rest-8.1.0.dist-info/RECORD,,
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
muffin_rest/__init__.py,sha256=NBZeOEJgQHtFFhVgd9d0fpApFRgU405sbm0cu1y1MOU,1242
|
|
2
|
-
muffin_rest/api.py,sha256=gCqRb5PgKEMkE84Y0ZnJw_laVRmVWZRxzBqBv0ns6w8,3882
|
|
3
|
-
muffin_rest/errors.py,sha256=TIXSADZYSwx70dOVPRAzuNwGLfpLuzZZ1ugMZMwIGDo,1169
|
|
4
|
-
muffin_rest/filters.py,sha256=lMnPMdy7h0nB9bH-Nf5Hv_qenAGzG0V3ULpizTK3OnM,5623
|
|
5
|
-
muffin_rest/handler.py,sha256=LcPFFH01nwe2tYKYbq-KVDX5UBVMlaRmeisJ_s4jsCo,10035
|
|
6
|
-
muffin_rest/marshmallow.py,sha256=hHPLTLdaSz5jTLWBqyHeOwo2xfBv7aMIuJFD_trHRuE,715
|
|
7
|
-
muffin_rest/mongo/__init__.py,sha256=unoEAKCU9H3EKhQqKGosn02tTS3H5nPOcTd3THM4Qs8,4675
|
|
8
|
-
muffin_rest/mongo/filters.py,sha256=y2FleM_BqkICKGq3PmM_StOgKlE7RoSxt2NdQfCvnOE,921
|
|
9
|
-
muffin_rest/mongo/schema.py,sha256=y4OEPQnlV_COTIIQ3cKmpqDpD2r18eAWn0rijQldWm0,1205
|
|
10
|
-
muffin_rest/mongo/sorting.py,sha256=iJBnaFwE7g_JMwpGpQkoqSqbQK9XULx1K3skiRRgLgY,870
|
|
11
|
-
muffin_rest/mongo/types.py,sha256=Otqu_FyIVnDAUGcwtzY_B77CKNBYApQPO_LlS2aLAQk,206
|
|
12
|
-
muffin_rest/mongo/utils.py,sha256=RZdAiKaTsgaFz8WbIPI_sjjVDsF_VmPu5Agzp5mpbvY,3946
|
|
13
|
-
muffin_rest/openapi.py,sha256=XNhU4EffbKvFKr1HMCqFM-ZrukPqLyZwm3aJe2dNs40,8770
|
|
14
|
-
muffin_rest/options.py,sha256=6K8YD3U-FdItFVjILQxH9h_Hwi68kgzWs97zrtSN_m4,2049
|
|
15
|
-
muffin_rest/peewee/__init__.py,sha256=94DSj_ftT6fbPksHlBv40AH2HWaiZommUFOMN2jd9a4,129
|
|
16
|
-
muffin_rest/peewee/filters.py,sha256=dgbvTBm_V2Iu3penv5y--OWSysQ0ZnUfgGCuHcQKfhU,2433
|
|
17
|
-
muffin_rest/peewee/handler.py,sha256=O84-TmiyNyEhEamTh-DY93ds0hZJzVG8_yekRpF9p3k,5359
|
|
18
|
-
muffin_rest/peewee/openapi.py,sha256=ZZuh7nJVuK9cTJqtOJ_XASe9iJgter-xIjj9YJ8xszI,1111
|
|
19
|
-
muffin_rest/peewee/options.py,sha256=F-UjCbz5rAIXt-D_MtmriiYkOL7wsri6FRt7WCNZzyo,1480
|
|
20
|
-
muffin_rest/peewee/schemas.py,sha256=w6jBziUp40mOOjkz_4RCXuY0x5ZDIe9Ob25k1FnZSfc,469
|
|
21
|
-
muffin_rest/peewee/sorting.py,sha256=jLU9d9h8uCV61NbsjkdhDLvh0lCK_6Os-0kIOI-pKwc,1983
|
|
22
|
-
muffin_rest/peewee/types.py,sha256=cgCXhpGHkImKwudA1lulZHz5oJswHH168AiW5MhZRCM,155
|
|
23
|
-
muffin_rest/peewee/utils.py,sha256=wXeneVE1IZl1ROnY28re73H62Y1_tEmoEQYzPhuOyBI,702
|
|
24
|
-
muffin_rest/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
25
|
-
muffin_rest/redoc.html,sha256=GtuHIMvTuSi8Ro6bgI-G8VB94AljMyfjcZseqtBmGCY,559
|
|
26
|
-
muffin_rest/schemas.py,sha256=FiNUuI7b70zgbg0F0p-CLUSpGmij9uBZDq9fUn9I_q4,814
|
|
27
|
-
muffin_rest/sorting.py,sha256=Z3echqt8ve-p1f7iOVYlFFVsA3ShCXVaXDkVMFTC6tQ,2887
|
|
28
|
-
muffin_rest/sqlalchemy/__init__.py,sha256=WYxb5d4eQqXeT8MwpgdiItAjrOdFm-_5GaG-xtWQ4lk,6362
|
|
29
|
-
muffin_rest/sqlalchemy/filters.py,sha256=bTT7ndx_d0YaDSviDfpzwN9T46dQrV9WbeG8YH9KVBg,2466
|
|
30
|
-
muffin_rest/sqlalchemy/sorting.py,sha256=YlFKpIet4TUy7fJ2UBLC8b9lAOwY66QBpPDDApbyh8M,1643
|
|
31
|
-
muffin_rest/sqlalchemy/types.py,sha256=JnIw44XJ2ClWzOv-mTUrvFw1JPxAlvdX_jf7r4zau-s,204
|
|
32
|
-
muffin_rest/swagger.html,sha256=2uGLu_KpkYf925KnDKHBJmV9pm6OHn5C3BWScESsUS8,1736
|
|
33
|
-
muffin_rest/types.py,sha256=vy55ShzMcvs9zXjFpdjWlagv09dMrcmxb2-U4hTL3NM,521
|
|
34
|
-
muffin_rest/utils.py,sha256=WT87AHXvBFBzBVTkwsYmDXgG3ZX1wNKFo4SOUJ9JiQY,2095
|
|
35
|
-
muffin_rest-7.3.5.dist-info/LICENSE,sha256=xHPkOZhjyKBMOwXpWn9IB_BVLjrrMxv2M9slKkHj2hM,1082
|
|
36
|
-
muffin_rest-7.3.5.dist-info/METADATA,sha256=3S9RVEQb7kpUDE8b1IWRZg6Q27bzmlIS0J7u5SpsRcI,4177
|
|
37
|
-
muffin_rest-7.3.5.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
|
38
|
-
muffin_rest-7.3.5.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|