skoll 0.0.1__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.
- skoll/__init__.py +0 -0
- skoll/application/__init__.py +2 -0
- skoll/application/protocols.py +46 -0
- skoll/application/types.py +123 -0
- skoll/config.py +34 -0
- skoll/domain/__init__.py +4 -0
- skoll/domain/base.py +157 -0
- skoll/domain/enums.py +17 -0
- skoll/domain/objects.py +176 -0
- skoll/domain/primitives.py +319 -0
- skoll/errors.py +141 -0
- skoll/infras/__init__.py +3 -0
- skoll/infras/mediator/__init__.py +2 -0
- skoll/infras/mediator/basic.py +60 -0
- skoll/infras/mediator/nats.py +51 -0
- skoll/infras/mediator/utils.py +71 -0
- skoll/infras/postgresql.py +154 -0
- skoll/infras/spicedb.py +183 -0
- skoll/result.py +58 -0
- skoll/utils/__init__.py +2 -0
- skoll/utils/dep_injection.py +89 -0
- skoll/utils/functional.py +169 -0
- skoll-0.0.1.dist-info/METADATA +32 -0
- skoll-0.0.1.dist-info/RECORD +27 -0
- skoll-0.0.1.dist-info/WHEEL +4 -0
- skoll-0.0.1.dist-info/entry_points.txt +2 -0
- skoll-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import typing as t
|
|
5
|
+
from attrs import define, field
|
|
6
|
+
from datetime import datetime, timedelta, UTC
|
|
7
|
+
|
|
8
|
+
from skoll.errors import InvalidField
|
|
9
|
+
from skoll.domain.base import Object
|
|
10
|
+
from skoll.result import Result, ok, fail
|
|
11
|
+
from skoll.utils import new_ulid, to_tz, to_snake_case, safe_call
|
|
12
|
+
|
|
13
|
+
ID_REGEX = r"^[0-9a-z]{26}$"
|
|
14
|
+
EMAIL_REGEX = r"^[^@]+@[^@]+$"
|
|
15
|
+
TIME_REGEX = r"^(?:[01]?[0-9]|2[0-3]):[0-5][0-9]$"
|
|
16
|
+
LOCALE_PATTERN = r"^[a-z]{2,3}(-[A-Z][a-z]{3})?(-[A-Z]{2}|-[0-9]{3})?$"
|
|
17
|
+
|
|
18
|
+
__all__ = ["ID", "Time", "Email", "PositiveInt", "DateTime", "Latitude", "Longitude", "Locale", "LocalizedText"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@define(kw_only=True, slots=True, frozen=True)
|
|
22
|
+
class ID(Object):
|
|
23
|
+
|
|
24
|
+
value: str
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def new(cls) -> t.Self:
|
|
28
|
+
return cls(value=new_ulid())
|
|
29
|
+
|
|
30
|
+
@t.override
|
|
31
|
+
@classmethod
|
|
32
|
+
def prepare(cls, raw: t.Any) -> Result[t.Any]:
|
|
33
|
+
value = safe_call(str, raw)
|
|
34
|
+
if value is not None and re.fullmatch(ID_REGEX, value.strip()) is not None:
|
|
35
|
+
return ok(value.strip())
|
|
36
|
+
return fail(
|
|
37
|
+
InvalidField(
|
|
38
|
+
field=to_snake_case(cls.__name__),
|
|
39
|
+
hints={"expected": "string", "contraints": {"pattern": ID_REGEX}, "received": raw},
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@define(kw_only=True, slots=True, frozen=True)
|
|
45
|
+
class PositiveInt(Object):
|
|
46
|
+
|
|
47
|
+
value: int
|
|
48
|
+
|
|
49
|
+
def increment(self) -> t.Self:
|
|
50
|
+
return self.__class__(value=self.value + 1)
|
|
51
|
+
|
|
52
|
+
def decrement(self) -> t.Self:
|
|
53
|
+
return self.__class__(value=self.value - 1)
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def zero(cls) -> t.Self:
|
|
57
|
+
return cls(value=0)
|
|
58
|
+
|
|
59
|
+
@t.override
|
|
60
|
+
@classmethod
|
|
61
|
+
def prepare(cls, raw: t.Any) -> Result[t.Any]:
|
|
62
|
+
value = safe_call(int, raw)
|
|
63
|
+
if value is not None and value >= 0:
|
|
64
|
+
return ok(value)
|
|
65
|
+
return fail(
|
|
66
|
+
InvalidField(
|
|
67
|
+
field=to_snake_case(cls.__name__),
|
|
68
|
+
hints={"expected": "integer", "contraints": {"min": 0}, "received": raw},
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@define(kw_only=True, slots=True, frozen=True)
|
|
74
|
+
class DateTime(Object):
|
|
75
|
+
|
|
76
|
+
value: datetime
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def timestamp(self) -> int:
|
|
80
|
+
return int(self.value.timestamp() * 1000)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def week_day(self) -> int:
|
|
84
|
+
return self.value.weekday()
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def iso_format(self) -> str:
|
|
88
|
+
""" """
|
|
89
|
+
return self.value.strftime("%Y-%m-%dT%H:%M:%S") + "Z"
|
|
90
|
+
|
|
91
|
+
@t.override
|
|
92
|
+
def serialize(self):
|
|
93
|
+
return self.timestamp
|
|
94
|
+
|
|
95
|
+
def diff(self, other: t.Self) -> timedelta:
|
|
96
|
+
if other.value > self.value:
|
|
97
|
+
return other.value - self.value
|
|
98
|
+
return self.value - other.value
|
|
99
|
+
|
|
100
|
+
def plus(
|
|
101
|
+
self, days: int = 0, hours: int = 0, minutes: int = 0, seconds: int = 0, delta: timedelta | None = None
|
|
102
|
+
) -> DateTime:
|
|
103
|
+
new_date = (
|
|
104
|
+
self.value + timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds) + (delta or timedelta())
|
|
105
|
+
)
|
|
106
|
+
return DateTime(value=new_date)
|
|
107
|
+
|
|
108
|
+
def minus(
|
|
109
|
+
self, days: int = 0, hours: int = 0, minutes: int = 0, seconds: int = 0, delta: timedelta | None = None
|
|
110
|
+
) -> DateTime:
|
|
111
|
+
new_date = (
|
|
112
|
+
self.value - timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds) - (delta or timedelta())
|
|
113
|
+
)
|
|
114
|
+
return DateTime(value=new_date)
|
|
115
|
+
|
|
116
|
+
def __gt__(self, other: t.Self) -> bool:
|
|
117
|
+
return self.value > other.value
|
|
118
|
+
|
|
119
|
+
def __lt__(self, other: t.Self) -> bool:
|
|
120
|
+
return self.value < other.value
|
|
121
|
+
|
|
122
|
+
def __ge__(self, other: t.Self) -> bool:
|
|
123
|
+
return self.value >= other.value
|
|
124
|
+
|
|
125
|
+
def __le__(self, other: t.Self) -> bool:
|
|
126
|
+
return self.value <= other.value
|
|
127
|
+
|
|
128
|
+
def reset_second(self):
|
|
129
|
+
date = self.value.replace(second=0, microsecond=0)
|
|
130
|
+
return DateTime(value=date)
|
|
131
|
+
|
|
132
|
+
def reset_part(self, hour: bool = False, minute: bool = False):
|
|
133
|
+
date = self.value.replace(second=0, microsecond=0)
|
|
134
|
+
|
|
135
|
+
if hour:
|
|
136
|
+
date = date.replace(hour=0, minute=0)
|
|
137
|
+
elif minute:
|
|
138
|
+
date = date.replace(minute=0)
|
|
139
|
+
return DateTime(value=date)
|
|
140
|
+
|
|
141
|
+
def to_tz(self, tz_str: str = "UTC") -> DateTime:
|
|
142
|
+
return DateTime.from_timestamp(int(self.value.timestamp() * 1000), tz_str=tz_str)
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def today(cls, tz_str: str = "UTC") -> DateTime:
|
|
146
|
+
tz = to_tz(tz_str)
|
|
147
|
+
value = datetime.combine(datetime.now(tz=tz), datetime.min.time()).replace(tzinfo=tz)
|
|
148
|
+
return cls.from_timestamp(int(value.timestamp() * 1000), tz_str=tz_str)
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def tomorrow(cls, tz_str: str = "UTC") -> DateTime:
|
|
152
|
+
tz = to_tz(tz_str)
|
|
153
|
+
value = datetime.combine(datetime.now(tz=tz) + timedelta(days=1), datetime.min.time()).replace(tzinfo=tz)
|
|
154
|
+
return cls.from_timestamp(int(value.timestamp() * 1000), tz_str=tz_str)
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
def now(cls, tz_str: str = "UTC") -> DateTime:
|
|
158
|
+
tz = to_tz(tz_str)
|
|
159
|
+
value = int(datetime.now(tz=tz).timestamp() * 1000)
|
|
160
|
+
return cls.from_timestamp(value, tz_str=tz_str)
|
|
161
|
+
|
|
162
|
+
@classmethod
|
|
163
|
+
def from_timestamp(cls, timestamp: int, tz_str: str = "UTC") -> DateTime:
|
|
164
|
+
tz = to_tz(tz_str)
|
|
165
|
+
return DateTime(value=datetime.fromtimestamp(timestamp / 1000, tz=tz))
|
|
166
|
+
|
|
167
|
+
@t.override
|
|
168
|
+
@classmethod
|
|
169
|
+
def prepare(cls, raw: t.Any) -> Result[t.Any]:
|
|
170
|
+
value = safe_call(int, raw)
|
|
171
|
+
if value is not None and value >= 0:
|
|
172
|
+
return ok(datetime.fromtimestamp(value / 1000, tz=UTC))
|
|
173
|
+
return fail(
|
|
174
|
+
InvalidField(
|
|
175
|
+
field=to_snake_case(cls.__name__),
|
|
176
|
+
hints={"expected": "integer", "contraints": {"min": 0}, "received": raw},
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@define(kw_only=True, slots=True, frozen=True)
|
|
182
|
+
class Latitude(Object):
|
|
183
|
+
value: float
|
|
184
|
+
|
|
185
|
+
@t.override
|
|
186
|
+
@classmethod
|
|
187
|
+
def prepare(cls, raw: t.Any) -> Result[t.Any]:
|
|
188
|
+
value = safe_call(float, raw)
|
|
189
|
+
if value is not None and -90 <= value <= 90:
|
|
190
|
+
return ok(value)
|
|
191
|
+
return fail(
|
|
192
|
+
InvalidField(
|
|
193
|
+
field=to_snake_case(cls.__name__),
|
|
194
|
+
hints={"expected": "float", "contraints": {"min": -90, "max": 90}, "received": raw},
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@define(kw_only=True, slots=True, frozen=True)
|
|
200
|
+
class Longitude(Object):
|
|
201
|
+
value: float
|
|
202
|
+
|
|
203
|
+
@t.override
|
|
204
|
+
@classmethod
|
|
205
|
+
def prepare(cls, raw: t.Any) -> Result[t.Any]:
|
|
206
|
+
value = safe_call(float, raw)
|
|
207
|
+
if value is not None and -180 <= value <= 180:
|
|
208
|
+
return ok(value)
|
|
209
|
+
return fail(
|
|
210
|
+
InvalidField(
|
|
211
|
+
field=to_snake_case(cls.__name__),
|
|
212
|
+
hints={"expected": "float", "contraints": {"min": -180, "max": 180}, "received": raw},
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@define(kw_only=True, slots=True, frozen=True)
|
|
218
|
+
class Time(Object):
|
|
219
|
+
value: str
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def as_hour(self) -> float:
|
|
223
|
+
return self.hour + (self.minute / 60)
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def hour(self) -> int:
|
|
227
|
+
return int(self.value.split(":")[0])
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def minute(self) -> int:
|
|
231
|
+
return int(self.value.split(":")[1])
|
|
232
|
+
|
|
233
|
+
@t.override
|
|
234
|
+
@classmethod
|
|
235
|
+
def prepare(cls, raw: t.Any) -> Result[t.Any]:
|
|
236
|
+
value = safe_call(str, raw)
|
|
237
|
+
if value is not None and re.fullmatch(TIME_REGEX, value.strip()) is not None:
|
|
238
|
+
return ok(value.strip())
|
|
239
|
+
|
|
240
|
+
return fail(
|
|
241
|
+
InvalidField(
|
|
242
|
+
field=to_snake_case(cls.__name__),
|
|
243
|
+
hints={"expected": "string", "contraints": {"pattern": TIME_REGEX}, "received": raw},
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@define(kw_only=True, slots=True, frozen=True)
|
|
249
|
+
class Email(Object):
|
|
250
|
+
|
|
251
|
+
value: str
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def name(self) -> str:
|
|
255
|
+
return self.value.split("@")[0]
|
|
256
|
+
|
|
257
|
+
@classmethod
|
|
258
|
+
def anonymous(cls, id: ID) -> Email:
|
|
259
|
+
return cls(value=f"{id.value}.no-reply@email.com")
|
|
260
|
+
|
|
261
|
+
@t.override
|
|
262
|
+
@classmethod
|
|
263
|
+
def prepare(cls, raw: t.Any) -> Result[t.Any]:
|
|
264
|
+
value = safe_call(str, raw)
|
|
265
|
+
if value is not None and re.fullmatch(EMAIL_REGEX, value.strip()) is not None:
|
|
266
|
+
return ok(value.strip().lower())
|
|
267
|
+
return fail(
|
|
268
|
+
InvalidField(
|
|
269
|
+
field=to_snake_case(cls.__name__),
|
|
270
|
+
hints={"expected": "string", "contraints": {"pattern": EMAIL_REGEX}, "received": raw},
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@define(kw_only=True, slots=True, frozen=True)
|
|
276
|
+
class Locale(Object):
|
|
277
|
+
|
|
278
|
+
value: str
|
|
279
|
+
|
|
280
|
+
@t.override
|
|
281
|
+
@classmethod
|
|
282
|
+
def prepare(cls, raw: t.Any) -> Result[t.Any]:
|
|
283
|
+
value = safe_call(str, raw)
|
|
284
|
+
if value is not None and re.fullmatch(LOCALE_PATTERN, value.strip()) is not None:
|
|
285
|
+
return ok(value.strip().lower())
|
|
286
|
+
return fail(
|
|
287
|
+
InvalidField(
|
|
288
|
+
field=to_snake_case(cls.__name__),
|
|
289
|
+
hints={"received": raw, "expected": "BCP47Locale"},
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@define(kw_only=True, slots=True, frozen=True)
|
|
295
|
+
class LocalizedText(Object):
|
|
296
|
+
|
|
297
|
+
value: dict[str, str] = field(factory=dict)
|
|
298
|
+
|
|
299
|
+
@t.override
|
|
300
|
+
@classmethod
|
|
301
|
+
def prepare(cls, raw: t.Any) -> Result[t.Any]:
|
|
302
|
+
value = safe_call(dict, raw)
|
|
303
|
+
|
|
304
|
+
if value is not None:
|
|
305
|
+
keys_valid = all(isinstance(k, str) and re.fullmatch(LOCALE_PATTERN, k) for k in value.keys())
|
|
306
|
+
values_valid = all(isinstance(v, str) for v in value.values())
|
|
307
|
+
if keys_valid and values_valid:
|
|
308
|
+
return ok(t.cast(dict[str, str], value))
|
|
309
|
+
|
|
310
|
+
return fail(
|
|
311
|
+
InvalidField(
|
|
312
|
+
field=to_snake_case(cls.__name__),
|
|
313
|
+
hints={
|
|
314
|
+
"received": raw,
|
|
315
|
+
"expected": "Dictionaire<BCP47Locale, string>",
|
|
316
|
+
"example": {"en-US": "English", "en": "An example"},
|
|
317
|
+
},
|
|
318
|
+
)
|
|
319
|
+
)
|
skoll/errors.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import attrs
|
|
2
|
+
import typing as t
|
|
3
|
+
|
|
4
|
+
from skoll.utils import sanitize_dict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Error",
|
|
9
|
+
"NotFound",
|
|
10
|
+
"Conflict",
|
|
11
|
+
"Forbidden",
|
|
12
|
+
"BadRequest",
|
|
13
|
+
"InvalidField",
|
|
14
|
+
"MissingField",
|
|
15
|
+
"InternalError",
|
|
16
|
+
"Unauthenticated",
|
|
17
|
+
"ValidationFailed",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
type ErrorStatusCode = t.Literal[500, 400, 401, 403, 404, 409]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@attrs.define(kw_only=True, slots=True)
|
|
24
|
+
class Error(Exception):
|
|
25
|
+
|
|
26
|
+
code: str
|
|
27
|
+
field: str | None = None
|
|
28
|
+
status: ErrorStatusCode | None = None
|
|
29
|
+
detail: str = "An expected error occurred."
|
|
30
|
+
errors: list["Error"] = attrs.field(factory=list)
|
|
31
|
+
debug: dict[str, t.Any] = attrs.field(factory=dict)
|
|
32
|
+
hints: dict[str, t.Any] = attrs.field(factory=dict)
|
|
33
|
+
|
|
34
|
+
def serialize(
|
|
35
|
+
self,
|
|
36
|
+
public_view: bool = False,
|
|
37
|
+
trace_id: str | None = None,
|
|
38
|
+
) -> dict[str, t.Any]:
|
|
39
|
+
err_json: dict[str, t.Any] = {
|
|
40
|
+
"code": self.code,
|
|
41
|
+
"field": self.field,
|
|
42
|
+
"hints": self.hints,
|
|
43
|
+
"trace_id": trace_id,
|
|
44
|
+
"detail": self.detail,
|
|
45
|
+
"status": self.status,
|
|
46
|
+
"debug": self.debug if public_view is False else None,
|
|
47
|
+
"errors": [sub.serialize(public_view=public_view) for sub in self.errors],
|
|
48
|
+
}
|
|
49
|
+
return sanitize_dict(err_json)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@attrs.define(kw_only=True, slots=True)
|
|
53
|
+
class MissingField(Error):
|
|
54
|
+
|
|
55
|
+
code: str = attrs.field(default="missing_field", init=False)
|
|
56
|
+
hints: dict[str, t.Any] = attrs.field(factory=dict, init=False)
|
|
57
|
+
debug: dict[str, t.Any] = attrs.field(factory=dict, init=False)
|
|
58
|
+
status: ErrorStatusCode | None = attrs.field(default=None, init=False)
|
|
59
|
+
detail: str = attrs.field(default="This field is required to process your request", init=False)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@attrs.define(kw_only=True, slots=True)
|
|
63
|
+
class InvalidField(Error):
|
|
64
|
+
|
|
65
|
+
code: str = attrs.field(default="invalid_field", init=False)
|
|
66
|
+
debug: dict[str, t.Any] = attrs.field(factory=dict, init=False)
|
|
67
|
+
status: ErrorStatusCode | None = attrs.field(default=None, init=False)
|
|
68
|
+
detail: str = attrs.field(default="This field has invalid data, see hints for more details", init=False)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@attrs.define(kw_only=True, slots=True)
|
|
72
|
+
class InternalError(Error):
|
|
73
|
+
|
|
74
|
+
field: str | None = attrs.field(default=None, init=False)
|
|
75
|
+
code: str = attrs.field(default="internal_error", init=False)
|
|
76
|
+
hints: dict[str, t.Any] = attrs.field(factory=dict, init=False)
|
|
77
|
+
status: ErrorStatusCode | None = attrs.field(default=500, init=False)
|
|
78
|
+
detail: str = attrs.field(default="An expected error occurred. Please try again later.", init=False)
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def from_exception(cls, exc: Exception, extra: dict[str, t.Any] | None = None) -> t.Self:
|
|
82
|
+
return cls(debug={**(extra or {}), **{"message": str(exc)}})
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@attrs.define(kw_only=True, slots=True)
|
|
86
|
+
class BadRequest(Error):
|
|
87
|
+
|
|
88
|
+
field: str | None = attrs.field(default=None, init=False)
|
|
89
|
+
code: str = attrs.field(default="bad_request", init=False)
|
|
90
|
+
status: ErrorStatusCode | None = attrs.field(default=400, init=False)
|
|
91
|
+
detail: str = attrs.field(default="Your request is invalid. Check hints for more details", init=False)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@attrs.define(kw_only=True, slots=True)
|
|
95
|
+
class ValidationFailed(Error):
|
|
96
|
+
|
|
97
|
+
field: str | None = attrs.field(default=None, init=False)
|
|
98
|
+
debug: dict[str, t.Any] = attrs.field(factory=dict, init=False)
|
|
99
|
+
code: str = attrs.field(default="validation_failed", init=False)
|
|
100
|
+
status: ErrorStatusCode | None = attrs.field(default=400, init=False)
|
|
101
|
+
detail: str = attrs.field(
|
|
102
|
+
default="Your request contains invalid or missing data. Check hints for more details", init=False
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@attrs.define(kw_only=True, slots=True)
|
|
107
|
+
class Unauthenticated(Error):
|
|
108
|
+
|
|
109
|
+
field: str | None = attrs.field(default=None, init=False)
|
|
110
|
+
code: str = attrs.field(default="unauthenticated", init=False)
|
|
111
|
+
status: ErrorStatusCode | None = attrs.field(default=401, init=False)
|
|
112
|
+
detail: str = attrs.field(default="This request requires is only allowed for authenticated users", init=False)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@attrs.define(kw_only=True, slots=True)
|
|
116
|
+
class Forbidden(Error):
|
|
117
|
+
|
|
118
|
+
code: str = attrs.field(default="forbidden", init=False)
|
|
119
|
+
field: str | None = attrs.field(default=None, init=False)
|
|
120
|
+
status: ErrorStatusCode | None = attrs.field(default=403, init=False)
|
|
121
|
+
detail: str = attrs.field(
|
|
122
|
+
default="You do not have required privilege for this operation. See hints for more details", init=False
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@attrs.define(kw_only=True, slots=True)
|
|
127
|
+
class NotFound(Error):
|
|
128
|
+
|
|
129
|
+
code: str = attrs.field(default="not_found", init=False)
|
|
130
|
+
field: str | None = attrs.field(default=None, init=False)
|
|
131
|
+
status: ErrorStatusCode | None = attrs.field(default=404, init=False)
|
|
132
|
+
detail: str = attrs.field(default="There is no resource corresponding to your request", init=False)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@attrs.define(kw_only=True, slots=True)
|
|
136
|
+
class Conflict(Error):
|
|
137
|
+
|
|
138
|
+
code: str = attrs.field(default="conflict", init=False)
|
|
139
|
+
field: str | None = attrs.field(default=None, init=False)
|
|
140
|
+
status: ErrorStatusCode | None = attrs.field(default=409, init=False)
|
|
141
|
+
detail: str = "Can not perform this operation since it will put the system in an inconsistent state"
|
skoll/infras/__init__.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import typing as t
|
|
2
|
+
from attrs import define, field
|
|
3
|
+
from asyncio import Queue, Event, create_task, wait
|
|
4
|
+
|
|
5
|
+
from skoll.domain import Message
|
|
6
|
+
from skoll.result import Result, fail
|
|
7
|
+
from skoll.application import Subscriber, MissingSubscriber, Mediator, Service, RawMessage
|
|
8
|
+
|
|
9
|
+
from .utils import run_callback, is_subscribed
|
|
10
|
+
|
|
11
|
+
__all__ = ["BasicMediator"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@define(kw_only=True, slots=True)
|
|
15
|
+
class BasicMediator(Mediator):
|
|
16
|
+
"""Basic in-memory mediator implementation."""
|
|
17
|
+
|
|
18
|
+
queue: Queue[Message | RawMessage] = field(factory=Queue)
|
|
19
|
+
stop_signal: Event = field(factory=Event)
|
|
20
|
+
subscribers: list[Subscriber] = field(factory=list)
|
|
21
|
+
handlers: dict[str, Subscriber] = field(factory=dict)
|
|
22
|
+
|
|
23
|
+
@t.override
|
|
24
|
+
async def publish(self, msg: Message | RawMessage) -> None:
|
|
25
|
+
self.queue.put_nowait(msg)
|
|
26
|
+
|
|
27
|
+
@t.override
|
|
28
|
+
async def disconnect(self):
|
|
29
|
+
self.stop_signal.set()
|
|
30
|
+
|
|
31
|
+
@t.override
|
|
32
|
+
async def request(self, msg: Message | RawMessage) -> Result[t.Any]:
|
|
33
|
+
topic = msg.topic if isinstance(msg, Message) else msg["topic"]
|
|
34
|
+
subscriber = self.handlers.get(topic)
|
|
35
|
+
if subscriber is not None:
|
|
36
|
+
return await run_callback(subscriber, msg)
|
|
37
|
+
return fail(err=MissingSubscriber(debug={"subject": topic}))
|
|
38
|
+
|
|
39
|
+
@t.override
|
|
40
|
+
def register(self, *services: Service) -> None:
|
|
41
|
+
for service in services:
|
|
42
|
+
for subscriber in service.subscribers:
|
|
43
|
+
if subscriber.with_reply:
|
|
44
|
+
self.handlers[subscriber.topic] = subscriber
|
|
45
|
+
else:
|
|
46
|
+
self.subscribers.append(subscriber)
|
|
47
|
+
|
|
48
|
+
@t.override
|
|
49
|
+
async def connect(self):
|
|
50
|
+
while not self.stop_signal.is_set() or not self.queue.empty():
|
|
51
|
+
try:
|
|
52
|
+
msg = await self.queue.get()
|
|
53
|
+
tasks = [
|
|
54
|
+
create_task(run_callback(subscriber, msg))
|
|
55
|
+
for subscriber in self.subscribers
|
|
56
|
+
if is_subscribed(subscriber, msg)
|
|
57
|
+
]
|
|
58
|
+
await wait(tasks)
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
print(exc)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import typing as t
|
|
2
|
+
from skoll.domain import Message
|
|
3
|
+
from skoll.result import Result, ok
|
|
4
|
+
from skoll.config import NATSConfig
|
|
5
|
+
from skoll.application.types import RawMessage
|
|
6
|
+
from skoll.application import Mediator, Subscriber, Service
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
__all__ = ["NATSMediator"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NATSMediator(Mediator):
|
|
13
|
+
"""NATS mediator implementation."""
|
|
14
|
+
|
|
15
|
+
seed: str
|
|
16
|
+
urls: list[str]
|
|
17
|
+
subscribers: list[Subscriber]
|
|
18
|
+
handlers: dict[str, Subscriber]
|
|
19
|
+
|
|
20
|
+
def __init__(self, config: NATSConfig) -> None:
|
|
21
|
+
if config.urls is None or config.seed is None:
|
|
22
|
+
raise ValueError("NATS URLs and seed are required")
|
|
23
|
+
self.urls = config.urls.split(",")
|
|
24
|
+
self.seed = config.seed
|
|
25
|
+
self.handlers = {}
|
|
26
|
+
self.subscribers = []
|
|
27
|
+
|
|
28
|
+
@t.override
|
|
29
|
+
def register(self, *services: Service) -> None:
|
|
30
|
+
for service in services:
|
|
31
|
+
for subscriber in service.subscribers:
|
|
32
|
+
if subscriber.with_reply:
|
|
33
|
+
self.handlers[subscriber.topic] = subscriber
|
|
34
|
+
else:
|
|
35
|
+
self.subscribers.append(subscriber)
|
|
36
|
+
|
|
37
|
+
@t.override
|
|
38
|
+
async def connect(self) -> None:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
@t.override
|
|
42
|
+
async def disconnect(self) -> None:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
@t.override
|
|
46
|
+
async def publish(self, msg: Message | RawMessage) -> None:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
@t.override
|
|
50
|
+
async def request(self, msg: Message | RawMessage) -> Result[t.Any]:
|
|
51
|
+
return ok(None)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import typing as t
|
|
2
|
+
|
|
3
|
+
from skoll.domain import Message
|
|
4
|
+
from skoll.result import Result, fail, is_fail
|
|
5
|
+
from skoll.utils import call_with_dependencies
|
|
6
|
+
from skoll.application import Subscriber, RawMessage
|
|
7
|
+
from skoll.errors import Error, InternalError, ValidationFailed
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def run_callback(subscriber: Subscriber, message: Message | RawMessage) -> Result[t.Any]:
|
|
11
|
+
topic = message.topic if isinstance(message, Message) else message.get("topic")
|
|
12
|
+
try:
|
|
13
|
+
msg = get_message(subscriber.msg_cls, message)
|
|
14
|
+
return await call_with_dependencies(subscriber.callback, {subscriber.msg_arg: msg})
|
|
15
|
+
except Error as err:
|
|
16
|
+
return fail(err=err)
|
|
17
|
+
except Exception as exc:
|
|
18
|
+
return fail(err=InternalError.from_exception(exc, extra={"subject": topic, "message": message}))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_message(cls: type[Message], message: Message | RawMessage) -> Message:
|
|
22
|
+
if isinstance(message, Message):
|
|
23
|
+
return message
|
|
24
|
+
res = cls.create(raw=message)
|
|
25
|
+
if is_fail(res):
|
|
26
|
+
raise ValidationFailed(errors=res.err.errors)
|
|
27
|
+
|
|
28
|
+
return res.value
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_subscribed(subscriber: Subscriber, message: Message | RawMessage) -> bool:
|
|
32
|
+
topic = message.topic if isinstance(message, Message) else message.get("topic")
|
|
33
|
+
s = topic.split(".") if topic else []
|
|
34
|
+
p = subscriber.topic.split(".") if subscriber.topic else []
|
|
35
|
+
|
|
36
|
+
i = j = 0
|
|
37
|
+
while i < len(s) and j < len(p):
|
|
38
|
+
if j + 1 < len(p) and p[j + 1] == ">":
|
|
39
|
+
# Next token is '>', so this token must match exactly
|
|
40
|
+
if p[j] != "*" and p[j] != s[i]:
|
|
41
|
+
return False
|
|
42
|
+
j += 2
|
|
43
|
+
i += 1
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
if p[j] == ">":
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
if p[j] == "*":
|
|
50
|
+
i += 1
|
|
51
|
+
j += 1
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
if p[j] != s[i]:
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
i += 1
|
|
58
|
+
j += 1
|
|
59
|
+
|
|
60
|
+
if i == len(s):
|
|
61
|
+
# Check remaining pattern tokens
|
|
62
|
+
while j < len(p):
|
|
63
|
+
if p[j] == ">":
|
|
64
|
+
return True
|
|
65
|
+
if p[j] != "*":
|
|
66
|
+
return False
|
|
67
|
+
j += 1
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
# Subject has leftover tokens
|
|
71
|
+
return j < len(p) and p[j] == ">"
|