omdnotificationforwarder 4.7__tar.gz → 4.8__tar.gz

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.
Files changed (76) hide show
  1. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/.gitignore +0 -1
  2. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/PKG-INFO +1 -1
  3. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/bin/notificationforwarder +49 -3
  4. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/pyproject.toml +1 -1
  5. omdnotificationforwarder-4.8/src/notificationforwarder/spool_dump.py +259 -0
  6. omdnotificationforwarder-4.8/tests/test_spool_dump.py +669 -0
  7. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/README.md +0 -0
  8. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/src/notificationforwarder/baseclass.py +0 -0
  9. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/src/notificationforwarder/component_loader.py +0 -0
  10. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/src/notificationforwarder/email/formatter.py +0 -0
  11. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/src/notificationforwarder/email/forwarder.py +0 -0
  12. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/src/notificationforwarder/example/formatter.py +0 -0
  13. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/src/notificationforwarder/example/forwarder.py +0 -0
  14. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/src/notificationforwarder/json/__init__.py +0 -0
  15. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/src/notificationforwarder/json/logger.py +0 -0
  16. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/src/notificationforwarder/naemonlog/reporter.py +0 -0
  17. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/src/notificationforwarder/nostr/__init__.py +0 -0
  18. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/src/notificationforwarder/nostr/formatter.py +0 -0
  19. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/src/notificationforwarder/nostr/forwarder.py +0 -0
  20. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/src/notificationforwarder/oauth2webhook/__init__.py +0 -0
  21. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/src/notificationforwarder/oauth2webhook/forwarder.py +0 -0
  22. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/src/notificationforwarder/rabbitmq/formatter.py +0 -0
  23. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/src/notificationforwarder/rabbitmq/forwarder.py +0 -0
  24. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/src/notificationforwarder/runtime_config.py +0 -0
  25. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/src/notificationforwarder/runtime_flow.py +0 -0
  26. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/src/notificationforwarder/spool.py +0 -0
  27. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/src/notificationforwarder/syslog/formatter.py +0 -0
  28. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/src/notificationforwarder/syslog/forwarder.py +0 -0
  29. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/src/notificationforwarder/telegram/forwarder.py +0 -0
  30. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/src/notificationforwarder/text/__init__.py +0 -0
  31. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/src/notificationforwarder/text/logger.py +0 -0
  32. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/src/notificationforwarder/util.py +0 -0
  33. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/src/notificationforwarder/webhook/forwarder.py +0 -0
  34. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/TESTS.md +0 -0
  35. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/pythonpath/lib/python/notificationforwarder/split1/forwarder.py +0 -0
  36. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/pythonpath/lib/python/notificationforwarder/split2/formatter.py +0 -0
  37. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/pythonpath/lib/python/notificationforwarder/split2/forwarder.py +0 -0
  38. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/pythonpath/lib/python/notificationforwarder/split3/formatter.py +0 -0
  39. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/pythonpath/lib/python/notificationforwarder/split3/forwarder.py +0 -0
  40. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/pythonpath/local/lib/python/notificationforwarder/alertmanager_servicenow/formatter.py +0 -0
  41. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/pythonpath/local/lib/python/notificationforwarder/bayern/formatter.py +0 -0
  42. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/pythonpath/local/lib/python/notificationforwarder/datadup/formatter.py +0 -0
  43. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/pythonpath/local/lib/python/notificationforwarder/datapost/formatter.py +0 -0
  44. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/pythonpath/local/lib/python/notificationforwarder/discard/formatter.py +0 -0
  45. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/pythonpath/local/lib/python/notificationforwarder/split1/formatter.py +0 -0
  46. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/pythonpath/local/lib/python/notificationforwarder/split2/forwarder.py +0 -0
  47. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/pythonpath/local/lib/python/notificationforwarder/split3/formatter.py +0 -0
  48. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/pythonpath/local/lib/python/notificationforwarder/split3/forwarder.py +0 -0
  49. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/pythonpath/local/lib/python/notificationforwarder/split4/formatter.py +0 -0
  50. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/pythonpath/local/lib/python/notificationforwarder/split4/forwarder.py +0 -0
  51. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/pythonpath/local/lib/python/notificationforwarder/ticketsystem/forwarder.py +0 -0
  52. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/pythonpath/local/lib/python/notificationforwarder/ticketsystem/reporter.py +0 -0
  53. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/pythonpath/local/lib/python/notificationforwarder/vong/formatter.py +0 -0
  54. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/test_alertmanager.py +0 -0
  55. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/test_builtin_plugins_focus.py +0 -0
  56. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/test_classes.py +0 -0
  57. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/test_contracts_formatters.py +0 -0
  58. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/test_contracts_forwarders.py +0 -0
  59. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/test_contracts_reporters.py +0 -0
  60. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/test_delivery_resilience.py +0 -0
  61. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/test_discard.py +0 -0
  62. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/test_formatter.py +0 -0
  63. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/test_integration_cli.py +0 -0
  64. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/test_lint_gate.py +0 -0
  65. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/test_loading_resolution.py +0 -0
  66. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/test_logger.py +0 -0
  67. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/test_loggers.py +0 -0
  68. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/test_nostr.py +0 -0
  69. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/test_oauth2webhook.py +0 -0
  70. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/test_orchestration_flow.py +0 -0
  71. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/test_package.py +0 -0
  72. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/test_paths.py +0 -0
  73. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/test_reporter.py +0 -0
  74. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/test_resilience_spool.py +0 -0
  75. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/test_runtime_foundation.py +0 -0
  76. {omdnotificationforwarder-4.7 → omdnotificationforwarder-4.8}/tests/test_webhook.py +0 -0
