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 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())