veil-api-kit 0.10.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.
veil_kit/tasks.py ADDED
@@ -0,0 +1,199 @@
1
+ """Application tasks: periodic jobs, cron schedules and resident coroutines.
2
+
3
+ .. code-block:: python
4
+
5
+ from veil_kit.tasks import Tasks
6
+
7
+ tasks = Tasks(app)
8
+
9
+ @tasks.every(seconds=30)
10
+ async def refresh():
11
+ ...
12
+
13
+ @tasks.cron("*/5 * * * *") # minute hour day month weekday (UTC); croniter is used when installed
14
+ def report():
15
+ ...
16
+
17
+ @tasks.background
18
+ async def consume():
19
+ while True:
20
+ ...
21
+
22
+ Tasks start after the application startup (on the first event loop) and are cancelled when the server stops.
23
+ ``async def`` tasks run on the event loop; plain functions run on a thread pool. Runs of one task never overlap; an
24
+ exception is logged and the task keeps its schedule. For work tied to one request use ``veil.BackgroundTasks``.
25
+ """
26
+
27
+ # F-268: 常駐のループ(F-263)の上で動かす。lifespan の起動の後に始め、停止で取り消す。
28
+
29
+ import asyncio
30
+ import datetime
31
+ import inspect
32
+ import random
33
+ import traceback
34
+
35
+ import veil
36
+
37
+ __all__ = ["Tasks", "CronSchedule"]
38
+
39
+
40
+ class Tasks:
41
+ """The tasks of an application (registered on ``app.on_startup`` / ``app.on_shutdown``)."""
42
+
43
+ def __init__(self, app=None):
44
+ """Attach to ``app`` (``veil``'s default application when omitted)."""
45
+ self.app = app if app is not None else veil._default_app
46
+ self._specs = []
47
+ self._running = []
48
+ self.app.on_startup(self._start)
49
+ self.app.on_shutdown(self._stop)
50
+
51
+ def every(self, seconds=None, *, minutes=None, hours=None, jitter=0.1, run_at_start=False):
52
+ """Run the decorated function every period (the first run after up to ``jitter`` of a period, or at start)."""
53
+ period = (seconds or 0) + 60 * (minutes or 0) + 3600 * (hours or 0)
54
+ if period <= 0:
55
+ raise ValueError("the period must be positive")
56
+
57
+ def deco(f):
58
+ self._specs.append(("every", f, period, jitter, run_at_start))
59
+ return f
60
+
61
+ return deco
62
+
63
+ def cron(self, expr):
64
+ """Run the decorated function on a cron schedule (five fields, UTC)."""
65
+ sched = CronSchedule(expr)
66
+
67
+ def deco(f):
68
+ self._specs.append(("cron", f, sched))
69
+ return f
70
+
71
+ return deco
72
+
73
+ def background(self, f):
74
+ """Run the decorated coroutine function once at start and keep it running until the server stops."""
75
+ self._specs.append(("background", f))
76
+ return f
77
+
78
+ async def _start(self):
79
+ loop = asyncio.get_running_loop()
80
+ for spec in self._specs:
81
+ self._running.append(loop.create_task(self._run(spec)))
82
+
83
+ async def _stop(self):
84
+ for t in self._running:
85
+ t.cancel()
86
+ for t in self._running:
87
+ try:
88
+ await t
89
+ except (asyncio.CancelledError, Exception): # noqa: BLE001
90
+ pass
91
+ self._running.clear()
92
+
93
+ @staticmethod
94
+ async def _once(f):
95
+ try:
96
+ if inspect.iscoroutinefunction(f):
97
+ await f()
98
+ else:
99
+ await veil.run_in_threadpool(f)
100
+ except asyncio.CancelledError:
101
+ raise
102
+ except Exception: # noqa: BLE001 1 回の失敗で予定を止めない
103
+ traceback.print_exc()
104
+
105
+ async def _run(self, spec):
106
+ kind = spec[0]
107
+ if kind == "background":
108
+ await self._once(spec[1])
109
+ return
110
+ if kind == "every":
111
+ _, f, period, jitter, at_start = spec
112
+ loop = asyncio.get_running_loop()
113
+ first = 0 if at_start else random.uniform(0, period * jitter)
114
+ nxt = loop.time() + first
115
+ while True:
116
+ await asyncio.sleep(max(0.0, nxt - loop.time()))
117
+ await self._once(f)
118
+ nxt += period
119
+ if nxt < loop.time(): # 遅れたら次の周期へ(重ねない)
120
+ nxt = loop.time() + period
121
+ _, f, sched = spec
122
+ while True:
123
+ now = datetime.datetime.now(datetime.timezone.utc)
124
+ nxt = sched.next_after(now)
125
+ await asyncio.sleep(max(0.0, (nxt - now).total_seconds()))
126
+ await self._once(f)
127
+
128
+
129
+ class CronSchedule:
130
+ """A five-field cron expression (``minute hour day month weekday``; ``*``, numbers, ``a-b``, lists and ``/step``;
131
+ weekday 0 or 7 = Sunday). Uses croniter when it is installed."""
132
+
133
+ _RANGES = ((0, 59), (0, 23), (1, 31), (1, 12), (0, 7))
134
+
135
+ def __init__(self, expr):
136
+ """Parse ``expr`` (``ValueError`` when invalid)."""
137
+ self.expr = expr
138
+ try:
139
+ import croniter # noqa: PLC0415
140
+
141
+ if not croniter.croniter.is_valid(expr):
142
+ raise ValueError("invalid cron expression %r" % expr)
143
+ self._croniter = croniter.croniter
144
+ except ImportError:
145
+ self._croniter = None
146
+ fields = expr.split()
147
+ if len(fields) != 5:
148
+ raise ValueError("a cron expression has five fields: %r" % expr)
149
+ self.sets = [self._field(f, lo, hi) for f, (lo, hi) in zip(fields, self._RANGES)]
150
+ if 7 in self.sets[4]:
151
+ self.sets[4].add(0)
152
+ self.dom_any = fields[2] == "*"
153
+ self.dow_any = fields[4] == "*"
154
+
155
+ @staticmethod
156
+ def _field(text, lo, hi):
157
+ out = set()
158
+ for part in text.split(","):
159
+ rng, _, step = part.partition("/")
160
+ step = int(step) if step else 1
161
+ if step <= 0:
162
+ raise ValueError("bad step in %r" % text)
163
+ if rng == "*":
164
+ a, b = lo, hi
165
+ elif "-" in rng:
166
+ a, b = (int(x) for x in rng.split("-", 1))
167
+ else:
168
+ a = int(rng)
169
+ b = hi if step > 1 else a
170
+ if not (lo <= a <= hi and lo <= b <= hi and a <= b):
171
+ raise ValueError("value out of range in %r" % text)
172
+ out.update(range(a, b + 1, step))
173
+ return out
174
+
175
+ def next_after(self, t):
176
+ """The first run time after ``t`` (an aware ``datetime``)."""
177
+ if self._croniter is not None:
178
+ return self._croniter(self.expr, t).get_next(datetime.datetime)
179
+ mins, hours, days, months, dows = self.sets
180
+ t = (t + datetime.timedelta(minutes=1)).replace(second=0, microsecond=0)
181
+ for _ in range(366 * 24 * 60):
182
+ dow = (t.weekday() + 1) % 7
183
+ if self.dom_any and self.dow_any:
184
+ day_ok = True
185
+ elif self.dom_any:
186
+ day_ok = dow in dows
187
+ elif self.dow_any:
188
+ day_ok = t.day in days
189
+ else:
190
+ day_ok = t.day in days or dow in dows
191
+ if t.month in months and day_ok and t.hour in hours and t.minute in mins:
192
+ return t
193
+ if t.month not in months or not day_ok:
194
+ t = (t + datetime.timedelta(days=1)).replace(hour=0, minute=0)
195
+ elif t.hour not in hours:
196
+ t = (t + datetime.timedelta(hours=1)).replace(minute=0)
197
+ else:
198
+ t += datetime.timedelta(minutes=1)
199
+ raise ValueError("no run time within a year for %r" % self.expr)
veil_kit/testing.py ADDED
@@ -0,0 +1,24 @@
1
+ """Test helpers: the in-process :class:`TestClient` and dependency overrides as context managers."""
2
+
3
+ # F-268
4
+
5
+ import contextlib
6
+
7
+ from veil.testclient import TestClient, TestResponse
8
+
9
+ __all__ = ["TestClient", "TestResponse", "override_dependency"]
10
+
11
+
12
+ @contextlib.contextmanager
13
+ def override_dependency(app, dependency, replacement):
14
+ """Use ``replacement`` for ``dependency`` (in ``app.dependency_overrides``) inside the ``with`` block."""
15
+ had = dependency in app.dependency_overrides
16
+ old = app.dependency_overrides.get(dependency)
17
+ app.dependency_overrides[dependency] = replacement
18
+ try:
19
+ yield replacement
20
+ finally:
21
+ if had:
22
+ app.dependency_overrides[dependency] = old
23
+ else:
24
+ del app.dependency_overrides[dependency]