pgdispatch 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.
- pgdispatch/__init__.py +69 -0
- pgdispatch/__main__.py +5 -0
- pgdispatch/_version.py +1 -0
- pgdispatch/backends/__init__.py +6 -0
- pgdispatch/backends/asyncpg.py +103 -0
- pgdispatch/backends/base.py +142 -0
- pgdispatch/cli.py +420 -0
- pgdispatch/client.py +490 -0
- pgdispatch/config.py +223 -0
- pgdispatch/core/__init__.py +1 -0
- pgdispatch/core/circuit.py +94 -0
- pgdispatch/core/common.py +87 -0
- pgdispatch/core/deliveries.py +216 -0
- pgdispatch/core/endpoints.py +338 -0
- pgdispatch/core/events.py +65 -0
- pgdispatch/core/maintenance.py +121 -0
- pgdispatch/core/matching.py +76 -0
- pgdispatch/core/payload.py +108 -0
- pgdispatch/core/publisher.py +232 -0
- pgdispatch/core/retry.py +90 -0
- pgdispatch/core/secrets.py +138 -0
- pgdispatch/core/signing.py +141 -0
- pgdispatch/django/__init__.py +37 -0
- pgdispatch/django/apps.py +10 -0
- pgdispatch/django/client.py +402 -0
- pgdispatch/django/management/__init__.py +0 -0
- pgdispatch/django/management/commands/__init__.py +0 -0
- pgdispatch/django/management/commands/pgdispatch_worker.py +32 -0
- pgdispatch/django/migrations/0001_initial.py +30 -0
- pgdispatch/django/migrations/__init__.py +0 -0
- pgdispatch/errors.py +43 -0
- pgdispatch/fastapi/__init__.py +23 -0
- pgdispatch/fastapi/router.py +243 -0
- pgdispatch/hooks.py +80 -0
- pgdispatch/metrics.py +80 -0
- pgdispatch/migrations/0001_initial.sql +104 -0
- pgdispatch/migrations/__init__.py +93 -0
- pgdispatch/models.py +208 -0
- pgdispatch/py.typed +0 -0
- pgdispatch/security/__init__.py +1 -0
- pgdispatch/security/ssrf.py +141 -0
- pgdispatch/sqlalchemy.py +130 -0
- pgdispatch/worker/__init__.py +5 -0
- pgdispatch/worker/fetcher.py +183 -0
- pgdispatch/worker/ratelimit.py +47 -0
- pgdispatch/worker/recorder.py +435 -0
- pgdispatch/worker/runner.py +36 -0
- pgdispatch/worker/sender.py +160 -0
- pgdispatch/worker/worker.py +557 -0
- pgdispatch-0.1.0.dist-info/METADATA +329 -0
- pgdispatch-0.1.0.dist-info/RECORD +54 -0
- pgdispatch-0.1.0.dist-info/WHEEL +4 -0
- pgdispatch-0.1.0.dist-info/entry_points.txt +2 -0
- pgdispatch-0.1.0.dist-info/licenses/LICENSE +21 -0
pgdispatch/cli.py
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"""The ``pgdispatch`` command-line tool.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
pgdispatch migrate # create or upgrade the schema
|
|
6
|
+
pgdispatch worker --concurrency 50 # deliver webhooks
|
|
7
|
+
pgdispatch status # queue, lag, open circuit breakers
|
|
8
|
+
pgdispatch deliveries --status failed --since 1h
|
|
9
|
+
pgdispatch deliveries show 18342 # every attempt with responses
|
|
10
|
+
pgdispatch retry 18342 # retry one delivery
|
|
11
|
+
pgdispatch retry --endpoint <id> # retry all failed deliveries of an endpoint
|
|
12
|
+
pgdispatch replay --event <id> [--endpoint <id>]
|
|
13
|
+
pgdispatch endpoints list --owner merchant_42
|
|
14
|
+
pgdispatch endpoints enable <id> # enable and reset the circuit breaker
|
|
15
|
+
pgdispatch cleanup --older-than 14d
|
|
16
|
+
|
|
17
|
+
The database is taken from ``--dsn``, ``PGDISPATCH_DSN`` or ``DATABASE_URL``;
|
|
18
|
+
every other setting from ``PGDISPATCH_*`` environment variables.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import asyncio
|
|
25
|
+
import dataclasses
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
import uuid
|
|
31
|
+
from collections.abc import Callable, Coroutine, Sequence
|
|
32
|
+
from datetime import datetime, timedelta, timezone
|
|
33
|
+
from typing import Any, TextIO
|
|
34
|
+
|
|
35
|
+
import asyncpg
|
|
36
|
+
|
|
37
|
+
from pgdispatch._version import __version__
|
|
38
|
+
from pgdispatch.backends.asyncpg import AsyncpgBackend
|
|
39
|
+
from pgdispatch.client import Webhooks
|
|
40
|
+
from pgdispatch.config import WebhooksConfig, parse_duration
|
|
41
|
+
from pgdispatch.core.common import UNSET, OwnerFilter
|
|
42
|
+
from pgdispatch.errors import PgDispatchError
|
|
43
|
+
from pgdispatch.worker.runner import run_until_signalled
|
|
44
|
+
|
|
45
|
+
__all__ = ["main"]
|
|
46
|
+
|
|
47
|
+
Handler = Callable[[Webhooks, argparse.Namespace, TextIO], Coroutine[Any, Any, int]]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
51
|
+
parser = _build_parser()
|
|
52
|
+
args = parser.parse_args(argv)
|
|
53
|
+
if not hasattr(args, "handler"):
|
|
54
|
+
parser.print_help()
|
|
55
|
+
return 2
|
|
56
|
+
logging.basicConfig(
|
|
57
|
+
level=getattr(logging, args.log_level.upper()),
|
|
58
|
+
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
|
59
|
+
)
|
|
60
|
+
dsn = args.dsn or os.environ.get("PGDISPATCH_DSN") or os.environ.get("DATABASE_URL")
|
|
61
|
+
if not dsn:
|
|
62
|
+
parser.error("no database given: use --dsn, PGDISPATCH_DSN or DATABASE_URL")
|
|
63
|
+
try:
|
|
64
|
+
config = WebhooksConfig(**_config_overrides(args))
|
|
65
|
+
except ValueError as exc:
|
|
66
|
+
parser.error(str(exc))
|
|
67
|
+
try:
|
|
68
|
+
return asyncio.run(_run(args.handler, dsn, config, args))
|
|
69
|
+
except PgDispatchError as exc:
|
|
70
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
71
|
+
return 1
|
|
72
|
+
except asyncpg.UndefinedTableError:
|
|
73
|
+
print(
|
|
74
|
+
f"error: schema {config.db_schema!r} is not set up; run 'pgdispatch migrate' first",
|
|
75
|
+
file=sys.stderr,
|
|
76
|
+
)
|
|
77
|
+
return 1
|
|
78
|
+
except (asyncpg.PostgresError, OSError) as exc:
|
|
79
|
+
print(f"error: database: {exc}", file=sys.stderr)
|
|
80
|
+
return 1
|
|
81
|
+
except KeyboardInterrupt: # pragma: no cover - interactive use
|
|
82
|
+
return 130
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def _run(handler: Handler, dsn: str, config: WebhooksConfig, args: argparse.Namespace) -> int:
|
|
86
|
+
async with Webhooks(AsyncpgBackend(dsn), config) as webhooks:
|
|
87
|
+
return await handler(webhooks, args, sys.stdout)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# --- commands -------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def _migrate(webhooks: Webhooks, args: argparse.Namespace, out: TextIO) -> int:
|
|
94
|
+
applied = await webhooks.migrate()
|
|
95
|
+
schema = webhooks.config.db_schema
|
|
96
|
+
if applied:
|
|
97
|
+
print(f"Applied migrations {', '.join(map(str, applied))} to schema {schema!r}.", file=out)
|
|
98
|
+
else:
|
|
99
|
+
print(f"Schema {schema!r} is up to date.", file=out)
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def _worker(webhooks: Webhooks, args: argparse.Namespace, out: TextIO) -> int:
|
|
104
|
+
await run_until_signalled(webhooks.worker(), metrics_port=args.metrics_port)
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def _status(webhooks: Webhooks, args: argparse.Namespace, out: TextIO) -> int:
|
|
109
|
+
stats = await webhooks.status()
|
|
110
|
+
if args.json:
|
|
111
|
+
return _print_json(dataclasses.asdict(stats), out)
|
|
112
|
+
rows = [
|
|
113
|
+
("pending", stats.pending),
|
|
114
|
+
("due now", stats.due),
|
|
115
|
+
("in flight", stats.in_flight),
|
|
116
|
+
("delivery lag", _format_duration(stats.oldest_due_age)),
|
|
117
|
+
("failed (24h)", stats.failed_last_24h),
|
|
118
|
+
("endpoints enabled", stats.endpoints_enabled),
|
|
119
|
+
("endpoints disabled", stats.endpoints_disabled),
|
|
120
|
+
("circuit breakers open", stats.circuits_open),
|
|
121
|
+
("active workers", stats.active_workers),
|
|
122
|
+
]
|
|
123
|
+
width = max(len(name) for name, _ in rows)
|
|
124
|
+
for name, value in rows:
|
|
125
|
+
print(f"{name:<{width}} {value}", file=out)
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def _deliveries(webhooks: Webhooks, args: argparse.Namespace, out: TextIO) -> int:
|
|
130
|
+
deliveries = await webhooks.deliveries.list(
|
|
131
|
+
endpoint_id=args.endpoint,
|
|
132
|
+
event_id=args.event,
|
|
133
|
+
status=args.status,
|
|
134
|
+
since=_since(args.since),
|
|
135
|
+
limit=args.limit,
|
|
136
|
+
owner_id=_owner(args.owner),
|
|
137
|
+
)
|
|
138
|
+
if args.json:
|
|
139
|
+
return _print_json([dataclasses.asdict(d) for d in deliveries], out)
|
|
140
|
+
_print_table(
|
|
141
|
+
out,
|
|
142
|
+
["ID", "STATUS", "EVENT TYPE", "ENDPOINT", "ATTEMPTS", "LAST", "CREATED"],
|
|
143
|
+
[
|
|
144
|
+
[
|
|
145
|
+
d.id,
|
|
146
|
+
d.status.value,
|
|
147
|
+
d.event_type,
|
|
148
|
+
d.endpoint_id,
|
|
149
|
+
d.attempt_count,
|
|
150
|
+
d.last_status_code or d.last_error or "",
|
|
151
|
+
_format_time(d.created_at),
|
|
152
|
+
]
|
|
153
|
+
for d in deliveries
|
|
154
|
+
],
|
|
155
|
+
)
|
|
156
|
+
return 0
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def _delivery_show(webhooks: Webhooks, args: argparse.Namespace, out: TextIO) -> int:
|
|
160
|
+
delivery = await webhooks.deliveries.get(args.delivery_id)
|
|
161
|
+
attempts = await webhooks.deliveries.attempts(args.delivery_id)
|
|
162
|
+
if args.json:
|
|
163
|
+
data = dataclasses.asdict(delivery)
|
|
164
|
+
data["attempts"] = [dataclasses.asdict(a) for a in attempts]
|
|
165
|
+
return _print_json(data, out)
|
|
166
|
+
for name, value in dataclasses.asdict(delivery).items():
|
|
167
|
+
print(f"{name:<17} {_plain(value)}", file=out)
|
|
168
|
+
for attempt in attempts:
|
|
169
|
+
outcome = attempt.response_status or attempt.error
|
|
170
|
+
print(
|
|
171
|
+
f"\n#{attempt.attempt_number} {_format_time(attempt.created_at)} {outcome} "
|
|
172
|
+
f"{attempt.duration_ms} ms",
|
|
173
|
+
file=out,
|
|
174
|
+
)
|
|
175
|
+
if attempt.error and attempt.response_status:
|
|
176
|
+
print(f" error: {attempt.error}", file=out)
|
|
177
|
+
if attempt.response_body:
|
|
178
|
+
print(f" {attempt.response_body[:500]}", file=out)
|
|
179
|
+
return 0
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def _retry(webhooks: Webhooks, args: argparse.Namespace, out: TextIO) -> int:
|
|
183
|
+
if args.delivery_id is not None:
|
|
184
|
+
delivery = await webhooks.deliveries.retry(args.delivery_id)
|
|
185
|
+
print(f"Delivery {delivery.id} scheduled for an immediate attempt.", file=out)
|
|
186
|
+
return 0
|
|
187
|
+
count = await webhooks.deliveries.retry_all(
|
|
188
|
+
endpoint_id=args.endpoint, status=args.status, since=_since(args.since)
|
|
189
|
+
)
|
|
190
|
+
print(f"Scheduled {count} deliveries for retry.", file=out)
|
|
191
|
+
return 0
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
async def _replay(webhooks: Webhooks, args: argparse.Namespace, out: TextIO) -> int:
|
|
195
|
+
ids = await webhooks.events.replay(args.event, endpoint_id=args.endpoint)
|
|
196
|
+
print(f"Created {len(ids)} deliveries: {', '.join(map(str, ids)) or '-'}", file=out)
|
|
197
|
+
return 0
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
async def _endpoints_list(webhooks: Webhooks, args: argparse.Namespace, out: TextIO) -> int:
|
|
201
|
+
endpoints = await webhooks.endpoints.list(
|
|
202
|
+
owner_id=_owner(args.owner), enabled=args.enabled, limit=args.limit
|
|
203
|
+
)
|
|
204
|
+
if args.json:
|
|
205
|
+
return _print_json([dataclasses.asdict(e) for e in endpoints], out)
|
|
206
|
+
_print_table(
|
|
207
|
+
out,
|
|
208
|
+
["ID", "OWNER", "URL", "EVENTS", "ENABLED", "CIRCUIT", "FAILURES"],
|
|
209
|
+
[
|
|
210
|
+
[
|
|
211
|
+
e.id,
|
|
212
|
+
e.owner_id or "",
|
|
213
|
+
e.url,
|
|
214
|
+
",".join(e.event_types),
|
|
215
|
+
"yes" if e.is_enabled else f"no ({e.disabled_reason})",
|
|
216
|
+
e.circuit_state.value,
|
|
217
|
+
e.consecutive_failures,
|
|
218
|
+
]
|
|
219
|
+
for e in endpoints
|
|
220
|
+
],
|
|
221
|
+
)
|
|
222
|
+
return 0
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
async def _endpoints_enable(webhooks: Webhooks, args: argparse.Namespace, out: TextIO) -> int:
|
|
226
|
+
endpoint = await webhooks.endpoints.enable(args.endpoint_id)
|
|
227
|
+
print(f"Endpoint {endpoint.id} enabled; circuit breaker reset.", file=out)
|
|
228
|
+
return 0
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
async def _endpoints_disable(webhooks: Webhooks, args: argparse.Namespace, out: TextIO) -> int:
|
|
232
|
+
endpoint = await webhooks.endpoints.disable(args.endpoint_id, reason=args.reason)
|
|
233
|
+
print(f"Endpoint {endpoint.id} disabled.", file=out)
|
|
234
|
+
return 0
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
async def _cleanup(webhooks: Webhooks, args: argparse.Namespace, out: TextIO) -> int:
|
|
238
|
+
deleted = await webhooks.cleanup(older_than=args.older_than)
|
|
239
|
+
print(f"Deleted {deleted} events with their deliveries and attempts.", file=out)
|
|
240
|
+
return 0
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# --- argument parsing -------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _global_options(*, top_level: bool) -> argparse.ArgumentParser:
|
|
247
|
+
# Global options are accepted before and after the command. Sub-commands
|
|
248
|
+
# use SUPPRESS so that an option given before the command is not
|
|
249
|
+
# overwritten by the sub-command's default.
|
|
250
|
+
def default(value: Any) -> Any:
|
|
251
|
+
return value if top_level else argparse.SUPPRESS
|
|
252
|
+
|
|
253
|
+
options = argparse.ArgumentParser(add_help=False)
|
|
254
|
+
options.add_argument(
|
|
255
|
+
"--dsn",
|
|
256
|
+
default=default(None),
|
|
257
|
+
help="PostgreSQL URL (default: $PGDISPATCH_DSN or $DATABASE_URL)",
|
|
258
|
+
)
|
|
259
|
+
options.add_argument(
|
|
260
|
+
"--schema", default=default(None), help="database schema (default: pgdispatch)"
|
|
261
|
+
)
|
|
262
|
+
options.add_argument(
|
|
263
|
+
"--log-level", default=default("info"), choices=["debug", "info", "warning", "error"]
|
|
264
|
+
)
|
|
265
|
+
return options
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _build_parser() -> argparse.ArgumentParser: # noqa: PLR0915 - a flat list of options
|
|
269
|
+
common = _global_options(top_level=False)
|
|
270
|
+
parser = argparse.ArgumentParser(
|
|
271
|
+
prog="pgdispatch",
|
|
272
|
+
description="Reliable webhook delivery using only PostgreSQL.",
|
|
273
|
+
parents=[_global_options(top_level=True)],
|
|
274
|
+
)
|
|
275
|
+
parser.add_argument("--version", action="version", version=f"pgdispatch {__version__}")
|
|
276
|
+
commands = parser.add_subparsers(title="commands", metavar="<command>")
|
|
277
|
+
|
|
278
|
+
def command(name: str, handler: Handler, help_text: str) -> argparse.ArgumentParser:
|
|
279
|
+
sub = commands.add_parser(name, help=help_text, description=help_text, parents=[common])
|
|
280
|
+
sub.set_defaults(handler=handler)
|
|
281
|
+
return sub
|
|
282
|
+
|
|
283
|
+
command("migrate", _migrate, "Create or upgrade the database schema.")
|
|
284
|
+
|
|
285
|
+
worker = command("worker", _worker, "Deliver webhooks until interrupted.")
|
|
286
|
+
worker.add_argument("--concurrency", type=int, help="simultaneous HTTP requests")
|
|
287
|
+
worker.add_argument("--batch-size", type=int, help="deliveries claimed per query")
|
|
288
|
+
worker.add_argument("--metrics-port", type=int, help="serve Prometheus metrics on this port")
|
|
289
|
+
|
|
290
|
+
status = command("status", _status, "Show queue size, delivery lag and open circuit breakers.")
|
|
291
|
+
status.add_argument("--json", action="store_true", help="machine-readable output")
|
|
292
|
+
|
|
293
|
+
deliveries = command("deliveries", _deliveries, "List recent deliveries.")
|
|
294
|
+
deliveries.add_argument(
|
|
295
|
+
"--status", choices=["pending", "in_flight", "succeeded", "failed", "cancelled"]
|
|
296
|
+
)
|
|
297
|
+
deliveries.add_argument("--endpoint", type=uuid.UUID, help="endpoint id")
|
|
298
|
+
deliveries.add_argument("--event", type=uuid.UUID, help="event id")
|
|
299
|
+
deliveries.add_argument("--owner", help="owner id")
|
|
300
|
+
deliveries.add_argument("--since", type=_duration, help="e.g. 30m, 1h, 7d")
|
|
301
|
+
deliveries.add_argument("--limit", type=int, default=50)
|
|
302
|
+
deliveries.add_argument("--json", action="store_true", help="machine-readable output")
|
|
303
|
+
show = deliveries.add_subparsers(metavar="show").add_parser(
|
|
304
|
+
"show", help="Show a delivery with every attempt.", parents=[common]
|
|
305
|
+
)
|
|
306
|
+
show.add_argument("delivery_id", type=int)
|
|
307
|
+
show.add_argument("--json", action="store_true", help="machine-readable output")
|
|
308
|
+
show.set_defaults(handler=_delivery_show)
|
|
309
|
+
|
|
310
|
+
retry = command(
|
|
311
|
+
"retry", _retry, "Retry one delivery, or every failed delivery matching filters."
|
|
312
|
+
)
|
|
313
|
+
retry.add_argument("delivery_id", type=int, nargs="?")
|
|
314
|
+
retry.add_argument("--endpoint", type=uuid.UUID, help="endpoint id")
|
|
315
|
+
retry.add_argument("--status", default="failed", choices=["failed", "cancelled", "succeeded"])
|
|
316
|
+
retry.add_argument("--since", type=_duration, help="only deliveries created this recently")
|
|
317
|
+
|
|
318
|
+
replay = command("replay", _replay, "Deliver an event again.")
|
|
319
|
+
replay.add_argument("--event", type=uuid.UUID, required=True, help="event id")
|
|
320
|
+
replay.add_argument("--endpoint", type=uuid.UUID, help="only to this endpoint")
|
|
321
|
+
|
|
322
|
+
endpoints = commands.add_parser("endpoints", help="Manage endpoints.", parents=[common])
|
|
323
|
+
endpoint_commands = endpoints.add_subparsers(
|
|
324
|
+
title="commands", metavar="<command>", required=True
|
|
325
|
+
)
|
|
326
|
+
listing = endpoint_commands.add_parser("list", help="List endpoints.", parents=[common])
|
|
327
|
+
listing.add_argument("--owner", help="owner id")
|
|
328
|
+
listing.add_argument("--enabled", action=argparse.BooleanOptionalAction, default=None)
|
|
329
|
+
listing.add_argument("--limit", type=int, default=100)
|
|
330
|
+
listing.add_argument("--json", action="store_true", help="machine-readable output")
|
|
331
|
+
listing.set_defaults(handler=_endpoints_list)
|
|
332
|
+
enable = endpoint_commands.add_parser(
|
|
333
|
+
"enable", help="Enable an endpoint and reset its circuit breaker.", parents=[common]
|
|
334
|
+
)
|
|
335
|
+
enable.add_argument("endpoint_id", type=uuid.UUID)
|
|
336
|
+
enable.set_defaults(handler=_endpoints_enable)
|
|
337
|
+
disable = endpoint_commands.add_parser("disable", help="Disable an endpoint.", parents=[common])
|
|
338
|
+
disable.add_argument("endpoint_id", type=uuid.UUID)
|
|
339
|
+
disable.add_argument("--reason")
|
|
340
|
+
disable.set_defaults(handler=_endpoints_disable)
|
|
341
|
+
|
|
342
|
+
cleanup = command("cleanup", _cleanup, "Delete data older than the retention period.")
|
|
343
|
+
cleanup.add_argument("--older-than", type=_duration, help="default: retention_days")
|
|
344
|
+
return parser
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _config_overrides(args: argparse.Namespace) -> dict[str, Any]:
|
|
348
|
+
names = {"schema": "db_schema", "concurrency": "concurrency", "batch_size": "batch_size"}
|
|
349
|
+
return {
|
|
350
|
+
setting: getattr(args, option)
|
|
351
|
+
for option, setting in names.items()
|
|
352
|
+
if getattr(args, option, None) is not None
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _duration(value: str) -> timedelta:
|
|
357
|
+
try:
|
|
358
|
+
return parse_duration(value)
|
|
359
|
+
except ValueError as exc:
|
|
360
|
+
raise argparse.ArgumentTypeError(str(exc)) from None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _owner(owner: str | None) -> OwnerFilter:
|
|
364
|
+
# Without --owner, show every owner (None would mean "endpoints without an owner").
|
|
365
|
+
return UNSET if owner is None else owner
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _since(duration: timedelta | None) -> datetime | None:
|
|
369
|
+
return datetime.now(timezone.utc) - duration if duration else None
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# --- output -------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _print_table(out: TextIO, headers: list[str], rows: list[list[Any]]) -> None:
|
|
376
|
+
if not rows:
|
|
377
|
+
print("(none)", file=out)
|
|
378
|
+
return
|
|
379
|
+
cells = [headers, *[[_plain(value) for value in row] for row in rows]]
|
|
380
|
+
widths = [max(len(row[column]) for row in cells) for column in range(len(headers))]
|
|
381
|
+
for row in cells:
|
|
382
|
+
print(
|
|
383
|
+
" ".join(cell.ljust(width) for cell, width in zip(row, widths, strict=True)).rstrip(),
|
|
384
|
+
file=out,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _print_json(data: Any, out: TextIO) -> int:
|
|
389
|
+
json.dump(data, out, indent=2, default=_plain)
|
|
390
|
+
out.write("\n")
|
|
391
|
+
return 0
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _plain(value: Any) -> str:
|
|
395
|
+
if isinstance(value, datetime):
|
|
396
|
+
return _format_time(value)
|
|
397
|
+
if isinstance(value, timedelta):
|
|
398
|
+
return _format_duration(value)
|
|
399
|
+
if hasattr(value, "value"): # enums
|
|
400
|
+
return str(value.value)
|
|
401
|
+
return "" if value is None else str(value)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _format_time(moment: datetime) -> str:
|
|
405
|
+
return moment.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _format_duration(duration: timedelta) -> str:
|
|
409
|
+
seconds = int(duration.total_seconds())
|
|
410
|
+
if seconds < 60:
|
|
411
|
+
return f"{seconds}s"
|
|
412
|
+
minutes, seconds = divmod(seconds, 60)
|
|
413
|
+
if minutes < 60:
|
|
414
|
+
return f"{minutes}m{seconds:02d}s"
|
|
415
|
+
hours, minutes = divmod(minutes, 60)
|
|
416
|
+
return f"{hours}h{minutes:02d}m"
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
if __name__ == "__main__": # pragma: no cover
|
|
420
|
+
sys.exit(main())
|