goatbeat-cli 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.
File without changes
@@ -0,0 +1,183 @@
1
+ import json
2
+ import signal
3
+ import socket
4
+ import sys
5
+ import threading
6
+ import uuid
7
+ from collections.abc import Sequence
8
+ from types import FrameType
9
+ from typing import Final
10
+
11
+ from goatbeat.cli import environment as runtime
12
+ from goatbeat.cli.environment import Check, Environment
13
+ from goatbeat.core.ids import IdempotencyKey, SchedulerId
14
+ from goatbeat.core.schedule import Schedule
15
+ from goatbeat.core.scheduler import Scheduler
16
+ from goatbeat.core.time import SystemClock
17
+ from goatbeat.core.triggers import CronTrigger
18
+
19
+ SUCCESS: Final = 0
20
+ CHECK_FAILED: Final = 1
21
+ STOP_SIGNALS: Final = (signal.SIGTERM, signal.SIGINT)
22
+
23
+
24
+ class CommandFailedError(Exception):
25
+ pass
26
+
27
+
28
+ def run_scheduler(environment: Environment, *, once: bool, metrics_port: int | None) -> int:
29
+ environment.ready()
30
+ metrics = runtime.scheduler_metrics(environment.metrics, metrics_port)
31
+ scheduler = Scheduler(
32
+ store=environment.store,
33
+ leases=environment.leases,
34
+ submitter=environment.submitter,
35
+ config=environment.config,
36
+ registry=environment.registry,
37
+ metrics=metrics,
38
+ )
39
+ if once:
40
+ try:
41
+ scheduler.start()
42
+ scheduler.tick()
43
+ finally:
44
+ scheduler.release()
45
+ return SUCCESS
46
+ stop = threading.Event()
47
+
48
+ def request_stop(signum: int, frame: FrameType | None) -> None:
49
+ del signum, frame
50
+ stop.set()
51
+
52
+ for stop_signal in STOP_SIGNALS:
53
+ signal.signal(stop_signal, request_stop)
54
+ scheduler.run(stop)
55
+ return SUCCESS
56
+
57
+
58
+ def publish(environment: Environment) -> None:
59
+ """Code-declared schedules must be in the store before anything reads it."""
60
+ environment.ready()
61
+ environment.store.sync_code_schedules(environment.registry.definitions(), SystemClock().now())
62
+
63
+
64
+ def status(environment: Environment, *, as_json: bool) -> int:
65
+ publish(environment)
66
+ now = SystemClock().now()
67
+ lease = environment.leases.current()
68
+ schedules = environment.store.list_schedules()
69
+ wakeup = environment.store.next_wakeup()
70
+ report: dict[str, object] = {
71
+ "leader": None
72
+ if lease is None
73
+ else {
74
+ "holder": lease.holder.value,
75
+ "token": lease.token.value,
76
+ "expires_at": lease.expires_at.isoformat(),
77
+ "held": lease.held_at(now),
78
+ },
79
+ "schedules": {
80
+ "total": len(schedules),
81
+ "enabled": sum(schedule.definition.enabled for schedule in schedules),
82
+ },
83
+ "next_wakeup": None if wakeup is None else wakeup.isoformat(),
84
+ }
85
+ if as_json:
86
+ _write(json.dumps(report))
87
+ return SUCCESS
88
+ leader = report["leader"]
89
+ _write(f"leader: {leader if leader is not None else 'none yet'}")
90
+ _write(f"schedules: {report['schedules']}")
91
+ _write(f"next wakeup: {report['next_wakeup']}")
92
+ return SUCCESS
93
+
94
+
95
+ def doctor(environment: Environment, *, as_json: bool) -> int:
96
+ checks = _run_checks(environment)
97
+ if as_json:
98
+ _write(json.dumps([_check_dict(check) for check in checks]))
99
+ else:
100
+ for check in checks:
101
+ _write(f"{'ok ' if check.ok else 'FAIL'} {check.name}: {check.detail}")
102
+ return SUCCESS if all(check.ok for check in checks) else CHECK_FAILED
103
+
104
+
105
+ def list_schedules(environment: Environment, *, as_json: bool) -> int:
106
+ publish(environment)
107
+ described = [_describe(schedule) for schedule in environment.store.list_schedules()]
108
+ if as_json:
109
+ _write(json.dumps(described))
110
+ return SUCCESS
111
+ for item in described:
112
+ state = "enabled" if item["enabled"] else "disabled"
113
+ _write(
114
+ f"{item['name']:<40} {item['source']:<6} {state:<9} "
115
+ f"{item['next_run_at']:<33} {item['trigger']}"
116
+ )
117
+ return SUCCESS
118
+
119
+
120
+ def run_now(environment: Environment, name: str) -> int:
121
+ publish(environment)
122
+ schedule = environment.store.get_schedule(name)
123
+ if schedule is None:
124
+ raise CommandFailedError(f"unknown schedule {name!r}")
125
+ key = IdempotencyKey(f"manual:{uuid.uuid4().hex}")
126
+ requester = SchedulerId(f"cli:{socket.gethostname()}")
127
+ execution = environment.store.request_run(schedule, key, requester, SystemClock().now())
128
+ _write(f"{execution.idempotency_key.value} {execution.status.value}")
129
+ return SUCCESS
130
+
131
+
132
+ def prune(environment: Environment) -> int:
133
+ environment.store.sync_code_schedules(
134
+ environment.registry.definitions(), SystemClock().now(), prune=True
135
+ )
136
+ return SUCCESS
137
+
138
+
139
+ def migrate(environment: Environment) -> int:
140
+ _write(environment.migrate() if environment.migrate else "nothing to migrate")
141
+ return SUCCESS
142
+
143
+
144
+ def _run_checks(environment: Environment) -> Sequence[Check]:
145
+ backend = list(environment.backend_checks())
146
+ if any(not check.ok for check in backend if check.name == "schema"):
147
+ return [Check("store", ok=True, detail="reachable"), *backend]
148
+ publish(environment)
149
+ schedules = environment.store.list_schedules()
150
+ checks = [Check("store", ok=True, detail=f"{len(schedules)} schedules readable")]
151
+ paths = sorted({schedule.definition.task.path for schedule in schedules})
152
+ missing = [problem for path in paths if (problem := environment.task_problem(path))]
153
+ checks.append(
154
+ Check(
155
+ "tasks",
156
+ ok=not missing,
157
+ detail="every task imports" if not missing else "; ".join(missing),
158
+ )
159
+ )
160
+ return [*checks, *backend]
161
+
162
+
163
+ def _check_dict(check: Check) -> dict[str, object]:
164
+ return {"name": check.name, "ok": check.ok, "detail": check.detail}
165
+
166
+
167
+ def _describe(schedule: Schedule) -> dict[str, str | bool]:
168
+ trigger = schedule.definition.trigger
169
+ if isinstance(trigger, CronTrigger):
170
+ described = f"cron {trigger.expression.text} ({trigger.timezone.key})"
171
+ else:
172
+ described = f"every {trigger.every}"
173
+ return {
174
+ "name": schedule.name,
175
+ "source": schedule.source.value,
176
+ "enabled": schedule.definition.enabled,
177
+ "next_run_at": schedule.next_run_at.isoformat(),
178
+ "trigger": described,
179
+ }
180
+
181
+
182
+ def _write(line: str) -> None:
183
+ sys.stdout.write(f"{line}\n")
@@ -0,0 +1,256 @@
1
+ import os
2
+ from collections.abc import Callable, Generator, Sequence
3
+ from contextlib import ExitStack, contextmanager
4
+ from dataclasses import dataclass
5
+ from datetime import timedelta
6
+ from importlib import import_module
7
+ from importlib.util import find_spec
8
+ from typing import Final
9
+ from urllib.parse import urlsplit
10
+
11
+ from goatbeat.core.app import GoatbeatApp
12
+ from goatbeat.core.config import GoatbeatConfig
13
+ from goatbeat.core.errors import ConfigurationError
14
+ from goatbeat.core.observability import FanoutMetrics, MetricsSink, NoopMetrics
15
+ from goatbeat.core.protocols import LeaseManager, ScheduleStore, TaskSubmitter
16
+ from goatbeat.core.registry import SupportsDefinitions
17
+ from goatbeat.core.stores.memory import MemoryStore
18
+ from goatbeat.core.stores.sqlite import SQLiteStore
19
+ from goatbeat.core.tasks import import_object
20
+ from goatbeat.core.time import SystemClock
21
+
22
+ APP_VARIABLE: Final = "GOATBEAT_APP"
23
+ DJANGO_VARIABLE: Final = "DJANGO_SETTINGS_MODULE"
24
+ DATABASE_VARIABLE: Final = "GOATBEAT_DATABASE_URL"
25
+ SQLITE_PREFIX: Final = "sqlite:///"
26
+ # libpq also accepts "host=... dbname=..." strings; they carry no URL scheme.
27
+ LIBPQ_KEYWORDS: Final = "libpq"
28
+ # Lease correctness ignores host clocks; a larger drift still confuses logs and schedules.
29
+ MAX_CLOCK_DRIFT: Final = timedelta(seconds=2)
30
+ PROMETHEUS_HINT: Final = "--metrics-port needs: pip install goatbeat[prometheus]"
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class Check:
35
+ name: str
36
+ ok: bool
37
+ detail: str
38
+
39
+
40
+ def no_checks() -> Sequence[Check]:
41
+ return ()
42
+
43
+
44
+ def always_ready() -> None:
45
+ pass
46
+
47
+
48
+ def import_problem(path: str) -> str | None:
49
+ try:
50
+ import_object(path)
51
+ except Exception as error:
52
+ return f"{path}: {type(error).__name__}: {error}"
53
+ return None
54
+
55
+
56
+ @dataclass(slots=True)
57
+ class Environment:
58
+ config: GoatbeatConfig
59
+ store: ScheduleStore
60
+ leases: LeaseManager
61
+ registry: SupportsDefinitions
62
+ submitter: TaskSubmitter
63
+ metrics: MetricsSink
64
+ backend_checks: Callable[[], Sequence[Check]] = no_checks
65
+ migrate: Callable[[], str] | None = None
66
+ task_problem: Callable[[str], str | None] = import_problem
67
+ ready: Callable[[], None] = always_ready
68
+
69
+
70
+ def start_http_server(port: int) -> None:
71
+ try:
72
+ from prometheus_client import start_http_server as serve # noqa: PLC0415
73
+ except ImportError as error:
74
+ raise ConfigurationError(PROMETHEUS_HINT) from error
75
+ serve(port)
76
+
77
+
78
+ def scheduler_metrics(configured: MetricsSink, port: int | None) -> MetricsSink:
79
+ if port is None:
80
+ return configured
81
+ try:
82
+ from goatbeat.prometheus import PrometheusMetrics # noqa: PLC0415
83
+ except ImportError as error:
84
+ raise ConfigurationError(PROMETHEUS_HINT) from error
85
+ start_http_server(port)
86
+ if isinstance(configured, PrometheusMetrics):
87
+ return configured
88
+ if isinstance(configured, NoopMetrics):
89
+ return PrometheusMetrics()
90
+ return FanoutMetrics(configured, PrometheusMetrics())
91
+
92
+
93
+ @contextmanager
94
+ def open_environment(app_reference: str | None) -> Generator[Environment]:
95
+ reference = app_reference or os.environ.get(APP_VARIABLE)
96
+ with ExitStack() as stack:
97
+ if reference is None and os.environ.get(DJANGO_VARIABLE):
98
+ yield _django_environment(stack)
99
+ elif reference is None:
100
+ raise ConfigurationError(
101
+ "tell goatbeat where your schedules are: --app module:attribute, "
102
+ f"{APP_VARIABLE}, or {DJANGO_VARIABLE} for a Django project"
103
+ )
104
+ else:
105
+ config = GoatbeatConfig.from_env(os.environ)
106
+ yield _standalone_environment(stack, load_app(reference), config)
107
+
108
+
109
+ def load_app(reference: str) -> GoatbeatApp:
110
+ module_name, _, attribute = reference.partition(":")
111
+ if not module_name or not attribute:
112
+ raise ConfigurationError(f"--app expects module:attribute, got {reference!r}")
113
+ try:
114
+ module = import_module(module_name) # nosemgrep: non-literal-import
115
+ target: object = getattr(module, attribute)
116
+ except (ImportError, AttributeError) as error:
117
+ raise ConfigurationError(f"cannot load {reference!r}: {error}") from error
118
+ if callable(target):
119
+ target = target()
120
+ if not isinstance(target, GoatbeatApp):
121
+ raise ConfigurationError(f"{reference!r} is not a GoatbeatApp")
122
+ return target
123
+
124
+
125
+ def _standalone_environment(
126
+ stack: ExitStack, app: GoatbeatApp, config: GoatbeatConfig
127
+ ) -> Environment:
128
+ url = config.database_url
129
+ if url is None:
130
+ raise ConfigurationError(
131
+ f"set {DATABASE_VARIABLE} (postgresql://..., {SQLITE_PREFIX}path or memory://)"
132
+ )
133
+ scheme = urlsplit(url).scheme or (LIBPQ_KEYWORDS if "=" in url else "")
134
+ if scheme == "memory":
135
+ memory = MemoryStore(SystemClock())
136
+ return _environment(app, config, memory, memory)
137
+ if scheme == "sqlite":
138
+ sqlite = SQLiteStore(_sqlite_path(url), SystemClock())
139
+ stack.callback(sqlite.close)
140
+ return _environment(app, config, sqlite, sqlite)
141
+ if scheme in {"postgres", "postgresql", LIBPQ_KEYWORDS}:
142
+ return _postgres_environment(stack, app, config, url)
143
+ raise ConfigurationError(f"unsupported database URL scheme {scheme!r}")
144
+
145
+
146
+ def _sqlite_path(url: str) -> str:
147
+ path = url.removeprefix(SQLITE_PREFIX)
148
+ if not url.startswith(SQLITE_PREFIX) or not path or urlsplit(url).netloc:
149
+ raise ConfigurationError(
150
+ f"SQLite URLs look like {SQLITE_PREFIX}relative.db or {SQLITE_PREFIX}/absolute.db, "
151
+ f"got {url!r}"
152
+ )
153
+ return path
154
+
155
+
156
+ def _environment(
157
+ app: GoatbeatApp, config: GoatbeatConfig, store: ScheduleStore, leases: LeaseManager
158
+ ) -> Environment:
159
+ return Environment(
160
+ config=config,
161
+ store=store,
162
+ leases=leases,
163
+ registry=app.registry,
164
+ submitter=app.submitter,
165
+ metrics=app.metrics,
166
+ )
167
+
168
+
169
+ def _postgres_environment(
170
+ stack: ExitStack, app: GoatbeatApp, config: GoatbeatConfig, url: str
171
+ ) -> Environment:
172
+ try:
173
+ from goatbeat.postgres.notify import PsycopgNotifier # noqa: PLC0415
174
+ from goatbeat.postgres.provider import PsycopgProvider # noqa: PLC0415
175
+ from goatbeat.postgres.schema import ( # noqa: PLC0415
176
+ SCHEMA_VERSION,
177
+ applied_version,
178
+ database_clock,
179
+ migrate,
180
+ )
181
+ from goatbeat.postgres.store import PostgresStore # noqa: PLC0415
182
+ except ImportError as error:
183
+ raise ConfigurationError("PostgreSQL needs: pip install goatbeat[postgres]") from error
184
+ provider = stack.enter_context(PsycopgProvider.connect(url, wait=True))
185
+ notifier = stack.enter_context(PsycopgNotifier(url))
186
+ store = PostgresStore(provider, notifier=notifier)
187
+
188
+ def schema_problem() -> str | None:
189
+ version = applied_version(provider)
190
+ if version < SCHEMA_VERSION:
191
+ return f"schema at version {version}, expected {SCHEMA_VERSION}: run goatbeat migrate"
192
+ if version > SCHEMA_VERSION:
193
+ return f"schema at version {version} is newer than this release: upgrade goatbeat"
194
+ return None
195
+
196
+ def ready() -> None:
197
+ problem = schema_problem()
198
+ if problem is not None:
199
+ raise ConfigurationError(problem)
200
+
201
+ def checks() -> Sequence[Check]:
202
+ problem = schema_problem()
203
+ drift = database_clock(provider) - SystemClock().now()
204
+ return (
205
+ Check("schema", problem is None, problem or f"at version {SCHEMA_VERSION}"),
206
+ Check(
207
+ "clock",
208
+ abs(drift) <= MAX_CLOCK_DRIFT,
209
+ f"local clock is {drift.total_seconds():+.3f} s from PostgreSQL",
210
+ ),
211
+ )
212
+
213
+ def apply_migrations() -> str:
214
+ return f"schema at version {migrate(provider)}"
215
+
216
+ environment = _environment(app, config, store, store)
217
+ environment.backend_checks = checks
218
+ environment.migrate = apply_migrations
219
+ environment.ready = ready
220
+ return environment
221
+
222
+
223
+ def _django_environment(stack: ExitStack) -> Environment:
224
+ try:
225
+ import django # noqa: PLC0415
226
+ from django.core.exceptions import ImproperlyConfigured # noqa: PLC0415
227
+
228
+ from goatbeat.django import registry # noqa: PLC0415
229
+ from goatbeat.django.conf import DjangoSettings # noqa: PLC0415
230
+ from goatbeat.django.provider import DjangoProvider, notifier_conninfo # noqa: PLC0415
231
+ from goatbeat.django.submitter import DjangoTasksSubmitter, is_django_task # noqa: PLC0415
232
+ from goatbeat.postgres.notify import PsycopgNotifier # noqa: PLC0415
233
+ from goatbeat.postgres.store import PostgresStore # noqa: PLC0415
234
+ except ImportError as error:
235
+ raise ConfigurationError("Django projects need: pip install goatbeat[django]") from error
236
+ module = os.environ.get(DJANGO_VARIABLE, "")
237
+ try:
238
+ if find_spec(module) is None:
239
+ raise ImportError(f"no module named {module!r}")
240
+ django.setup()
241
+ except (ImportError, ImproperlyConfigured) as error:
242
+ raise ConfigurationError(f"cannot load the Django settings {module!r}: {error}") from error
243
+ settings = DjangoSettings.from_django()
244
+ notifier = stack.enter_context(PsycopgNotifier(notifier_conninfo(settings.database)))
245
+ store = PostgresStore(DjangoProvider(settings.database), notifier=notifier)
246
+ environment = Environment(
247
+ config=settings.config,
248
+ store=store,
249
+ leases=store,
250
+ registry=registry(),
251
+ submitter=DjangoTasksSubmitter(),
252
+ metrics=NoopMetrics(),
253
+ task_problem=lambda path: None if is_django_task(path) else f"{path}: not a Django task",
254
+ )
255
+ environment.migrate = lambda: "Django projects migrate with: python manage.py migrate"
256
+ return environment
goatbeat/cli/logs.py ADDED
@@ -0,0 +1,47 @@
1
+ import logging
2
+ import sys
3
+ from typing import Final, override
4
+
5
+ from goatbeat.core.observability import EXTRA_KEY, LOGGER_NAME, JsonFormatter
6
+
7
+ DATABASE_MODULES: Final = ("psycopg", "django.db")
8
+
9
+
10
+ class TextFormatter(logging.Formatter):
11
+ @override
12
+ def format(self, record: logging.LogRecord) -> str:
13
+ line = super().format(record)
14
+ fields: object = record.__dict__.get(EXTRA_KEY)
15
+ if isinstance(fields, dict):
16
+ extra = " ".join(
17
+ f"{key}={value}"
18
+ for key, value in fields.items() # pyright: ignore[reportUnknownVariableType]
19
+ if key != "event"
20
+ )
21
+ line = f"{line} {extra}" if extra else line
22
+ return line
23
+
24
+
25
+ def configure_logging(level: str, *, as_json: bool) -> None:
26
+ logger = logging.getLogger(LOGGER_NAME)
27
+ for handler in list(logger.handlers):
28
+ if getattr(handler, "goatbeat_cli", False):
29
+ logger.removeHandler(handler)
30
+ handler = logging.StreamHandler(sys.stderr)
31
+ handler.goatbeat_cli = True # type: ignore[attr-defined]
32
+ handler.setFormatter(
33
+ JsonFormatter() if as_json else TextFormatter("%(asctime)s %(levelname)s %(message)s")
34
+ )
35
+ logger.addHandler(handler)
36
+ logger.setLevel(level)
37
+
38
+
39
+ def is_database_unavailable(error: Exception) -> bool:
40
+ """psycopg is optional here, so its connection errors are recognized without importing it."""
41
+ for kind in type(error).__mro__:
42
+ if (
43
+ kind.__module__.startswith(("psycopg", "django.db"))
44
+ and kind.__name__ == "OperationalError"
45
+ ):
46
+ return True
47
+ return False
goatbeat/cli/main.py ADDED
@@ -0,0 +1,112 @@
1
+ import argparse
2
+ import sys
3
+ from collections.abc import Callable, Sequence
4
+ from typing import Final
5
+
6
+ from goatbeat.cli import commands
7
+ from goatbeat.cli.environment import Environment, open_environment
8
+ from goatbeat.cli.logs import configure_logging, is_database_unavailable
9
+ from goatbeat.core.errors import ConfigurationError, StoreUnavailableError
10
+
11
+ CONFIGURATION_ERROR: Final = 3
12
+ STORE_UNAVAILABLE: Final = 4
13
+ UNEXPECTED_ERROR: Final = 5
14
+ LOG_LEVELS: Final = ("DEBUG", "INFO", "WARNING", "ERROR")
15
+
16
+ type Handler = Callable[[Environment, argparse.Namespace], int]
17
+
18
+
19
+ def parser() -> argparse.ArgumentParser:
20
+ root = argparse.ArgumentParser(prog="goatbeat", description=__doc__)
21
+ root.add_argument("--app", help="module:attribute of a GoatbeatApp (or GOATBEAT_APP)")
22
+ root.add_argument("--log-level", choices=LOG_LEVELS, default="INFO")
23
+ root.add_argument("--log-format", choices=("text", "json"), default="text")
24
+ sub = root.add_subparsers(dest="command", required=True)
25
+
26
+ scheduler = sub.add_parser("scheduler", help="run the scheduler until SIGTERM")
27
+ scheduler.add_argument("--once", action="store_true", help="run one tick and exit")
28
+ scheduler.add_argument("--metrics-port", type=int, help="serve Prometheus metrics")
29
+ scheduler.set_defaults(handler=_scheduler)
30
+
31
+ status = sub.add_parser("status", help="show the leader and schedules")
32
+ status.add_argument("--json", action="store_true", help="machine-readable output")
33
+ status.set_defaults(handler=_status)
34
+
35
+ doctor = sub.add_parser("doctor", help="check the setup")
36
+ doctor.add_argument("--json", action="store_true", help="machine-readable output")
37
+ doctor.set_defaults(handler=_doctor)
38
+
39
+ migrate = sub.add_parser("migrate", help="create or upgrade the database schema")
40
+ migrate.set_defaults(handler=_migrate)
41
+
42
+ schedule = sub.add_parser("schedule", help="inspect and manage schedules")
43
+ actions = schedule.add_subparsers(dest="action", required=True)
44
+ listing = actions.add_parser("list", help="publish code schedules and list them")
45
+ listing.add_argument("--json", action="store_true", help="machine-readable output")
46
+ listing.set_defaults(handler=_list)
47
+ run = actions.add_parser("run", help="ask the leader to run a schedule now")
48
+ run.add_argument("name")
49
+ run.set_defaults(handler=_run)
50
+ prune = actions.add_parser("prune", help="disable code schedules no longer declared")
51
+ prune.set_defaults(handler=_prune)
52
+ return root
53
+
54
+
55
+ def main(argv: Sequence[str] | None = None) -> int:
56
+ arguments = parser().parse_args(argv)
57
+ handler: Handler = arguments.handler
58
+ configure_logging(arguments.log_level, as_json=arguments.log_format == "json")
59
+ try:
60
+ with open_environment(arguments.app) as environment:
61
+ return handler(environment, arguments)
62
+ except ConfigurationError as error:
63
+ sys.stderr.write(f"goatbeat: configuration error: {error}\n")
64
+ return CONFIGURATION_ERROR
65
+ except StoreUnavailableError as error:
66
+ sys.stderr.write(f"goatbeat: store unavailable: {error}\n")
67
+ return STORE_UNAVAILABLE
68
+ except commands.CommandFailedError as error:
69
+ sys.stderr.write(f"goatbeat: {error}\n")
70
+ return commands.CHECK_FAILED
71
+ except Exception as error:
72
+ if is_database_unavailable(error):
73
+ sys.stderr.write(f"goatbeat: store unavailable: {error}\n")
74
+ return STORE_UNAVAILABLE
75
+ sys.stderr.write(f"goatbeat: unexpected error: {type(error).__name__}: {error}\n")
76
+ return UNEXPECTED_ERROR
77
+
78
+
79
+ def _scheduler(environment: Environment, arguments: argparse.Namespace) -> int:
80
+ return commands.run_scheduler(
81
+ environment, once=arguments.once, metrics_port=arguments.metrics_port
82
+ )
83
+
84
+
85
+ def _status(environment: Environment, arguments: argparse.Namespace) -> int:
86
+ return commands.status(environment, as_json=bool(arguments.json))
87
+
88
+
89
+ def _doctor(environment: Environment, arguments: argparse.Namespace) -> int:
90
+ return commands.doctor(environment, as_json=bool(arguments.json))
91
+
92
+
93
+ def _migrate(environment: Environment, arguments: argparse.Namespace) -> int:
94
+ del arguments
95
+ return commands.migrate(environment)
96
+
97
+
98
+ def _list(environment: Environment, arguments: argparse.Namespace) -> int:
99
+ return commands.list_schedules(environment, as_json=bool(arguments.json))
100
+
101
+
102
+ def _run(environment: Environment, arguments: argparse.Namespace) -> int:
103
+ return commands.run_now(environment, str(arguments.name))
104
+
105
+
106
+ def _prune(environment: Environment, arguments: argparse.Namespace) -> int:
107
+ del arguments
108
+ return commands.prune(environment)
109
+
110
+
111
+ if __name__ == "__main__":
112
+ raise SystemExit(main())
goatbeat/cli/py.typed ADDED
File without changes
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: goatbeat-cli
3
+ Version: 0.1.0
4
+ Summary: The goatbeat command line.
5
+ Keywords: scheduler,cron,periodic-tasks,distributed,django-tasks,leader-election
6
+ Author: Louis Midson LAJEANTY
7
+ Author-email: Louis Midson LAJEANTY <midsonlajeanty@proton.me>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Topic :: System :: Distributed Computing
16
+ Classifier: Typing :: Typed
17
+ Requires-Dist: goatbeat-core==0.1.0
18
+ Requires-Python: >=3.13
19
+ Project-URL: Homepage, https://github.com/goatlabshq/goatbeat
20
+ Project-URL: Documentation, https://goatlabshq.github.io/goatbeat/
21
+ Project-URL: Issues, https://github.com/goatlabshq/goatbeat/issues
22
+ Description-Content-Type: text/markdown
23
+
24
+ # goatbeat-cli
25
+
26
+ The `goatbeat` command line: run the scheduler, inspect status and manage schedules. Part of [Goatbeat](https://github.com/goatlabshq/goatbeat).
@@ -0,0 +1,11 @@
1
+ goatbeat/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ goatbeat/cli/commands.py,sha256=fGC8pSVtpgxEpGijn6_Qofu272mrQ2-sk5Qoi5mrt_Q,6110
3
+ goatbeat/cli/environment.py,sha256=BtMysRc5ZMUU40zHkHvz1Yq7d_fdRLOG2kwmQHmRD1E,9763
4
+ goatbeat/cli/logs.py,sha256=5J2JnDp11Zql3iKv003ZHcAM4qA-xuRy__9KFNhcYlk,1629
5
+ goatbeat/cli/main.py,sha256=NA4XCq4NNVGOD9jKAadoC1E3HWG4TiQolRQpHhnp2Uc,4663
6
+ goatbeat/cli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ goatbeat_cli-0.1.0.dist-info/licenses/LICENSE,sha256=Zcs5rp01UDASfBq28ErEU7tdk14P5WTR3zNmlaZKnkA,1105
8
+ goatbeat_cli-0.1.0.dist-info/WHEEL,sha256=oBPtwmRtX9fzUVIGqTf2NA4uW8MUj4N1HrmGwtPkDyA,80
9
+ goatbeat_cli-0.1.0.dist-info/entry_points.txt,sha256=8aiWREFaiqegZ_H_imAz5IzEvf0DUjdbyyHnWDpvG5c,53
10
+ goatbeat_cli-0.1.0.dist-info/METADATA,sha256=QNkLohgobgT__50dU07eXn90bH-_Nw2oUkocp7ClsfA,1089
11
+ goatbeat_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.2
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ goatbeat = goatbeat.cli.main:main
3
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Louis Midson LAJEANTY <midsonlajeanty@proton.me>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.