tickforge 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.
- tickforge/__init__.py +107 -0
- tickforge/cli.py +516 -0
- tickforge/core.py +976 -0
- tickforge/logging.py +265 -0
- tickforge/persistence.py +601 -0
- tickforge/scheduler.py +745 -0
- tickforge-0.1.0.dist-info/METADATA +271 -0
- tickforge-0.1.0.dist-info/RECORD +12 -0
- tickforge-0.1.0.dist-info/WHEEL +5 -0
- tickforge-0.1.0.dist-info/entry_points.txt +2 -0
- tickforge-0.1.0.dist-info/licenses/LICENSE +21 -0
- tickforge-0.1.0.dist-info/top_level.txt +1 -0
tickforge/__init__.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""tickforge: an advanced task scheduler with persistence and async support.
|
|
2
|
+
|
|
3
|
+
Quick start
|
|
4
|
+
-----------
|
|
5
|
+
import asyncio
|
|
6
|
+
from tickforge import AsyncScheduler, IntervalTrigger, SQLiteJobStore
|
|
7
|
+
|
|
8
|
+
async def ping():
|
|
9
|
+
print("pong")
|
|
10
|
+
|
|
11
|
+
async def main():
|
|
12
|
+
scheduler = AsyncScheduler(store=SQLiteJobStore("jobs.db"))
|
|
13
|
+
await scheduler.add_job(ping, IntervalTrigger(seconds=5), name="ping")
|
|
14
|
+
await scheduler.start()
|
|
15
|
+
await scheduler.wait_closed()
|
|
16
|
+
|
|
17
|
+
asyncio.run(main())
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from .core import (
|
|
21
|
+
ConfigurationError,
|
|
22
|
+
CronTrigger,
|
|
23
|
+
DateTrigger,
|
|
24
|
+
IntervalTrigger,
|
|
25
|
+
Job,
|
|
26
|
+
JobExecutionError,
|
|
27
|
+
JobLookupError,
|
|
28
|
+
JobResult,
|
|
29
|
+
JobRun,
|
|
30
|
+
JobStatus,
|
|
31
|
+
SchedulePlusError,
|
|
32
|
+
SerializationError,
|
|
33
|
+
Trigger,
|
|
34
|
+
TriggerError,
|
|
35
|
+
build_trigger,
|
|
36
|
+
callable_ref,
|
|
37
|
+
coerce_datetime,
|
|
38
|
+
coerce_timezone,
|
|
39
|
+
resolve_callable,
|
|
40
|
+
trigger_from_dict,
|
|
41
|
+
utcnow,
|
|
42
|
+
)
|
|
43
|
+
from .logging import (
|
|
44
|
+
JSONFormatter,
|
|
45
|
+
TextFormatter,
|
|
46
|
+
configure_logging,
|
|
47
|
+
get_logger,
|
|
48
|
+
job_context,
|
|
49
|
+
)
|
|
50
|
+
from .persistence import (
|
|
51
|
+
JobStore,
|
|
52
|
+
JSONFileJobStore,
|
|
53
|
+
MemoryJobStore,
|
|
54
|
+
SQLiteJobStore,
|
|
55
|
+
create_store,
|
|
56
|
+
)
|
|
57
|
+
from .scheduler import (
|
|
58
|
+
AsyncScheduler,
|
|
59
|
+
EventType,
|
|
60
|
+
Scheduler,
|
|
61
|
+
SchedulerConfig,
|
|
62
|
+
SchedulerEvent,
|
|
63
|
+
SchedulerState,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
__version__ = "0.1.0"
|
|
67
|
+
|
|
68
|
+
__all__ = [
|
|
69
|
+
"__version__",
|
|
70
|
+
"SchedulePlusError",
|
|
71
|
+
"TriggerError",
|
|
72
|
+
"JobLookupError",
|
|
73
|
+
"JobExecutionError",
|
|
74
|
+
"SerializationError",
|
|
75
|
+
"ConfigurationError",
|
|
76
|
+
"Trigger",
|
|
77
|
+
"DateTrigger",
|
|
78
|
+
"IntervalTrigger",
|
|
79
|
+
"CronTrigger",
|
|
80
|
+
"Job",
|
|
81
|
+
"JobRun",
|
|
82
|
+
"JobResult",
|
|
83
|
+
"JobStatus",
|
|
84
|
+
"build_trigger",
|
|
85
|
+
"trigger_from_dict",
|
|
86
|
+
"resolve_callable",
|
|
87
|
+
"callable_ref",
|
|
88
|
+
"coerce_datetime",
|
|
89
|
+
"coerce_timezone",
|
|
90
|
+
"utcnow",
|
|
91
|
+
"JobStore",
|
|
92
|
+
"MemoryJobStore",
|
|
93
|
+
"SQLiteJobStore",
|
|
94
|
+
"JSONFileJobStore",
|
|
95
|
+
"create_store",
|
|
96
|
+
"AsyncScheduler",
|
|
97
|
+
"Scheduler",
|
|
98
|
+
"SchedulerConfig",
|
|
99
|
+
"SchedulerState",
|
|
100
|
+
"SchedulerEvent",
|
|
101
|
+
"EventType",
|
|
102
|
+
"configure_logging",
|
|
103
|
+
"get_logger",
|
|
104
|
+
"job_context",
|
|
105
|
+
"JSONFormatter",
|
|
106
|
+
"TextFormatter",
|
|
107
|
+
]
|
tickforge/cli.py
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
"""Command line interface for tickforge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
9
|
+
import sys
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
from .core import (
|
|
16
|
+
ConfigurationError,
|
|
17
|
+
Job,
|
|
18
|
+
JobLookupError,
|
|
19
|
+
JobRun,
|
|
20
|
+
SchedulePlusError,
|
|
21
|
+
build_trigger,
|
|
22
|
+
callable_ref,
|
|
23
|
+
format_datetime,
|
|
24
|
+
humanize_delta,
|
|
25
|
+
utcnow,
|
|
26
|
+
)
|
|
27
|
+
from .logging import configure_logging, get_logger
|
|
28
|
+
from .persistence import JobStore, create_store
|
|
29
|
+
from .scheduler import AsyncScheduler, EventType, SchedulerConfig, SchedulerEvent
|
|
30
|
+
|
|
31
|
+
__all__ = ["cli", "main"]
|
|
32
|
+
|
|
33
|
+
logger = get_logger("cli")
|
|
34
|
+
|
|
35
|
+
DEFAULT_DB = os.environ.get(
|
|
36
|
+
"TICKFORGE_DB",
|
|
37
|
+
os.path.join(os.path.expanduser("~"), ".tickforge", "jobs.db"),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _version() -> str:
|
|
42
|
+
from . import __version__
|
|
43
|
+
|
|
44
|
+
return __version__
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _fail(message: str, code: int = 1) -> "click.ClickException":
|
|
48
|
+
exc = click.ClickException(message)
|
|
49
|
+
exc.exit_code = code
|
|
50
|
+
return exc
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parse_value(raw: str) -> Any:
|
|
54
|
+
"""Interpret a CLI value as JSON, falling back to a plain string."""
|
|
55
|
+
try:
|
|
56
|
+
return json.loads(raw)
|
|
57
|
+
except (TypeError, ValueError):
|
|
58
|
+
return raw
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _parse_kwargs(pairs: Tuple[str, ...]) -> Dict[str, Any]:
|
|
62
|
+
result: Dict[str, Any] = {}
|
|
63
|
+
for pair in pairs:
|
|
64
|
+
key, sep, raw = pair.partition("=")
|
|
65
|
+
if not sep or not key.strip():
|
|
66
|
+
raise _fail("invalid --kwarg %r, expected key=value" % (pair,))
|
|
67
|
+
result[key.strip()] = _parse_value(raw)
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _short(value: Optional[str], width: int) -> str:
|
|
72
|
+
if not value:
|
|
73
|
+
return "-"
|
|
74
|
+
return value if len(value) <= width else value[: width - 1] + "…"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _job_row(job: Job) -> List[str]:
|
|
78
|
+
next_run = format_datetime(job.next_run_at) or "never"
|
|
79
|
+
return [
|
|
80
|
+
job.id[:8],
|
|
81
|
+
_short(job.name, 22),
|
|
82
|
+
_short(job.trigger.describe(), 34),
|
|
83
|
+
next_run,
|
|
84
|
+
"yes" if job.enabled else "no",
|
|
85
|
+
str(job.status),
|
|
86
|
+
"%d/%d" % (job.success_count, job.run_count),
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _print_table(headers: List[str], rows: List[List[str]]) -> None:
|
|
91
|
+
if not rows:
|
|
92
|
+
click.echo("no records")
|
|
93
|
+
return
|
|
94
|
+
widths = [len(header) for header in headers]
|
|
95
|
+
for row in rows:
|
|
96
|
+
for index, cell in enumerate(row):
|
|
97
|
+
widths[index] = max(widths[index], len(cell))
|
|
98
|
+
template = " ".join("{:<%d}" % width for width in widths)
|
|
99
|
+
click.echo(click.style(template.format(*headers), bold=True))
|
|
100
|
+
click.echo("-" * (sum(widths) + 2 * (len(widths) - 1)))
|
|
101
|
+
for row in rows:
|
|
102
|
+
click.echo(template.format(*row))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _get_store(ctx: click.Context) -> JobStore:
|
|
106
|
+
store: JobStore = ctx.obj["store"]
|
|
107
|
+
store.setup()
|
|
108
|
+
return store
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _resolve_job(store: JobStore, identifier: str) -> Job:
|
|
112
|
+
"""Match a job by full id, id prefix or exact name."""
|
|
113
|
+
try:
|
|
114
|
+
return store.get_job(identifier)
|
|
115
|
+
except JobLookupError:
|
|
116
|
+
pass
|
|
117
|
+
matches = [
|
|
118
|
+
job
|
|
119
|
+
for job in store.get_jobs()
|
|
120
|
+
if job.id.startswith(identifier) or job.name == identifier
|
|
121
|
+
]
|
|
122
|
+
if not matches:
|
|
123
|
+
raise _fail("no job matching %r" % (identifier,))
|
|
124
|
+
if len(matches) > 1:
|
|
125
|
+
names = ", ".join("%s (%s)" % (job.name, job.id[:8]) for job in matches)
|
|
126
|
+
raise _fail("ambiguous job reference %r: %s" % (identifier, names))
|
|
127
|
+
return matches[0]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --------------------------------------------------------------------------- #
|
|
131
|
+
# Root group
|
|
132
|
+
# --------------------------------------------------------------------------- #
|
|
133
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
134
|
+
@click.option(
|
|
135
|
+
"--db",
|
|
136
|
+
default=DEFAULT_DB,
|
|
137
|
+
show_default=True,
|
|
138
|
+
envvar="TICKFORGE_DB",
|
|
139
|
+
help="Store URI or path (sqlite path, *.json, or memory://).",
|
|
140
|
+
)
|
|
141
|
+
@click.option("-v", "--verbose", count=True, help="Increase verbosity (-v, -vv).")
|
|
142
|
+
@click.option("-q", "--quiet", is_flag=True, help="Suppress console logging.")
|
|
143
|
+
@click.option(
|
|
144
|
+
"--log-format",
|
|
145
|
+
type=click.Choice(["text", "json"]),
|
|
146
|
+
default="text",
|
|
147
|
+
show_default=True,
|
|
148
|
+
help="Log output format.",
|
|
149
|
+
)
|
|
150
|
+
@click.option("--log-file", type=click.Path(dir_okay=False), default=None, help="Also log to this file.")
|
|
151
|
+
@click.version_option(version=_version(), prog_name="tickforge")
|
|
152
|
+
@click.pass_context
|
|
153
|
+
def cli(
|
|
154
|
+
ctx: click.Context,
|
|
155
|
+
db: str,
|
|
156
|
+
verbose: int,
|
|
157
|
+
quiet: bool,
|
|
158
|
+
log_format: str,
|
|
159
|
+
log_file: Optional[str],
|
|
160
|
+
) -> None:
|
|
161
|
+
"""tickforge — persistent, async-capable task scheduler."""
|
|
162
|
+
level = "WARNING" if quiet else ("DEBUG" if verbose >= 2 else "INFO" if verbose == 1 else "INFO")
|
|
163
|
+
configure_logging(level=level, fmt=log_format, log_file=log_file, quiet=quiet, force=True)
|
|
164
|
+
try:
|
|
165
|
+
store = create_store(db)
|
|
166
|
+
except SchedulePlusError as exc:
|
|
167
|
+
raise _fail(str(exc))
|
|
168
|
+
ctx.ensure_object(dict)
|
|
169
|
+
ctx.obj["store"] = store
|
|
170
|
+
ctx.obj["db"] = db
|
|
171
|
+
ctx.call_on_close(store.close)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# --------------------------------------------------------------------------- #
|
|
175
|
+
# Job management
|
|
176
|
+
# --------------------------------------------------------------------------- #
|
|
177
|
+
@cli.command("add")
|
|
178
|
+
@click.argument("func")
|
|
179
|
+
@click.option("--name", default=None, help="Human-readable job name.")
|
|
180
|
+
@click.option("--interval", default=None, help="Interval such as 30s, 5m, 1h30m.")
|
|
181
|
+
@click.option("--cron", default=None, help="Cron expression 'min hour day month dow'.")
|
|
182
|
+
@click.option("--at", default=None, help="One-shot ISO timestamp or natural date.")
|
|
183
|
+
@click.option("--timezone", "tz", default=None, help="IANA timezone for the trigger.")
|
|
184
|
+
@click.option("--start-at", default=None, help="Do not fire before this timestamp.")
|
|
185
|
+
@click.option("--end-at", default=None, help="Do not fire after this timestamp.")
|
|
186
|
+
@click.option("--jitter", type=float, default=None, help="Random delay up to N seconds (interval only).")
|
|
187
|
+
@click.option("--arg", "positional", multiple=True, help="Positional argument (JSON or string).")
|
|
188
|
+
@click.option("--kwarg", "keyword", multiple=True, help="Keyword argument as key=value.")
|
|
189
|
+
@click.option("--retries", type=int, default=0, show_default=True, help="Retry attempts on failure.")
|
|
190
|
+
@click.option("--retry-delay", type=float, default=5.0, show_default=True, help="Seconds between retries.")
|
|
191
|
+
@click.option("--timeout", type=float, default=None, help="Abort a run after N seconds.")
|
|
192
|
+
@click.option("--grace", type=float, default=None, help="Misfire grace window in seconds.")
|
|
193
|
+
@click.option("--tag", "tags", multiple=True, help="Tag for filtering (repeatable).")
|
|
194
|
+
@click.option("--allow-concurrent", is_flag=True, help="Permit overlapping runs of this job.")
|
|
195
|
+
@click.option("--disabled", is_flag=True, help="Create the job in a paused state.")
|
|
196
|
+
@click.pass_context
|
|
197
|
+
def add_command(
|
|
198
|
+
ctx: click.Context,
|
|
199
|
+
func: str,
|
|
200
|
+
name: Optional[str],
|
|
201
|
+
interval: Optional[str],
|
|
202
|
+
cron: Optional[str],
|
|
203
|
+
at: Optional[str],
|
|
204
|
+
tz: Optional[str],
|
|
205
|
+
start_at: Optional[str],
|
|
206
|
+
end_at: Optional[str],
|
|
207
|
+
jitter: Optional[float],
|
|
208
|
+
positional: Tuple[str, ...],
|
|
209
|
+
keyword: Tuple[str, ...],
|
|
210
|
+
retries: int,
|
|
211
|
+
retry_delay: float,
|
|
212
|
+
timeout: Optional[float],
|
|
213
|
+
grace: Optional[float],
|
|
214
|
+
tags: Tuple[str, ...],
|
|
215
|
+
allow_concurrent: bool,
|
|
216
|
+
disabled: bool,
|
|
217
|
+
) -> None:
|
|
218
|
+
"""Register a job. FUNC is a 'package.module:callable' reference."""
|
|
219
|
+
store = _get_store(ctx)
|
|
220
|
+
try:
|
|
221
|
+
reference = callable_ref(func)
|
|
222
|
+
trigger = build_trigger(
|
|
223
|
+
interval=interval,
|
|
224
|
+
cron=cron,
|
|
225
|
+
at=at,
|
|
226
|
+
timezone=tz,
|
|
227
|
+
start_at=start_at,
|
|
228
|
+
end_at=end_at,
|
|
229
|
+
jitter=jitter,
|
|
230
|
+
)
|
|
231
|
+
job = Job.create(
|
|
232
|
+
reference,
|
|
233
|
+
trigger,
|
|
234
|
+
name=name or "",
|
|
235
|
+
args=tuple(_parse_value(item) for item in positional),
|
|
236
|
+
kwargs=_parse_kwargs(keyword),
|
|
237
|
+
max_retries=retries,
|
|
238
|
+
retry_delay=retry_delay,
|
|
239
|
+
timeout=timeout,
|
|
240
|
+
misfire_grace_time=grace,
|
|
241
|
+
allow_concurrent=allow_concurrent,
|
|
242
|
+
enabled=not disabled,
|
|
243
|
+
tags=list(tags),
|
|
244
|
+
)
|
|
245
|
+
store.add_job(job)
|
|
246
|
+
except (SchedulePlusError, ValueError) as exc:
|
|
247
|
+
raise _fail(str(exc))
|
|
248
|
+
click.echo("added job %s (%s)" % (click.style(job.name, bold=True), job.id))
|
|
249
|
+
click.echo(" trigger : %s" % (job.trigger.describe(),))
|
|
250
|
+
click.echo(" next run: %s" % (format_datetime(job.next_run_at) or "never",))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@cli.command("list")
|
|
254
|
+
@click.option("--tag", default=None, help="Only show jobs carrying this tag.")
|
|
255
|
+
@click.option("--enabled/--disabled", "enabled", default=None, help="Filter by enabled state.")
|
|
256
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of a table.")
|
|
257
|
+
@click.pass_context
|
|
258
|
+
def list_command(
|
|
259
|
+
ctx: click.Context, tag: Optional[str], enabled: Optional[bool], as_json: bool
|
|
260
|
+
) -> None:
|
|
261
|
+
"""List registered jobs."""
|
|
262
|
+
store = _get_store(ctx)
|
|
263
|
+
jobs = store.get_jobs(enabled=enabled, tag=tag)
|
|
264
|
+
if as_json:
|
|
265
|
+
click.echo(json.dumps([job.to_dict() for job in jobs], indent=2, default=str))
|
|
266
|
+
return
|
|
267
|
+
_print_table(
|
|
268
|
+
["ID", "NAME", "TRIGGER", "NEXT RUN", "ON", "STATUS", "OK/RUNS"],
|
|
269
|
+
[_job_row(job) for job in jobs],
|
|
270
|
+
)
|
|
271
|
+
click.echo("\n%d job(s)" % (len(jobs),))
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@cli.command("show")
|
|
275
|
+
@click.argument("job_id")
|
|
276
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of text.")
|
|
277
|
+
@click.pass_context
|
|
278
|
+
def show_command(ctx: click.Context, job_id: str, as_json: bool) -> None:
|
|
279
|
+
"""Show the full definition of one job."""
|
|
280
|
+
store = _get_store(ctx)
|
|
281
|
+
job = _resolve_job(store, job_id)
|
|
282
|
+
if as_json:
|
|
283
|
+
click.echo(json.dumps(job.to_dict(), indent=2, default=str))
|
|
284
|
+
return
|
|
285
|
+
data = job.to_dict()
|
|
286
|
+
data["trigger"] = job.trigger.describe()
|
|
287
|
+
for key in sorted(data):
|
|
288
|
+
click.echo("%-20s %s" % (key + ":", data[key]))
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@cli.command("remove")
|
|
292
|
+
@click.argument("job_id")
|
|
293
|
+
@click.option("--yes", is_flag=True, help="Skip the confirmation prompt.")
|
|
294
|
+
@click.pass_context
|
|
295
|
+
def remove_command(ctx: click.Context, job_id: str, yes: bool) -> None:
|
|
296
|
+
"""Delete a job and its run history."""
|
|
297
|
+
store = _get_store(ctx)
|
|
298
|
+
job = _resolve_job(store, job_id)
|
|
299
|
+
if not yes:
|
|
300
|
+
click.confirm("remove job %s (%s)?" % (job.name, job.id[:8]), abort=True)
|
|
301
|
+
store.remove_job(job.id)
|
|
302
|
+
click.echo("removed %s" % (job.id,))
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@cli.command("pause")
|
|
306
|
+
@click.argument("job_id")
|
|
307
|
+
@click.pass_context
|
|
308
|
+
def pause_command(ctx: click.Context, job_id: str) -> None:
|
|
309
|
+
"""Disable a job without deleting it."""
|
|
310
|
+
from .core import JobStatus
|
|
311
|
+
|
|
312
|
+
store = _get_store(ctx)
|
|
313
|
+
job = _resolve_job(store, job_id)
|
|
314
|
+
job.enabled = False
|
|
315
|
+
job.status = JobStatus.PAUSED
|
|
316
|
+
store.update_job(job)
|
|
317
|
+
click.echo("paused %s" % (job.name,))
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@cli.command("resume")
|
|
321
|
+
@click.argument("job_id")
|
|
322
|
+
@click.pass_context
|
|
323
|
+
def resume_command(ctx: click.Context, job_id: str) -> None:
|
|
324
|
+
"""Re-enable a paused job and recompute its next run."""
|
|
325
|
+
from .core import JobStatus
|
|
326
|
+
|
|
327
|
+
store = _get_store(ctx)
|
|
328
|
+
job = _resolve_job(store, job_id)
|
|
329
|
+
job.enabled = True
|
|
330
|
+
job.status = JobStatus.PENDING
|
|
331
|
+
job.schedule_next(utcnow())
|
|
332
|
+
store.update_job(job)
|
|
333
|
+
click.echo("resumed %s, next run %s" % (job.name, format_datetime(job.next_run_at) or "never"))
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
@cli.command("history")
|
|
337
|
+
@click.argument("job_id", required=False)
|
|
338
|
+
@click.option("--limit", type=int, default=20, show_default=True, help="Maximum rows to display.")
|
|
339
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of a table.")
|
|
340
|
+
@click.pass_context
|
|
341
|
+
def history_command(ctx: click.Context, job_id: Optional[str], limit: int, as_json: bool) -> None:
|
|
342
|
+
"""Show recent run history, newest first."""
|
|
343
|
+
store = _get_store(ctx)
|
|
344
|
+
target = _resolve_job(store, job_id).id if job_id else None
|
|
345
|
+
runs: List[JobRun] = store.get_history(target, limit)
|
|
346
|
+
if as_json:
|
|
347
|
+
click.echo(json.dumps([run.to_dict() for run in runs], indent=2, default=str))
|
|
348
|
+
return
|
|
349
|
+
rows = [
|
|
350
|
+
[
|
|
351
|
+
run.id[:8],
|
|
352
|
+
_short(run.job_name or run.job_id[:8], 20),
|
|
353
|
+
format_datetime(run.started_at) or "-",
|
|
354
|
+
"%.3fs" % run.duration if run.duration is not None else "-",
|
|
355
|
+
str(run.status),
|
|
356
|
+
str(run.attempts),
|
|
357
|
+
_short(run.error, 40),
|
|
358
|
+
]
|
|
359
|
+
for run in runs
|
|
360
|
+
]
|
|
361
|
+
_print_table(["RUN", "JOB", "STARTED", "TOOK", "STATUS", "TRIES", "ERROR"], rows)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@cli.command("clear")
|
|
365
|
+
@click.option("--yes", is_flag=True, help="Skip the confirmation prompt.")
|
|
366
|
+
@click.pass_context
|
|
367
|
+
def clear_command(ctx: click.Context, yes: bool) -> None:
|
|
368
|
+
"""Delete every job and run record in the store."""
|
|
369
|
+
store = _get_store(ctx)
|
|
370
|
+
if not yes:
|
|
371
|
+
click.confirm("delete ALL jobs and history from %s?" % (ctx.obj["db"],), abort=True)
|
|
372
|
+
count = store.clear()
|
|
373
|
+
click.echo("cleared %d job(s)" % (count,))
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@cli.command("run-now")
|
|
377
|
+
@click.argument("job_id")
|
|
378
|
+
@click.pass_context
|
|
379
|
+
def run_now_command(ctx: click.Context, job_id: str) -> None:
|
|
380
|
+
"""Execute a job immediately, ignoring its schedule."""
|
|
381
|
+
store = _get_store(ctx)
|
|
382
|
+
job = _resolve_job(store, job_id)
|
|
383
|
+
scheduler = AsyncScheduler(store=store)
|
|
384
|
+
|
|
385
|
+
async def _runner() -> JobRun:
|
|
386
|
+
await scheduler.start(paused=True)
|
|
387
|
+
try:
|
|
388
|
+
return await scheduler.run_job_now(job.id)
|
|
389
|
+
finally:
|
|
390
|
+
await scheduler.stop(wait=True)
|
|
391
|
+
|
|
392
|
+
run = asyncio.get_event_loop().run_until_complete(_runner()) if _loop_open() else asyncio.run(_runner())
|
|
393
|
+
click.echo("status : %s" % (run.status,))
|
|
394
|
+
click.echo("duration: %s" % ("%.3fs" % run.duration if run.duration is not None else "-",))
|
|
395
|
+
if run.result:
|
|
396
|
+
click.echo("result : %s" % (run.result,))
|
|
397
|
+
if run.error:
|
|
398
|
+
click.echo(click.style("error : %s" % (run.error,), fg="red"))
|
|
399
|
+
raise SystemExit(1)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _loop_open() -> bool:
|
|
403
|
+
"""True when a usable event loop is already installed in this thread."""
|
|
404
|
+
try:
|
|
405
|
+
loop = asyncio.get_event_loop()
|
|
406
|
+
except RuntimeError:
|
|
407
|
+
return False
|
|
408
|
+
return not loop.is_closed() and not loop.is_running()
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
@cli.command("start")
|
|
412
|
+
@click.option("--poll", type=float, default=1.0, show_default=True, help="Seconds between store polls.")
|
|
413
|
+
@click.option("--concurrency", type=int, default=10, show_default=True, help="Maximum simultaneous jobs.")
|
|
414
|
+
@click.option("--workers", type=int, default=8, show_default=True, help="Threads for sync callables.")
|
|
415
|
+
@click.option("--grace", type=float, default=60.0, show_default=True, help="Default misfire grace window.")
|
|
416
|
+
@click.option("--paused", is_flag=True, help="Start without dispatching until resumed.")
|
|
417
|
+
@click.pass_context
|
|
418
|
+
def start_command(
|
|
419
|
+
ctx: click.Context,
|
|
420
|
+
poll: float,
|
|
421
|
+
concurrency: int,
|
|
422
|
+
workers: int,
|
|
423
|
+
grace: float,
|
|
424
|
+
paused: bool,
|
|
425
|
+
) -> None:
|
|
426
|
+
"""Run the scheduler in the foreground until interrupted."""
|
|
427
|
+
store = _get_store(ctx)
|
|
428
|
+
try:
|
|
429
|
+
config = SchedulerConfig(
|
|
430
|
+
poll_interval=poll,
|
|
431
|
+
max_concurrent_jobs=concurrency,
|
|
432
|
+
worker_threads=workers,
|
|
433
|
+
misfire_grace_time=grace,
|
|
434
|
+
)
|
|
435
|
+
except ConfigurationError as exc:
|
|
436
|
+
raise _fail(str(exc))
|
|
437
|
+
|
|
438
|
+
scheduler = AsyncScheduler(store=store, config=config)
|
|
439
|
+
|
|
440
|
+
def _on_event(event: SchedulerEvent) -> None:
|
|
441
|
+
if event.type in (EventType.JOB_ERROR, EventType.JOB_MISSED):
|
|
442
|
+
logger.warning("event %s for job %s", event.type, event.job.name if event.job else "?")
|
|
443
|
+
|
|
444
|
+
scheduler.add_listener(_on_event)
|
|
445
|
+
|
|
446
|
+
async def _serve() -> None:
|
|
447
|
+
await scheduler.start(paused=paused)
|
|
448
|
+
loop = asyncio.get_event_loop()
|
|
449
|
+
stop_event = asyncio.Event()
|
|
450
|
+
|
|
451
|
+
def _request_stop() -> None:
|
|
452
|
+
logger.info("shutdown signal received")
|
|
453
|
+
stop_event.set()
|
|
454
|
+
|
|
455
|
+
for signal_name in ("SIGINT", "SIGTERM"):
|
|
456
|
+
handle = getattr(signal, signal_name, None)
|
|
457
|
+
if handle is None:
|
|
458
|
+
continue
|
|
459
|
+
try:
|
|
460
|
+
loop.add_signal_handler(handle, _request_stop)
|
|
461
|
+
except (NotImplementedError, RuntimeError): # Windows
|
|
462
|
+
signal.signal(handle, lambda *_: _request_stop())
|
|
463
|
+
try:
|
|
464
|
+
await stop_event.wait()
|
|
465
|
+
finally:
|
|
466
|
+
await scheduler.stop(wait=True)
|
|
467
|
+
|
|
468
|
+
jobs = store.get_jobs(enabled=True)
|
|
469
|
+
click.echo("scheduler starting with %d enabled job(s) from %s" % (len(jobs), ctx.obj["db"]))
|
|
470
|
+
try:
|
|
471
|
+
asyncio.run(_serve())
|
|
472
|
+
except KeyboardInterrupt: # pragma: no cover - defensive
|
|
473
|
+
click.echo("interrupted")
|
|
474
|
+
click.echo("scheduler stopped")
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@cli.command("next")
|
|
478
|
+
@click.argument("job_id")
|
|
479
|
+
@click.option("--count", type=int, default=5, show_default=True, help="How many fire times to preview.")
|
|
480
|
+
@click.pass_context
|
|
481
|
+
def next_command(ctx: click.Context, job_id: str, count: int) -> None:
|
|
482
|
+
"""Preview upcoming fire times for a job without running it."""
|
|
483
|
+
if count <= 0:
|
|
484
|
+
raise _fail("--count must be positive")
|
|
485
|
+
store = _get_store(ctx)
|
|
486
|
+
job = _resolve_job(store, job_id)
|
|
487
|
+
cursor: Optional[datetime] = utcnow()
|
|
488
|
+
now = cursor
|
|
489
|
+
for index in range(count):
|
|
490
|
+
cursor = job.trigger.next_fire_time(cursor)
|
|
491
|
+
if cursor is None:
|
|
492
|
+
click.echo("%2d. no further runs" % (index + 1,))
|
|
493
|
+
break
|
|
494
|
+
click.echo("%2d. %s (in %s)" % (index + 1, format_datetime(cursor), humanize_delta(cursor - now)))
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
498
|
+
"""Console-script entry point."""
|
|
499
|
+
try:
|
|
500
|
+
cli.main(args=argv if argv is not None else sys.argv[1:], standalone_mode=False)
|
|
501
|
+
except click.ClickException as exc:
|
|
502
|
+
exc.show()
|
|
503
|
+
return exc.exit_code
|
|
504
|
+
except click.Abort:
|
|
505
|
+
click.echo("aborted", err=True)
|
|
506
|
+
return 130
|
|
507
|
+
except SchedulePlusError as exc:
|
|
508
|
+
click.echo("error: %s" % (exc,), err=True)
|
|
509
|
+
return 1
|
|
510
|
+
except SystemExit as exc:
|
|
511
|
+
return int(exc.code or 0)
|
|
512
|
+
return 0
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
if __name__ == "__main__": # pragma: no cover
|
|
516
|
+
sys.exit(main())
|