@@ -6,4 +6,3 @@ sdist
6
6
  examples/
7
7
  .opencode/
8
8
  notificationforwarder/tests/var/
9
- /openspec/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: omdnotificationforwarder
3
- Version: 4.7
3
+ Version: 4.8
4
4
  Summary: A framework for notification scripts for OMD
5
5
  Project-URL: Homepage, https://github.com/lausser/noteventificationforhandlerwarder
6
6
  Project-URL: Bug Tracker, https://github.com/lausser/noteventificationforhandlerwarder/issues
@@ -8,6 +8,7 @@ import sys
8
8
  #import logging
9
9
  from notificationforwarder.util import setup_logging
10
10
  from notificationforwarder import baseclass
11
+ from notificationforwarder import spool_dump
11
12
  import traceback
12
13
 
13
14
 
@@ -32,8 +33,9 @@ if __name__ == '__main__':
32
33
  This can be any backend you want, provided
33
34
  there is a Python module forwarder.py in the folder
34
35
  notificationforwarder/<forwarder> somewhere in your PYTHONPATH.
35
- Examples are victorops, syslog, servicenow, rabbitmq,...''',
36
- default="syslog")
36
+ Examples are victorops, syslog, servicenow, rabbitmq,...
37
+ Defaults to syslog when not in dump mode. Required with --dump-spool.''',
38
+ default=argparse.SUPPRESS)
37
39
  parser.add_argument('--forwardertag', '--receivertag', action='store',
38
40
  dest="forwardertag",
39
41
  help='A distinctive string which is added to the logfile',
@@ -77,13 +79,57 @@ Example for an HTTP-based reporter:
77
79
  dest="logger",
78
80
  help='Logger type: text (default) or json, or custom logger module',
79
81
  default='text')
82
+ parser.add_argument('--dump-spool', action='store_true',
83
+ dest="dump_spool",
84
+ help='''Dump the spooled events of the selected forwarder instance and exit.
85
+ The spool is read only: nothing is forwarded, retried, or modified.
86
+ --forwarder is required in this mode; --forwardertag selects the tagged instance.''',
87
+ default=False)
88
+ parser.add_argument('--order', action='store',
89
+ dest="order",
90
+ choices=("oldest", "newest"),
91
+ help='Spool dump order: oldest (default) or newest',
92
+ default="oldest")
93
+ parser.add_argument('--limit', action='store',
94
+ dest="limit",
95
+ help='Spool dump: maximum number of records to display (default: all)',
96
+ default=None)
97
+ parser.add_argument('--dump-format', action='store',
98
+ dest="dump_format",
99
+ choices=("text", "json"),
100
+ help='Spool dump output format: text (default) or json',
101
+ default="text")
80
102
  parser.add_argument('--version', action='version',
81
- version=f'%(prog)s 4.7')
103
+ version=f'%(prog)s 4.8')
82
104
 
83
105
  args = parser.parse_args()
