cronstable 1.2.11__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.
cronstable/__init__.py ADDED
File without changes
cronstable/__main__.py ADDED
@@ -0,0 +1,278 @@
1
+ import argparse
2
+ import asyncio
3
+ import logging
4
+ import os
5
+ import sys
6
+
7
+ import cronstable.version
8
+ from cronstable import platform
9
+
10
+ # Where -c looks when not given: /etc/cronstable.d on POSIX,
11
+ # %APPDATA%\cronstable on
12
+ # Windows (see cronstable.platform).
13
+ CONFIG_DEFAULT = platform.DEFAULT_CONFIG_PATH
14
+
15
+
16
+ def _add_state_subcommands(parser: argparse.ArgumentParser) -> None:
17
+ """Wire the `cronstable state <action>` administration subcommands.
18
+
19
+ Bare `cronstable` (no subcommand) stays the daemon. Each action accepts
20
+ its own -c/--config (same dest and default as the daemon flag) so both
21
+ `cronstable -c X state gc` and `cronstable state gc -c X` work.
22
+ """
23
+ sub = parser.add_subparsers(dest="command", metavar="COMMAND")
24
+ state = sub.add_parser(
25
+ "state",
26
+ help="administer the durable state store (backup/restore/migrate/"
27
+ "gc/check/migrate-schema)",
28
+ )
29
+ actions = state.add_subparsers(dest="state_command", metavar="ACTION")
30
+
31
+ def _with_config(sub_parser):
32
+ # SUPPRESS, not CONFIG_DEFAULT: a subparser default would otherwise
33
+ # OVERWRITE a root-level `cronstable -c X state ...` value (argparse
34
+ # applies subparser defaults after the root parse). The root parser
35
+ # already supplies the default.
36
+ sub_parser.add_argument(
37
+ "-c",
38
+ "--config",
39
+ default=argparse.SUPPRESS,
40
+ metavar="FILE-OR-DIR",
41
+ help="configuration with the `state:` section to administer",
42
+ )
43
+ return sub_parser
44
+
45
+ backup = _with_config(
46
+ actions.add_parser(
47
+ "backup", help="write a .tar.gz backup of the store"
48
+ )
49
+ )
50
+ backup.add_argument("-o", "--output", required=True, metavar="FILE.tar.gz")
51
+ restore = _with_config(
52
+ actions.add_parser("restore", help="restore a backup into the store")
53
+ )
54
+ restore.add_argument("archive", metavar="FILE.tar.gz")
55
+ restore.add_argument(
56
+ "--force",
57
+ default=False,
58
+ action="store_true",
59
+ help="merge into a non-empty store (NOT safe while a daemon uses it)",
60
+ )
61
+ migrate = _with_config(
62
+ actions.add_parser(
63
+ "migrate",
64
+ help="copy the store to another path or mount "
65
+ "(local disk <-> S3 Files / EFS)",
66
+ )
67
+ )
68
+ migrate.add_argument("--dest", required=True, metavar="PATH")
69
+ migrate.add_argument(
70
+ "--dest-deployment-id",
71
+ default=None,
72
+ metavar="ID",
73
+ help="namespace at the destination (default: keep the current one)",
74
+ )
75
+ migrate.add_argument(
76
+ "--force",
77
+ default=False,
78
+ action="store_true",
79
+ help="overwrite a non-empty destination store",
80
+ )
81
+ gc = _with_config(
82
+ actions.add_parser(
83
+ "gc", help="garbage-collect state of unreferenced jobs"
84
+ )
85
+ )
86
+ gc.add_argument("--dry-run", default=False, action="store_true")
87
+ _with_config(
88
+ actions.add_parser(
89
+ "check", help="verify the store is usable and print an inventory"
90
+ )
91
+ )
92
+ migrate_schema = _with_config(
93
+ actions.add_parser(
94
+ "migrate-schema",
95
+ help="rewrite records of older known record schemes",
96
+ )
97
+ )
98
+ migrate_schema.add_argument(
99
+ "--dry-run", default=False, action="store_true"
100
+ )
101
+
102
+ # The job-facing state commands. The KV actions (get/set/delete/
103
+ # keys) hang off the SAME `state` subparser as the admin actions above and
104
+ # coexist with them (the action name routes); the other verbs (cursor/
105
+ # lock/artifact/idempotent/secret) are their own top-level commands. Both
106
+ # are thin clients of the daemon's loopback endpoint. Imported here (not at
107
+ # module load) so the import cost is paid only when the CLI is built.
108
+ from cronstable import jobcli
109
+
110
+ jobcli.add_state_job_actions(actions)
111
+ jobcli.add_job_commands(sub)
112
+
113
+
114
+ def main_loop(loop):
115
+ parser = argparse.ArgumentParser(prog="cronstable")
116
+ parser.add_argument(
117
+ "-c",
118
+ "--config",
119
+ default=CONFIG_DEFAULT,
120
+ metavar="FILE-OR-DIR",
121
+ help="configuration file, or directory containing configuration files",
122
+ )
123
+ parser.add_argument("-l", "--log-level", default="INFO")
124
+ parser.add_argument(
125
+ "-v", "--validate-config", default=False, action="store_true"
126
+ )
127
+ parser.add_argument(
128
+ "--job-set-id",
129
+ default=False,
130
+ action="store_true",
131
+ help="print the job-set id (an order-independent hash of every job's "
132
+ "effective configuration) and exit; identical across instances "
133
+ "running the same set of jobs",
134
+ )
135
+ parser.add_argument("--version", default=False, action="store_true")
136
+ _add_state_subcommands(parser)
137
+ # `lock run NAME [flags] -- CMD...` carries an arbitrary trailing command.
138
+ # argparse cannot capture it portably: nargs=REMAINDER swallows our own
139
+ # flags into the command list, while nargs="*" only picks up the tokens
140
+ # after "--" on Python >= 3.13 (older argparse reports them as
141
+ # "unrecognized arguments"). So split the command off at the first "--"
142
+ # ourselves -- identical on every supported Python -- and hand argparse
143
+ # only the head, where our flags and NAME parse cleanly everywhere.
144
+ argv = sys.argv[1:]
145
+ trailing_command = None
146
+ if "--" in argv:
147
+ cut = argv.index("--")
148
+ argv, trailing_command = argv[:cut], argv[cut + 1 :]
149
+ args = parser.parse_args(argv)
150
+ if trailing_command is not None:
151
+ if (
152
+ getattr(args, "command", None) == "lock"
153
+ and getattr(args, "lock_command", None) == "run"
154
+ ):
155
+ args.run_command = trailing_command
156
+ else:
157
+ parser.error("`--` is only valid before a `lock run` command")
158
+
159
+ logging.basicConfig(level=getattr(logging, args.log_level))
160
+ # logging.getLogger("asyncio").setLevel(logging.WARNING)
161
+ logger = logging.getLogger("cronstable")
162
+
163
+ if args.version:
164
+ print(cronstable.version.version)
165
+ sys.exit(0)
166
+
167
+ command = getattr(args, "command", None)
168
+ if command == "state":
169
+ # `state get/set/delete/keys` are job-facing (they reach the running
170
+ # daemon's loopback endpoint); everything else under `state` is offline
171
+ # store administration. Route by action name so the two coexist.
172
+ from cronstable import jobcli
173
+
174
+ if getattr(args, "state_command", None) in jobcli.STATE_JOB_ACTIONS:
175
+ sys.exit(jobcli.dispatch(args))
176
+ # lazy import: the admin module (tarfile etc.) costs the daemon and
177
+ # the stateless install nothing.
178
+ from cronstable import state_admin
179
+
180
+ sys.exit(state_admin.dispatch(args))
181
+
182
+ if command in (
183
+ "cursor",
184
+ "lock",
185
+ "artifact",
186
+ "idempotent",
187
+ "secret",
188
+ "xcom",
189
+ ):
190
+ from cronstable import jobcli
191
+
192
+ sys.exit(jobcli.dispatch(args))
193
+
194
+ if args.config == CONFIG_DEFAULT and not os.path.exists(args.config):
195
+ print(
196
+ "cronstable error: configuration file not found, please provide "
197
+ "one with the --config option",
198
+ file=sys.stderr,
199
+ )
200
+ parser.print_help(sys.stderr)
201
+ sys.exit(1)
202
+
203
+ # Imported here, not at module top: this pulls in aiohttp, strictyaml,
204
+ # sentry_sdk and the rest of the daemon graph (~300ms of import). The
205
+ # branches that exit before this point -- --version and the state / xcom
206
+ # / lock / cursor / artifact / idempotent / secret subcommands (thin
207
+ # urllib clients of the running daemon, routinely spawned from inside
208
+ # jobs) -- never touch Cron, so a job-facing CLI call no longer pays that
209
+ # cost. Everything from here down (the daemon, --job-set-id,
210
+ # --validate-config) needs it.
211
+ from cronstable.cron import ConfigError, Cron
212
+
213
+ try:
214
+ cron = Cron(args.config)
215
+ except ConfigError as err:
216
+ logger.error("Configuration error: %s", str(err))
217
+ sys.exit(1)
218
+
219
+ if args.job_set_id:
220
+ print(cron.job_set_id())
221
+ sys.exit(0)
222
+
223
+ if args.validate_config:
224
+ logger.info("Configuration is valid.")
225
+ sys.exit(0)
226
+
227
+ # Wire Ctrl-C / termination to a graceful shutdown. The mechanism differs
228
+ # per platform (loop signal handlers on POSIX, signal.signal on Windows),
229
+ # so it lives behind platform.install_shutdown_handlers.
230
+ remove_shutdown_handlers = platform.install_shutdown_handlers(
231
+ loop, cron.signal_shutdown
232
+ )
233
+ try:
234
+ loop.run_until_complete(cron.run())
235
+ finally:
236
+ remove_shutdown_handlers()
237
+
238
+
239
+ def _new_event_loop(): # pragma: no cover
240
+ """The event loop to run on: uvloop's faster libuv loop when available,
241
+ otherwise stock asyncio.
242
+
243
+ uvloop is a drop-in, libuv-based replacement for asyncio's selector loop
244
+ that runs cronstable's I/O paths -- cluster gossip/lease HTTP, the web
245
+ dashboard, the Prometheus scrape -- markedly faster. It is strictly
246
+ optional (install the ``speedups`` extra to pull it in): it has no Windows
247
+ build (where cronstable also needs the Proactor loop for subprocess
248
+ support)
249
+ and ships no wheels for some of the leaner architectures we target, so a
250
+ missing or unimportable uvloop silently falls back to stock asyncio with
251
+ identical behavior. Selecting the loop directly (rather than via
252
+ ``asyncio.set_event_loop_policy``) sidesteps the event-loop-policy API that
253
+ Python 3.14 deprecates.
254
+
255
+ ``asyncio.new_event_loop()`` yields the right stock loop per platform: a
256
+ subprocess-capable Proactor loop on Windows (the default since 3.8) and a
257
+ selector loop on POSIX.
258
+ """
259
+ if sys.platform != "win32":
260
+ try:
261
+ import uvloop
262
+ except ImportError:
263
+ pass
264
+ else:
265
+ return uvloop.new_event_loop()
266
+ return asyncio.new_event_loop()
267
+
268
+
269
+ def main(): # pragma: no cover
270
+ _loop = _new_event_loop()
271
+ try:
272
+ main_loop(_loop)
273
+ finally:
274
+ _loop.close()
275
+
276
+
277
+ if __name__ == "__main__": # pragma: no cover
278
+ main()
cronstable/_json.py ADDED
@@ -0,0 +1,127 @@
1
+ """Optional orjson acceleration for the hot JSON serialization paths.
2
+
3
+ orjson (a compiled Rust/pyo3 extension) serializes and parses JSON several
4
+ times faster than the stdlib and hands back ``bytes`` directly, saving the
5
+ separate ``.encode("utf-8")`` every persistence site does by hand. It is an
6
+ OPTIONAL speedup, wired exactly like the uvloop event-loop swap: it ships no
7
+ wheels for some of the leaner architectures cronstable targets (riscv64, armv6,
8
+ musl ppc64le/s390x), so it stays out of the core dependency set (install the
9
+ ``speedups`` extra to pull it in) and this module transparently falls back to
10
+ the stdlib ``json`` when it is absent -- identical behaviour, just slower.
11
+
12
+ CONTRACT -- read before pointing anything new at these helpers:
13
+
14
+ Use them only where the serialized bytes are round-tripped back through
15
+ :func:`loads` (durable state records, leases, documents; parsing peer gossip
16
+ bodies). orjson always emits UTF-8 and does NOT ``ensure_ascii``, so for
17
+ non-ASCII input its bytes are NOT identical to
18
+ ``json.dumps(...).encode("utf-8")``. Anywhere the exact bytes matter -- a value
19
+ fed into a hash / content-address, or compared across nodes or versions (the
20
+ job-set fingerprint in :mod:`cronstable.fingerprint`, the cluster peer ETag in
21
+ :mod:`cronstable.cluster`) -- keep the stdlib ``json`` directly, so the output
22
+ stays stable and backend-independent whether or not a given host has orjson.
23
+ """
24
+
25
+ import json as _stdlib
26
+ import math
27
+ from typing import Any, Union, cast
28
+
29
+ try:
30
+ import orjson
31
+ except ImportError: # pragma: no cover - exercised on the no-orjson baseline
32
+ orjson = None # type: ignore[assignment]
33
+
34
+
35
+ # The portable-value contract shared by BOTH backends. orjson and the stdlib
36
+ # are not interchangeable at the edges: orjson accepts only integers in the
37
+ # signed-64..unsigned-64 window (it raises on anything wider) and SILENTLY
38
+ # rewrites a non-finite float to ``null``; the stdlib accepts arbitrary-width
39
+ # ints and writes ``NaN`` / ``Infinity`` tokens that orjson then refuses to
40
+ # parse. So a value outside this window is serialized DIFFERENTLY -- or
41
+ # unreadably -- depending on whether a given host installed the optional orjson
42
+ # extra. On a mixed fleet sharing one store that is silent corruption: a node
43
+ # without orjson writes ``{"value":Infinity}`` that every orjson node's
44
+ # ``loads`` then rejects (a wedged cursor / a watermark read back as unset ->
45
+ # reprocessed backlog), or an orjson node coerces ``nan`` to ``null`` a stdlib
46
+ # node reads back as ``None``. :func:`dumps_bytes` rejects such values
47
+ # UNIFORMLY, on every backend, so the durable store stays backend-independent:
48
+ # bytes any node writes, every node can read.
49
+ _INT_MIN = -(2**63)
50
+ _INT_MAX = 2**64 - 1
51
+
52
+
53
+ class UnsupportedValue(ValueError):
54
+ """A value that cannot be encoded identically across a mixed-orjson fleet.
55
+
56
+ Raised by :func:`dumps_bytes` (and available as a standalone pre-check via
57
+ :func:`ensure_portable`) for a non-finite float (``NaN`` / ``Infinity``),
58
+ an integer outside the 64-bit window orjson supports, or a non-string
59
+ object key. Rejecting at write time is what keeps a record readable by
60
+ every node regardless of which ones have orjson.
61
+ """
62
+
63
+
64
+ def ensure_portable(obj: Any) -> None:
65
+ """Raise :class:`UnsupportedValue` if ``obj`` is not fleet-portable JSON.
66
+
67
+ A recursive, backend-independent pre-check so the accept/reject decision is
68
+ identical on every host -- with or without orjson -- instead of one backend
69
+ silently corrupting what the other rejects. Called at the top of
70
+ :func:`dumps_bytes`; a boundary that wants the check without serializing
71
+ (to translate the failure into its own error type) invokes it directly.
72
+ """
73
+ if isinstance(obj, bool):
74
+ return # a bool is an int subclass but always in range and portable
75
+ if isinstance(obj, float):
76
+ if not math.isfinite(obj):
77
+ raise UnsupportedValue(
78
+ "non-finite float {!r} is not portable across the fleet "
79
+ "(NaN/Infinity serializes differently with and without "
80
+ "orjson)".format(obj)
81
+ )
82
+ elif isinstance(obj, int):
83
+ if obj < _INT_MIN or obj > _INT_MAX:
84
+ raise UnsupportedValue(
85
+ "integer {} is outside the portable signed/unsigned 64-bit "
86
+ "range [{}, {}]".format(obj, _INT_MIN, _INT_MAX)
87
+ )
88
+ elif isinstance(obj, dict):
89
+ for key, value in obj.items():
90
+ if not isinstance(key, str):
91
+ raise UnsupportedValue(
92
+ "non-string object key {!r} is not portable across the "
93
+ "fleet (orjson rejects it, the stdlib coerces it)".format(
94
+ key
95
+ )
96
+ )
97
+ ensure_portable(value)
98
+ elif isinstance(obj, (list, tuple)):
99
+ for value in obj:
100
+ ensure_portable(value)
101
+
102
+
103
+ if orjson is not None:
104
+
105
+ def dumps_bytes(obj: Any, *, sort_keys: bool = False) -> bytes:
106
+ """Serialize ``obj`` to compact UTF-8 JSON bytes."""
107
+ ensure_portable(obj)
108
+ option = orjson.OPT_SORT_KEYS if sort_keys else 0
109
+ # cast: the tox mypy env runs --ignore-missing-imports (orjson not a
110
+ # dev dep), so orjson.dumps reads as Any and warn_return_any fires.
111
+ return cast(bytes, orjson.dumps(obj, option=option))
112
+
113
+ def loads(data: Union[bytes, str]) -> Any:
114
+ """Parse JSON from ``bytes`` or ``str``."""
115
+ return orjson.loads(data)
116
+
117
+ else:
118
+
119
+ def dumps_bytes(obj: Any, *, sort_keys: bool = False) -> bytes:
120
+ """Serialize ``obj`` to compact UTF-8 JSON bytes."""
121
+ ensure_portable(obj)
122
+ text = _stdlib.dumps(obj, separators=(",", ":"), sort_keys=sort_keys)
123
+ return text.encode("utf-8")
124
+
125
+ def loads(data: Union[bytes, str]) -> Any:
126
+ """Parse JSON from ``bytes`` or ``str``."""
127
+ return _stdlib.loads(data)
@@ -0,0 +1,55 @@
1
+ """Optional lease-store leadership backends (kubernetes, etcd).
2
+
3
+ Each module here implements :class:`cronstable.leadership.LeaseBackend` against
4
+ a
5
+ real coordination store, over plain HTTP via the core ``aiohttp`` dependency --
6
+ so neither adds a runtime dependency and, by avoiding grpc/protobuf wheels,
7
+ both run on every architecture cronstable targets.
8
+
9
+ * **kubernetes** drives a ``coordination.k8s.io/v1`` ``Lease`` and has **two
10
+ interchangeable transports**: the official ``kubernetes`` client when it is
11
+ installed (and importable on this architecture), or a hand-rolled apiserver
12
+ REST transport otherwise. ``cluster.kubernetes.clientLibrary`` chooses:
13
+ ``auto`` (default) prefers the native client and falls back to HTTP;
14
+ ``library`` requires it (a config error if absent); ``http`` forces the
15
+ hand-rolled path.
16
+ * **etcd** uses etcd's own v3 gRPC-gateway JSON/HTTP API directly -- a single,
17
+ fully-portable transport, with no optional client library (the gateway is
18
+ etcd's first-class HTTP interface, so a native grpc client buys little).
19
+
20
+ The modules are imported lazily by :func:`cronstable.leadership.make_backend`,
21
+ so they never enter the import graph unless ``cluster.backend`` selects them.
22
+ """
23
+
24
+ from cronstable.config import ConfigError
25
+
26
+ # transport kinds returned by select_transport.
27
+ TRANSPORT_HTTP = "http"
28
+ TRANSPORT_LIBRARY = "library"
29
+
30
+
31
+ def select_transport(
32
+ client_library: str, native_available: bool, backend: str
33
+ ) -> str:
34
+ """Choose the transport for a lease backend (pure; unit-tested).
35
+
36
+ ``client_library`` is the resolved ``cluster.<backend>.clientLibrary``
37
+ setting, ``native_available`` whether the native client imported on this
38
+ architecture. ``auto`` prefers the native client when present; ``library``
39
+ requires it (raising :class:`~cronstable.config.ConfigError` if absent);
40
+ ``http`` always uses the hand-rolled transport.
41
+ """
42
+ if client_library == "http":
43
+ return TRANSPORT_HTTP
44
+ if client_library == "library":
45
+ if not native_available:
46
+ raise ConfigError(
47
+ "cluster.{0}.clientLibrary is 'library' but the native client "
48
+ "is not importable on this architecture; install the optional "
49
+ "cronstable[{0}] extra, or use clientLibrary auto/http".format(
50
+ backend
51
+ )
52
+ )
53
+ return TRANSPORT_LIBRARY
54
+ # "auto": prefer the native client when present, else the HTTP fallback.
55
+ return TRANSPORT_LIBRARY if native_available else TRANSPORT_HTTP