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 +0 -0
- cronstable/__main__.py +278 -0
- cronstable/_json.py +127 -0
- cronstable/backends/__init__.py +55 -0
- cronstable/backends/etcd.py +1113 -0
- cronstable/backends/filesystem.py +882 -0
- cronstable/backends/kubernetes.py +1577 -0
- cronstable/cluster.py +3402 -0
- cronstable/config.py +3136 -0
- cronstable/cron.py +7709 -0
- cronstable/cronexpr.py +512 -0
- cronstable/crontabs.py +277 -0
- cronstable/dag.py +1201 -0
- cronstable/dagrun.py +1773 -0
- cronstable/fingerprint.py +284 -0
- cronstable/job.py +1022 -0
- cronstable/jobapi.py +838 -0
- cronstable/jobcli.py +805 -0
- cronstable/jobstate.py +422 -0
- cronstable/leadership.py +600 -0
- cronstable/platform.py +416 -0
- cronstable/prometheus.py +1122 -0
- cronstable/redact.py +235 -0
- cronstable/resources.py +989 -0
- cronstable/state.py +2847 -0
- cronstable/state_admin.py +666 -0
- cronstable/statsd.py +82 -0
- cronstable/version.py +24 -0
- cronstable/web/__init__.py +3 -0
- cronstable/web/index.html +7107 -0
- cronstable-1.2.11.dist-info/METADATA +1957 -0
- cronstable-1.2.11.dist-info/RECORD +36 -0
- cronstable-1.2.11.dist-info/WHEEL +5 -0
- cronstable-1.2.11.dist-info/entry_points.txt +2 -0
- cronstable-1.2.11.dist-info/licenses/LICENSE +21 -0
- cronstable-1.2.11.dist-info/top_level.txt +1 -0
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
|