106
+ if not hasattr(args, 'forwarder'):
107
+ # The default applies only outside dump mode; dump mode needs the
108
+ # instance named explicitly because the spool is one file per
109
+ # (forwarder, tag) pair.
110
+ if args.dump_spool:
111
+ parser.error("--dump-spool requires --forwarder (or --receiver)")
112
+ args.forwarder = "syslog"
84
113
  if not hasattr(args, 'formatter'):
85
114
  args.formatter = args.forwarder
86
115
 
116
+ if args.dump_spool:
117
+ try:
118
+ limit = int(args.limit) if args.limit is not None else None
119
+ except ValueError:
120
+ # one usage line naming the value, like the core's validation
121
+ print("error: --limit must be an integer, got '{}'".format(args.limit),
122
+ file=sys.stderr)
123
+ sys.exit(2)
124
+ status = spool_dump.dump_spool(
125
+ args.forwarder,
126
+ args.forwardertag,
127
+ args.order,
128
+ limit,
129
+ args.dump_format,
130
+ )
131
+ sys.exit(status)
132
+
87
133
  try:
88
134
  forwarder = baseclass.new(args.forwarder, args.forwardertag, args.formatter, args.verbose, args.debug, args.forwarderopt, args.reporter, args.reporteropt, args.logger)
89
135
  except Exception as a:
@@ -21,7 +21,7 @@ packages = ["src/notificationforwarder"]
21
21
 
22
22
  [project]
23
23
  name = "omdnotificationforwarder"
24
- version = "4.7"
24
+ version = "4.8"
25
25
  authors = [
26
26
  { name="Gerhard Lausser", email="lausser@yahoo.com" },
27
27
  ]
