graphrefly 0.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.
- graphrefly/__init__.py +160 -0
- graphrefly/compat/__init__.py +18 -0
- graphrefly/compat/async_utils.py +228 -0
- graphrefly/compat/asyncio_runner.py +89 -0
- graphrefly/compat/trio_runner.py +81 -0
- graphrefly/core/__init__.py +142 -0
- graphrefly/core/clock.py +20 -0
- graphrefly/core/dynamic_node.py +749 -0
- graphrefly/core/guard.py +277 -0
- graphrefly/core/meta.py +149 -0
- graphrefly/core/node.py +963 -0
- graphrefly/core/protocol.py +460 -0
- graphrefly/core/runner.py +107 -0
- graphrefly/core/subgraph_locks.py +296 -0
- graphrefly/core/sugar.py +138 -0
- graphrefly/core/versioning.py +193 -0
- graphrefly/extra/__init__.py +313 -0
- graphrefly/extra/adapters.py +2149 -0
- graphrefly/extra/backoff.py +287 -0
- graphrefly/extra/backpressure.py +113 -0
- graphrefly/extra/checkpoint.py +307 -0
- graphrefly/extra/composite.py +303 -0
- graphrefly/extra/cron.py +133 -0
- graphrefly/extra/data_structures.py +707 -0
- graphrefly/extra/resilience.py +727 -0
- graphrefly/extra/sources.py +766 -0
- graphrefly/extra/tier1.py +1067 -0
- graphrefly/extra/tier2.py +1802 -0
- graphrefly/graph/__init__.py +31 -0
- graphrefly/graph/graph.py +2249 -0
- graphrefly/integrations/__init__.py +1 -0
- graphrefly/integrations/fastapi.py +767 -0
- graphrefly/patterns/__init__.py +5 -0
- graphrefly/patterns/ai.py +2132 -0
- graphrefly/patterns/cqrs.py +515 -0
- graphrefly/patterns/memory.py +639 -0
- graphrefly/patterns/messaging.py +553 -0
- graphrefly/patterns/orchestration.py +536 -0
- graphrefly/patterns/reactive_layout/__init__.py +81 -0
- graphrefly/patterns/reactive_layout/measurement_adapters.py +276 -0
- graphrefly/patterns/reactive_layout/reactive_block_layout.py +434 -0
- graphrefly/patterns/reactive_layout/reactive_layout.py +943 -0
- graphrefly/py.typed +1 -0
- graphrefly-0.1.0.dist-info/METADATA +253 -0
- graphrefly-0.1.0.dist-info/RECORD +47 -0
- graphrefly-0.1.0.dist-info/WHEEL +4 -0
- graphrefly-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Composite data patterns (roadmap §3.2b).
|
|
2
|
+
|
|
3
|
+
These helpers compose existing primitives without introducing new protocol semantics.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import AsyncIterable, Awaitable, Iterable, Mapping
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import TYPE_CHECKING, Any, NotRequired, TypedDict, cast
|
|
11
|
+
|
|
12
|
+
from graphrefly.core.dynamic_node import dynamic_node
|
|
13
|
+
from graphrefly.core.node import Node
|
|
14
|
+
from graphrefly.core.protocol import MessageType, batch
|
|
15
|
+
from graphrefly.core.sugar import derived, state
|
|
16
|
+
from graphrefly.extra.data_structures import ReactiveMapBundle, reactive_map
|
|
17
|
+
from graphrefly.extra.sources import for_each, from_any, of
|
|
18
|
+
from graphrefly.extra.tier1 import merge
|
|
19
|
+
from graphrefly.extra.tier2 import switch_map
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class VerifiableBundle:
|
|
27
|
+
"""Result of :func:`verifiable`."""
|
|
28
|
+
|
|
29
|
+
node: Node[Any]
|
|
30
|
+
verified: Node[Any]
|
|
31
|
+
trigger: Node[Any] | None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def verifiable(
|
|
35
|
+
source: Any,
|
|
36
|
+
verify_fn: Callable[[Any], Any],
|
|
37
|
+
*,
|
|
38
|
+
trigger: Any | None = None,
|
|
39
|
+
auto_verify: bool = False,
|
|
40
|
+
initial_verified: Any = None,
|
|
41
|
+
) -> VerifiableBundle:
|
|
42
|
+
"""Compose a value node with a reactive verification companion.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
source: Value source (`Node`, scalar, awaitable, iterable, async iterable).
|
|
46
|
+
verify_fn: Verification function returning a `NodeInput`.
|
|
47
|
+
trigger: Optional reactive trigger; each `DATA` triggers verification.
|
|
48
|
+
auto_verify: When true, source value changes also trigger verification.
|
|
49
|
+
initial_verified: Initial value for the verification companion.
|
|
50
|
+
"""
|
|
51
|
+
source_node = from_any(source)
|
|
52
|
+
has_source_versioning = source_node.v is not None
|
|
53
|
+
meta_opts: dict[str, Any] = {"source_version": None} if has_source_versioning else {}
|
|
54
|
+
verified_node = state(initial_verified, **({"meta": meta_opts} if meta_opts else {}))
|
|
55
|
+
|
|
56
|
+
trigger_node: Node[Any] | None = None
|
|
57
|
+
if trigger is not None and auto_verify:
|
|
58
|
+
trigger_node = merge(from_any(trigger), source_node)
|
|
59
|
+
elif trigger is not None:
|
|
60
|
+
trigger_node = from_any(trigger)
|
|
61
|
+
elif auto_verify:
|
|
62
|
+
trigger_node = source_node
|
|
63
|
+
|
|
64
|
+
if trigger_node is not None:
|
|
65
|
+
verify_stream = switch_map(
|
|
66
|
+
lambda _t: _coerce_node_input(verify_fn(source_node.get())),
|
|
67
|
+
)(trigger_node)
|
|
68
|
+
|
|
69
|
+
def _on_verified(value: Any) -> None:
|
|
70
|
+
with batch():
|
|
71
|
+
verified_node.down([(MessageType.DATA, value)])
|
|
72
|
+
# V0 backfill: stamp which source version was verified (§6.0b).
|
|
73
|
+
if has_source_versioning:
|
|
74
|
+
sv = source_node.v
|
|
75
|
+
if sv is not None:
|
|
76
|
+
verified_node.meta["source_version"].down(
|
|
77
|
+
[(MessageType.DATA, {"id": sv.id, "version": sv.version})]
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
for_each(
|
|
81
|
+
verify_stream,
|
|
82
|
+
_on_verified,
|
|
83
|
+
on_error=lambda _err: None,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return VerifiableBundle(node=source_node, verified=verified_node, trigger=trigger_node)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Extraction(TypedDict):
|
|
90
|
+
"""Shape consumed by :func:`distill` extraction stages."""
|
|
91
|
+
|
|
92
|
+
upsert: list[dict[str, Any]]
|
|
93
|
+
remove: NotRequired[list[str]]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True, slots=True)
|
|
97
|
+
class DistillBundle:
|
|
98
|
+
"""Result of :func:`distill`."""
|
|
99
|
+
|
|
100
|
+
store: ReactiveMapBundle
|
|
101
|
+
compact: Node[Any]
|
|
102
|
+
size: Node[int]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _snapshot_map(store: ReactiveMapBundle) -> Mapping[str, Any]:
|
|
106
|
+
snap = store.data.get()
|
|
107
|
+
if snap is None:
|
|
108
|
+
return {}
|
|
109
|
+
if hasattr(snap, "value"):
|
|
110
|
+
return cast("Mapping[str, Any]", snap.value)
|
|
111
|
+
return cast("Mapping[str, Any]", snap)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _apply_extraction(store: ReactiveMapBundle, extraction: Extraction) -> None:
|
|
115
|
+
upsert = extraction.get("upsert")
|
|
116
|
+
if not isinstance(upsert, list):
|
|
117
|
+
msg = "distill extraction requires upsert: list[{key,value}]"
|
|
118
|
+
raise TypeError(msg)
|
|
119
|
+
with batch():
|
|
120
|
+
for row in upsert:
|
|
121
|
+
store.set(row["key"], row["value"])
|
|
122
|
+
for key in extraction.get("remove", []):
|
|
123
|
+
store.delete(key)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def distill(
|
|
127
|
+
source: Any,
|
|
128
|
+
extract_fn: Callable[[Any, Mapping[str, Any]], Any],
|
|
129
|
+
*,
|
|
130
|
+
score: Callable[[Any, Any], float],
|
|
131
|
+
cost: Callable[[Any], float],
|
|
132
|
+
budget: float = 2000,
|
|
133
|
+
evict: Callable[[str, Any], Any] | None = None,
|
|
134
|
+
consolidate: Callable[[Mapping[str, Any]], Any] | None = None,
|
|
135
|
+
consolidate_trigger: Any | None = None,
|
|
136
|
+
context: Any | None = None,
|
|
137
|
+
map_options: dict[str, Any] | None = None,
|
|
138
|
+
map_name: str | None = None,
|
|
139
|
+
map_default_ttl: float | None = None,
|
|
140
|
+
map_max_size: int | None = None,
|
|
141
|
+
) -> DistillBundle:
|
|
142
|
+
"""Budget-constrained reactive memory composition.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
source: Source stream to distill.
|
|
146
|
+
extract_fn: `(raw, existing) -> Extraction` as `NodeInput`.
|
|
147
|
+
score: Relevance function for compact packing.
|
|
148
|
+
cost: Cost function for compact packing.
|
|
149
|
+
budget: Maximum compact budget (default `2000`).
|
|
150
|
+
evict: Optional reactive eviction predicate per key.
|
|
151
|
+
consolidate: Optional consolidation function.
|
|
152
|
+
consolidate_trigger: Optional trigger for consolidation.
|
|
153
|
+
context: Optional context source affecting compact ranking.
|
|
154
|
+
map_options: Optional dict-style map config parity with TS (`name`,
|
|
155
|
+
`default_ttl`, `max_size`).
|
|
156
|
+
map_name: Optional underlying map node name.
|
|
157
|
+
map_default_ttl: Optional default TTL seconds for underlying map.
|
|
158
|
+
map_max_size: Optional underlying map max size.
|
|
159
|
+
"""
|
|
160
|
+
source_node = from_any(source)
|
|
161
|
+
context_node = from_any(context) if context is not None else state(None)
|
|
162
|
+
resolved_default_ttl = (
|
|
163
|
+
map_options["default_ttl"]
|
|
164
|
+
if map_options and "default_ttl" in map_options
|
|
165
|
+
else map_default_ttl
|
|
166
|
+
)
|
|
167
|
+
resolved_max_size = (
|
|
168
|
+
map_options["max_size"] if map_options and "max_size" in map_options else map_max_size
|
|
169
|
+
)
|
|
170
|
+
resolved_name = map_options["name"] if map_options and "name" in map_options else map_name
|
|
171
|
+
store = reactive_map(
|
|
172
|
+
default_ttl=resolved_default_ttl,
|
|
173
|
+
max_size=resolved_max_size,
|
|
174
|
+
name=resolved_name,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
extraction_stream = switch_map(
|
|
178
|
+
lambda raw: _coerce_node_input(extract_fn(raw, _snapshot_map(store))),
|
|
179
|
+
)(source_node)
|
|
180
|
+
for_each(
|
|
181
|
+
extraction_stream,
|
|
182
|
+
lambda ex: _apply_extraction(store, ex),
|
|
183
|
+
on_error=lambda _err: None,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
if evict is not None:
|
|
187
|
+
eviction_keys = dynamic_node(
|
|
188
|
+
lambda get: _compute_evictions(get, store, evict),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def _delete_keys(keys: list[str]) -> None:
|
|
192
|
+
for key in keys:
|
|
193
|
+
store.delete(key)
|
|
194
|
+
|
|
195
|
+
for_each(
|
|
196
|
+
cast("Node[Any]", eviction_keys),
|
|
197
|
+
_delete_keys,
|
|
198
|
+
on_error=lambda _err: None,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
if consolidate is not None and consolidate_trigger is not None:
|
|
202
|
+
consolidation_stream = switch_map(
|
|
203
|
+
lambda _t: _coerce_node_input(consolidate(_snapshot_map(store))),
|
|
204
|
+
)(from_any(consolidate_trigger))
|
|
205
|
+
for_each(
|
|
206
|
+
consolidation_stream,
|
|
207
|
+
lambda ex: _apply_extraction(store, ex),
|
|
208
|
+
on_error=lambda _err: None,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
compact = derived(
|
|
212
|
+
[store.data, context_node],
|
|
213
|
+
lambda deps, _a: _pack_compact(
|
|
214
|
+
deps[0].value if hasattr(deps[0], "value") else deps[0],
|
|
215
|
+
deps[1],
|
|
216
|
+
score,
|
|
217
|
+
cost,
|
|
218
|
+
budget,
|
|
219
|
+
),
|
|
220
|
+
)
|
|
221
|
+
size = derived(
|
|
222
|
+
[store.data],
|
|
223
|
+
lambda deps, _a: len(deps[0].value if hasattr(deps[0], "value") else deps[0]),
|
|
224
|
+
initial=0,
|
|
225
|
+
)
|
|
226
|
+
compact.subscribe(lambda _msgs: None)
|
|
227
|
+
size.subscribe(lambda _msgs: None)
|
|
228
|
+
|
|
229
|
+
return DistillBundle(store=store, compact=compact, size=size)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _coerce_node_input(value: Any) -> Node[Any]:
|
|
233
|
+
if isinstance(value, Node):
|
|
234
|
+
return value
|
|
235
|
+
if isinstance(value, AsyncIterable):
|
|
236
|
+
return from_any(value)
|
|
237
|
+
if isinstance(value, Awaitable):
|
|
238
|
+
return from_any(value)
|
|
239
|
+
if isinstance(value, Mapping):
|
|
240
|
+
return of(value)
|
|
241
|
+
if isinstance(value, (str, bytes, bytearray)):
|
|
242
|
+
return of(value)
|
|
243
|
+
if isinstance(value, Iterable):
|
|
244
|
+
return from_any(value)
|
|
245
|
+
return of(value)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _compute_evictions(
|
|
249
|
+
get: Callable[[Node[Any]], Any],
|
|
250
|
+
store: ReactiveMapBundle,
|
|
251
|
+
evict: Callable[[str, Any], Any],
|
|
252
|
+
) -> list[str]:
|
|
253
|
+
out: list[str] = []
|
|
254
|
+
current = get(store.data)
|
|
255
|
+
snapshot = current.value if hasattr(current, "value") else current
|
|
256
|
+
for key, mem in snapshot.items():
|
|
257
|
+
verdict = evict(key, mem)
|
|
258
|
+
if isinstance(verdict, Node):
|
|
259
|
+
if get(verdict) is True:
|
|
260
|
+
out.append(key)
|
|
261
|
+
continue
|
|
262
|
+
if isinstance(verdict, bool):
|
|
263
|
+
if verdict:
|
|
264
|
+
out.append(key)
|
|
265
|
+
continue
|
|
266
|
+
msg = "distill evict() must return bool or Node[bool] for reactive tracking"
|
|
267
|
+
raise TypeError(msg)
|
|
268
|
+
return out
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _pack_compact(
|
|
272
|
+
snapshot: Mapping[str, Any],
|
|
273
|
+
context: Any,
|
|
274
|
+
score: Callable[[Any, Any], float],
|
|
275
|
+
cost: Callable[[Any], float],
|
|
276
|
+
budget: float,
|
|
277
|
+
) -> list[dict[str, Any]]:
|
|
278
|
+
ranked = [
|
|
279
|
+
{
|
|
280
|
+
"key": key,
|
|
281
|
+
"value": value,
|
|
282
|
+
"score": float(score(value, context)),
|
|
283
|
+
"cost": float(cost(value)),
|
|
284
|
+
}
|
|
285
|
+
for key, value in snapshot.items()
|
|
286
|
+
]
|
|
287
|
+
ranked.sort(key=lambda row: row["score"], reverse=True)
|
|
288
|
+
packed: list[dict[str, Any]] = []
|
|
289
|
+
remaining = float(budget)
|
|
290
|
+
for row in ranked:
|
|
291
|
+
if row["cost"] <= remaining:
|
|
292
|
+
packed.append({"key": row["key"], "value": row["value"], "score": row["score"]})
|
|
293
|
+
remaining -= row["cost"]
|
|
294
|
+
return packed
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
__all__ = [
|
|
298
|
+
"DistillBundle",
|
|
299
|
+
"Extraction",
|
|
300
|
+
"VerifiableBundle",
|
|
301
|
+
"distill",
|
|
302
|
+
"verifiable",
|
|
303
|
+
]
|
graphrefly/extra/cron.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Minimal 5-field cron parser and matcher (minute hour day-of-month month day-of-week).
|
|
2
|
+
|
|
3
|
+
Ported from graphrefly-ts extra/cron.ts for from_cron (roadmap 2.3).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class CronSchedule:
|
|
17
|
+
"""Parsed representation of a 5-field cron expression.
|
|
18
|
+
|
|
19
|
+
Produced by :func:`parse_cron`; consumed by :func:`matches_cron`.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
minutes: Set of matching minute values (0–59).
|
|
23
|
+
hours: Set of matching hour values (0–23).
|
|
24
|
+
days_of_month: Set of matching day-of-month values (1–31).
|
|
25
|
+
months: Set of matching month values (1–12).
|
|
26
|
+
days_of_week: Set of matching day-of-week values (0–6, where 0 = Sunday).
|
|
27
|
+
|
|
28
|
+
Example:
|
|
29
|
+
```python
|
|
30
|
+
from graphrefly.extra.cron import parse_cron
|
|
31
|
+
schedule = parse_cron("0 9 * * 1")
|
|
32
|
+
assert 0 in schedule.minutes
|
|
33
|
+
assert 9 in schedule.hours
|
|
34
|
+
```
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
minutes: set[int] = field(default_factory=set)
|
|
38
|
+
hours: set[int] = field(default_factory=set)
|
|
39
|
+
days_of_month: set[int] = field(default_factory=set)
|
|
40
|
+
months: set[int] = field(default_factory=set)
|
|
41
|
+
days_of_week: set[int] = field(default_factory=set)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _parse_field(field_str: str, min_val: int, max_val: int) -> set[int]:
|
|
45
|
+
result: set[int] = set()
|
|
46
|
+
for part in field_str.split(","):
|
|
47
|
+
pieces = part.split("/")
|
|
48
|
+
range_str = pieces[0]
|
|
49
|
+
step = int(pieces[1]) if len(pieces) > 1 else 1
|
|
50
|
+
if step < 1:
|
|
51
|
+
msg = f"Invalid cron step: {part}"
|
|
52
|
+
raise ValueError(msg)
|
|
53
|
+
if range_str == "*":
|
|
54
|
+
start, end = min_val, max_val
|
|
55
|
+
elif "-" in range_str:
|
|
56
|
+
a, b = range_str.split("-")
|
|
57
|
+
start, end = int(a), int(b)
|
|
58
|
+
else:
|
|
59
|
+
start = int(range_str)
|
|
60
|
+
end = start
|
|
61
|
+
if start < min_val or end > max_val:
|
|
62
|
+
msg = f"Cron field out of range: {field_str} ({min_val}-{max_val})"
|
|
63
|
+
raise ValueError(msg)
|
|
64
|
+
if start > end:
|
|
65
|
+
msg = f"Invalid cron range: {start}-{end} in {field_str}"
|
|
66
|
+
raise ValueError(msg)
|
|
67
|
+
for i in range(start, end + 1, step):
|
|
68
|
+
result.add(i)
|
|
69
|
+
return result
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def parse_cron(expr: str) -> CronSchedule:
|
|
73
|
+
"""Parse a standard 5-field cron expression into a :class:`CronSchedule`.
|
|
74
|
+
|
|
75
|
+
Supports ``*``, ranges (``1-5``), steps (``*/2``), and comma-separated lists.
|
|
76
|
+
Fields are: minute hour day-of-month month day-of-week.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
expr: A cron expression string with exactly 5 whitespace-separated fields.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
A :class:`CronSchedule` with the parsed field sets.
|
|
83
|
+
|
|
84
|
+
Example:
|
|
85
|
+
```python
|
|
86
|
+
from graphrefly.extra.cron import parse_cron
|
|
87
|
+
s = parse_cron("0 9 * * 1-5")
|
|
88
|
+
assert 9 in s.hours
|
|
89
|
+
assert 0 not in s.days_of_week # Sunday excluded
|
|
90
|
+
```
|
|
91
|
+
"""
|
|
92
|
+
parts = expr.strip().split()
|
|
93
|
+
if len(parts) != 5: # noqa: PLR2004
|
|
94
|
+
msg = f"Invalid cron: expected 5 fields, got {len(parts)}"
|
|
95
|
+
raise ValueError(msg)
|
|
96
|
+
return CronSchedule(
|
|
97
|
+
minutes=_parse_field(parts[0], 0, 59),
|
|
98
|
+
hours=_parse_field(parts[1], 0, 23),
|
|
99
|
+
days_of_month=_parse_field(parts[2], 1, 31),
|
|
100
|
+
months=_parse_field(parts[3], 1, 12),
|
|
101
|
+
days_of_week=_parse_field(parts[4], 0, 6),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def matches_cron(schedule: CronSchedule, dt: datetime) -> bool:
|
|
106
|
+
"""Return True when *dt* satisfies every field of *schedule*.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
schedule: A :class:`CronSchedule` produced by :func:`parse_cron`.
|
|
110
|
+
dt: A :class:`datetime.datetime` to test.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
``True`` if minute, hour, day-of-month, month, and day-of-week all match.
|
|
114
|
+
|
|
115
|
+
Example:
|
|
116
|
+
```python
|
|
117
|
+
from datetime import datetime
|
|
118
|
+
from graphrefly.extra.cron import parse_cron, matches_cron
|
|
119
|
+
s = parse_cron("30 8 * * *")
|
|
120
|
+
dt = datetime(2025, 1, 1, 8, 30)
|
|
121
|
+
assert matches_cron(s, dt)
|
|
122
|
+
```
|
|
123
|
+
"""
|
|
124
|
+
return (
|
|
125
|
+
dt.minute in schedule.minutes
|
|
126
|
+
and dt.hour in schedule.hours
|
|
127
|
+
and dt.day in schedule.days_of_month
|
|
128
|
+
and dt.month in schedule.months
|
|
129
|
+
and dt.isoweekday() % 7 in schedule.days_of_week
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
__all__ = ["CronSchedule", "matches_cron", "parse_cron"]
|