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.
@@ -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"
@@ -0,0 +1,3 @@
1
+ from .spicedb import *
2
+ from .mediator import *
3
+ from .postgresql import *
@@ -0,0 +1,2 @@
1
+ from .nats import *
2
+ from .basic import *
@@ -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] == ">"