@@ -0,0 +1,259 @@
1
+ """Read-only inspection of a notificationforwarder spool database.
2
+
3
+ This is a pure read path. It resolves the spool database and table from the
4
+ forwarder name and an optional tag, opens them read-only, and prints the spooled
5
+ records. It never loads a forwarder, formatter, or reporter module, never takes
6
+ the flush lock, and never modifies the spool.
7
+
8
+ The failure reason for a spooled event is not stored in the database, so this
9
+ module does not derive or display one; the spool log remains the source,
10
+ correlated by the record number.
11
+ """
12
+ import json
13
+ import os
14
+ import re
15
+ import sqlite3
16
+ import sys
17
+ from urllib.parse import quote
18
+
19
+ # Cap on how long the dump waits on a concurrent writer before failing.
20
+ LOCK_TIMEOUT_MS = 1000
21
+ # How many characters of a corrupt payload to show so the operator has a breadcrumb.
22
+ RAW_PREFIX_LENGTH = 120
23
+ # Upper bound for --limit; a larger value is a usage error.
24
+ LIMIT_MAX = 9223372036854775807
25
+
26
+
27
+ def _forwarder_name(forwarder, tag):
28
+ return forwarder + ("_" + tag if tag else "")
29
+
30
+
31
+ def _use_utf8(stream):
32
+ # stdout and stderr carry valid UTF-8 independent of the process locale
33
+ # (e.g. LC_ALL=C with locale coercion disabled).
34
+ try:
35
+ stream.reconfigure(encoding="utf-8")
36
+ except (AttributeError, ValueError, OSError):
37
+ pass
38
+
39
+
40
+ def _spool_paths(forwarder, tag):
41
+ forwarder_name = _forwarder_name(forwarder, tag)
42
+ omd_root = os.environ.get("OMD_ROOT", "")
43
+ # Absolute so the read-only URI connect below is valid and the reported
44
+ # path is unambiguous.
45
+ db_file = os.path.abspath(omd_root + "/var/tmp/notificationforwarder_"
46
+ + forwarder_name + "_notifications.db")
47
+ table = "events_" + forwarder_name
48
+ return db_file, table
49
+
50
+
51
+ def _decode_payload(text):
52
+ """Return (decodable, value, prefix).
53
+
54
+ On success, (True, decoded_value, None). On failure, (False, None, prefix)
55
+ where prefix is the first RAW_PREFIX_LENGTH characters of the stored text.
56
+ """
57
+ try:
58
+ return True, json.loads(text), None
59
+ except (ValueError, TypeError):
60
+ if isinstance(text, str):
61
+ prefix = text[:RAW_PREFIX_LENGTH]
62
+ else:
63
+ prefix = str(text)[:RAW_PREFIX_LENGTH]
64
+ return False, None, prefix
65
+
66
+
67
+ def _spool_time(value):
68
+ # Show the stored timestamp verbatim (UTC YYYY-MM-DD HH:MM:SS). No conversion.
69
+ return str(value)
70
+
71
+
72
+ def _header(err, order, db_file, table, total, shown):
73
+ # Header to stderr before the first record so an interrupted dump still
74
+ # shows the counts, and the spool time is labeled UTC.
75
+ err.write("spool: {}\n".format(db_file))
76
+ err.write("table: {}\n".format(table))
77
+ err.write("order: {} (spool time UTC)\n".format(order))
78
+ err.write("total: {}\n".format(total))
79
+ err.write("shown: {}\n".format(shown))
80
+
81
+
82
+ def _write_empty(err, out, fmt, db_file, table, order):
83
+ _header(err, order, db_file, table, 0, 0)
84
+ err.write("spool empty\n")
85
+ if fmt == "json":
86
+ out.write("[]\n")
87
+
88
+
89
+ # U+0085 (NEL), U+2028, U+2029 are line breaks that json.dumps with
90
+ # ensure_ascii=False leaves raw; escape them so one record stays one
91
+ # physical line and stdout stays valid UTF-8 independent of the locale.
92
+ _LINE_BREAKS = re.compile("[\u0085\u2028\u2029]")
93
+
94
+
95
+ def _escape_line_breaks(text):
96
+ return _LINE_BREAKS.sub(lambda match: "\\u{:04x}".format(ord(match.group(0))), text)
97
+
98
+
99
+ # Lone surrogates (U+D800-U+DFFF) cannot be encoded in UTF-8; JSON carries
100
+ # them only as \u escapes, so escape exactly those code points and leave
101
+ # every other character (including all other non-ASCII) as itself.
102
+ _SURROGATES = re.compile("[\ud800-\udfff]")
103
+
104
+
105
+ def _escape_surrogates(text):
106
+ return _SURROGATES.sub(lambda match: "\\u{:04x}".format(ord(match.group(0))), text)
107
+
108
+
109
+ def _encode_utf8(value):
110
+ """Return a UTF-8-encodable JSON string for value.
111
+
112
+ ensure_ascii=False keeps non-ASCII readable; line breaks and lone
113
+ surrogates are escaped so the result stays one line and valid UTF-8.
114
+ Returns None when the value cannot be JSON encoded at all.
115
+ """
116
+ try:
117
+ text = _escape_line_breaks(json.dumps(value, ensure_ascii=False))
118
+ except (TypeError, ValueError):
119
+ return None
120
+ text = _escape_surrogates(text)
121
+ try:
122
+ text.encode("utf-8")
123
+ except UnicodeEncodeError:
124
+ return None
125
+ return text
126
+
127
+
128
+ def _render(rows, total, order, fmt, db_file, table, out, err):
129
+ shown = len(rows)
130
+ _header(err, order, db_file, table, total, shown)
131
+
132
+ if fmt == "json":
133
+ elements = []
134
+ for row_id, ts, payload_text in rows:
135
+ decodable, value, prefix = _decode_payload(payload_text)
136
+ record = {"id": row_id, "spooled_at": _spool_time(ts)}
137
+ if decodable:
138
+ encoded = _encode_utf8(value)
139
+ if encoded is None:
140
+ record["payload"] = None
141
+ record["undecodable"] = True
142
+ record["raw_prefix"] = prefix
143
+ else:
144
+ record["payload"] = value
145
+ else:
146
+ record["payload"] = None
147
+ record["undecodable"] = True
148
+ record["raw_prefix"] = prefix
149
+ elements.append(record)
150
+ # ensure_ascii=False keeps non-ASCII readable; line breaks and lone
151
+ # surrogates are escaped so the array stays valid UTF-8.
152
+ body = _escape_surrogates(_escape_line_breaks(json.dumps(elements, ensure_ascii=False)))
153
+ try:
154
+ body.encode("utf-8")
155
+ except UnicodeEncodeError:
156
+ # defensive; escaping the surrogates should already be enough
157
+ body = _escape_line_breaks(json.dumps(elements, ensure_ascii=True))
158
+ out.write(body)
159
+ out.write("\n")
160
+ else:
161
+ for row_id, ts, payload_text in rows:
162
+ decodable, value, prefix = _decode_payload(payload_text)
163
+ body = None
164
+ if decodable:
165
+ body = _encode_utf8(value)
166
+ if body is None:
167
+ # Undecodable or unencodable: marker plus raw prefix, escaped
168
+ # so the record stays one physical line and valid UTF-8.
169
+ prefix_body = _encode_utf8(prefix)
170
+ if prefix_body is None:
171
+ prefix_body = json.dumps(prefix, ensure_ascii=True)
172
+ body = "[undecodable payload] {}".format(prefix_body)
173
+ out.write("#{} {} {}\n".format(row_id, _spool_time(ts), body))
174
+ return 0
175
+
176
+
177
+ def dump_spool(forwarder, tag=None, order="oldest", limit=None, fmt="text", out=None, err=None):
178
+ """Dump the spooled events of one forwarder instance. Returns a process exit code."""
179
+ out = sys.stdout if out is None else out
180
+ err = sys.stderr if err is None else err
181
+ _use_utf8(out)
182
+ _use_utf8(err)
183
+
184
+ # Argument validation: a violation is a usage error (non-zero, no record data).
185
+ if not forwarder:
186
+ err.write("error: --dump-spool requires --forwarder (or --receiver); "
187
+ "the syslog default is not applied\n")
188
+ return 2
189
+ if order not in ("oldest", "newest"):
190
+ err.write("error: --order must be 'oldest' or 'newest'\n")
191
+ return 2
192
+ if fmt not in ("text", "json"):
193
+ err.write("error: --dump-format must be 'text' or 'json'\n")
194
+ return 2
195
+ if limit is not None and (not isinstance(limit, int) or isinstance(limit, bool)
196
+ or limit < 1 or limit > LIMIT_MAX):
197
+ err.write("error: --limit must be an integer between 1 and {} (got {})\n".format(
198
+ LIMIT_MAX, limit))
199
+ return 2
200
+
201
+ omd_root = os.environ.get("OMD_ROOT", "")
202
+ if not omd_root:
203
+ err.write("error: OMD_ROOT is not set; cannot resolve the spool database\n")
204
+ return 1
205
+
206
+ db_file, table = _spool_paths(forwarder, tag)
207
+
208
+ # A missing spool file is a healthy empty spool, not an error.
209
+ if not os.path.exists(db_file):
210
+ _write_empty(err, out, fmt, db_file, table, order)
211
+ return 0
212
+
213
+ direction = "ASC" if order == "oldest" else "DESC"
214
+
215
+ # Percent-encode the path: an unescaped '#' or '?' in a file or
216
+ # directory name would become a URI fragment/query and drop mode=ro.
217
+ try:
218
+ connection = sqlite3.connect("file:{}?mode=ro".format(quote(db_file, safe="/")), uri=True,
219
+ timeout=LOCK_TIMEOUT_MS / 1000.0, isolation_level=None)
220
+ except sqlite3.Error as exc:
221
+ err.write("error: could not open spool database {}: {}\n".format(db_file, exc))
222
+ return 1
223
+
224
+ total = 0
225
+ rows = []
226
+ try:
227
+ # One deferred read transaction holds the snapshot across COUNT and
228
+ # SELECT so total and the listed rows describe the same data.
229
+ connection.execute("BEGIN")
230
+ total = connection.execute("SELECT COUNT(*) FROM " + table).fetchone()[0]
231
+ sql = "SELECT id, timestamp, payload FROM {} ORDER BY timestamp {}, id {}".format(
232
+ table, direction, direction)
233
+ params = []
234
+ if limit is not None:
235
+ sql += " LIMIT ?"
236
+ params.append(limit)
237
+ rows = connection.execute(sql, params).fetchall()
238
+ connection.execute("COMMIT")
239
+ except sqlite3.OperationalError as exc:
240
+ message = str(exc).lower()
241
+ if "no such table" in message:
242
+ err.write("error: spool database {} exists but table {} is missing\n".format(db_file, table))
243
+ elif "locked" in message:
244
+ err.write("error: spool database {} stayed locked beyond {} ms: {}\n".format(
245
+ db_file, LOCK_TIMEOUT_MS, exc))
246
+ else:
247
+ err.write("error: could not read spool database {}: {}\n".format(db_file, exc))
248
+ return 1
249
+ except sqlite3.Error as exc:
250
+ err.write("error: could not read spool database {}: {}\n".format(db_file, exc))
251
+ return 1
252
+ finally:
253
+ connection.close()
254
+
255
+ if total == 0:
256
+ _write_empty(err, out, fmt, db_file, table, order)
257
+ return 0
258
+
259
+ return _render(rows, total, order, fmt, db_file, table, out, err)