pflog 2.0.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.
- pflog/__init__.py +48 -0
- pflog/__main__.py +10 -0
- pflog/cli/__init__.py +1 -0
- pflog/cli/__main__.py +10 -0
- pflog/cli/commands.py +388 -0
- pflog/cli/manual.py +242 -0
- pflog/models/__init__.py +22 -0
- pflog/models/span.py +87 -0
- pflog/report/__init__.py +25 -0
- pflog/report/jsonline.py +74 -0
- pflog/report/log.py +48 -0
- pflog/report/protocol.py +42 -0
- pflog/report/tee.py +34 -0
- pflog/span/__init__.py +16 -0
- pflog/span/timed.py +213 -0
- pflog-2.0.0.dist-info/METADATA +171 -0
- pflog-2.0.0.dist-info/RECORD +21 -0
- pflog-2.0.0.dist-info/WHEEL +5 -0
- pflog-2.0.0.dist-info/entry_points.txt +2 -0
- pflog-2.0.0.dist-info/licenses/LICENSE +21 -0
- pflog-2.0.0.dist-info/top_level.txt +1 -0
pflog/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""pflog: time a call and keep the record.
|
|
2
|
+
|
|
3
|
+
A span around any function or block, built on hostlib's records, reported as one
|
|
4
|
+
line of JSON.
|
|
5
|
+
|
|
6
|
+
Key components:
|
|
7
|
+
- timed, span_open: time a function or a block
|
|
8
|
+
- Span: the record
|
|
9
|
+
- JsonReporter, FileReporter, LogReporter, TeeReporter, NullReporter: where it goes
|
|
10
|
+
- HostFacts, JobResult, JobSpec: hostlib's records, re-exported
|
|
11
|
+
|
|
12
|
+
Typical usage:
|
|
13
|
+
from pflog import timed
|
|
14
|
+
|
|
15
|
+
@timed("shexec")
|
|
16
|
+
def main(options, inputdir, outputdir) -> int:
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
Dependencies:
|
|
20
|
+
- hostlib
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from pflog.models import HostFacts, JobLogs, JobResult, JobSpec, JobStarted, Span
|
|
26
|
+
from pflog.report import FileReporter, JsonReporter, LogReporter, NullReporter, SpanReporter, TeeReporter
|
|
27
|
+
from pflog.span import SpanBuilder, span_open, timed
|
|
28
|
+
|
|
29
|
+
__version__: str = "2.0.0"
|
|
30
|
+
|
|
31
|
+
__all__: list[str] = [
|
|
32
|
+
"FileReporter",
|
|
33
|
+
"HostFacts",
|
|
34
|
+
"JobLogs",
|
|
35
|
+
"JobResult",
|
|
36
|
+
"JobSpec",
|
|
37
|
+
"JobStarted",
|
|
38
|
+
"JsonReporter",
|
|
39
|
+
"LogReporter",
|
|
40
|
+
"NullReporter",
|
|
41
|
+
"Span",
|
|
42
|
+
"SpanBuilder",
|
|
43
|
+
"SpanReporter",
|
|
44
|
+
"TeeReporter",
|
|
45
|
+
"__version__",
|
|
46
|
+
"span_open",
|
|
47
|
+
"timed",
|
|
48
|
+
]
|
pflog/__main__.py
ADDED
pflog/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The ``pflog`` command."""
|
pflog/cli/__main__.py
ADDED
pflog/cli/commands.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""The ``pflog`` command: ``read`` span and job lines back as a table, ``demo`` a timed call.
|
|
2
|
+
|
|
3
|
+
``--help`` is argparse's quick reference; ``--man`` prints the full manual, ``--synopsis``
|
|
4
|
+
its first page.
|
|
5
|
+
|
|
6
|
+
Key components:
|
|
7
|
+
- BANNER_*, SYNOPSIS_CLI, DESCRIPTION, ARGS, RECORD_HELP, REPORTERS_HELP, EXAMPLES: the manual's text
|
|
8
|
+
- synopsis, manual_build: the manual
|
|
9
|
+
- parser_build: the parser, with ``read`` and ``demo`` subcommands
|
|
10
|
+
- records_read, table_render: reading lines back
|
|
11
|
+
- read_command, demo_command: the subcommands
|
|
12
|
+
- main: the entry point, honouring an explicit ``argv``
|
|
13
|
+
|
|
14
|
+
Typical usage:
|
|
15
|
+
pflog read /out/spans.jsonl
|
|
16
|
+
pflog read --failed /out/*.jsonl
|
|
17
|
+
pflog demo --seconds 0.5
|
|
18
|
+
|
|
19
|
+
Dependencies:
|
|
20
|
+
- pflog.models, pflog.span, pflog.report, pflog.cli.manual
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import sys
|
|
28
|
+
import time
|
|
29
|
+
from collections.abc import Iterable, Sequence
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, TextIO
|
|
33
|
+
|
|
34
|
+
from pflog import __version__
|
|
35
|
+
from pflog.cli.manual import Manual, manual_print
|
|
36
|
+
from pflog.report.jsonline import FileReporter, JsonReporter
|
|
37
|
+
from pflog.report.protocol import SpanReporter
|
|
38
|
+
from pflog.report.tee import TeeReporter
|
|
39
|
+
from pflog.span.timed import span_open
|
|
40
|
+
|
|
41
|
+
EXIT_USAGE: int = 2
|
|
42
|
+
EXIT_FAILED: int = 1
|
|
43
|
+
|
|
44
|
+
BANNER_TITLE: str = "pflog: time a call, keep the record"
|
|
45
|
+
BANNER_ART: str = r"""
|
|
46
|
+
__ _
|
|
47
|
+
/ _| |
|
|
48
|
+
_ __ | |_| | ___ __ _
|
|
49
|
+
| '_ \| _| |/ _ \ / _` |
|
|
50
|
+
| |_) | | | | (_) | (_| |
|
|
51
|
+
| .__/|_| |_|\___/ \__, |
|
|
52
|
+
| | __/ |
|
|
53
|
+
|_| |___/
|
|
54
|
+
"""
|
|
55
|
+
BANNER_TEXT: str = """
|
|
56
|
+
pflog: time a call, keep the record
|
|
57
|
+
|
|
58
|
+
-- version {version} --
|
|
59
|
+
|
|
60
|
+
A span around any function or block: when it began, when it ended, how long it
|
|
61
|
+
took, on which host, and whether it succeeded. The span is a record of the same
|
|
62
|
+
shape as hostlib's record of a process, written as one line of JSON, so a log
|
|
63
|
+
collector parses it and ``pflog read`` reads it back.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
SYNOPSIS_CLI: str = """
|
|
67
|
+
pflog read [--failed] [--json] [<file> ...]
|
|
68
|
+
|
|
69
|
+
pflog demo [--seconds <n>] [--fail] [--file <path>]
|
|
70
|
+
|
|
71
|
+
pflog [--man] [--synopsis] [--version]"""
|
|
72
|
+
|
|
73
|
+
DESCRIPTION: str = """
|
|
74
|
+
DESCRIPTION
|
|
75
|
+
|
|
76
|
+
pflog is first a Python module. A function wearing ``@timed("name")``, or a
|
|
77
|
+
block inside ``with span_open("name") as span:``, produces a Span when it
|
|
78
|
+
ends: name, status, returncode, errors, host, startedAt, endedAt, duration.
|
|
79
|
+
The call's own result is untouched: its return value comes back as it was,
|
|
80
|
+
and an exception it raises keeps propagating, after the span has recorded
|
|
81
|
+
it as a failure. The outcome is read as a shell reads a program: an integer
|
|
82
|
+
returned, or a SystemExit raised, is an exit status with 0 meaning success;
|
|
83
|
+
a boolean is the status itself; anything else, returned without error, is
|
|
84
|
+
success.
|
|
85
|
+
|
|
86
|
+
Each span goes to a reporter. The default writes one line of JSON to the
|
|
87
|
+
process's standard error, so a program's own standard output stays its own.
|
|
88
|
+
Other reporters append to a file, write through Python's logging, or fan out
|
|
89
|
+
to several.
|
|
90
|
+
|
|
91
|
+
The ``pflog`` command reads those lines back. ``read`` takes files of JSON
|
|
92
|
+
lines, span lines and hostlib job lines alike, and prints one row each:
|
|
93
|
+
when it started, how long it took, how it ended, and its name or command.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
ARGS: str = """
|
|
97
|
+
ARGUMENTS
|
|
98
|
+
|
|
99
|
+
read [--failed] [--json] [<file> ...]
|
|
100
|
+
Read span and job lines from the files, or from standard input when no
|
|
101
|
+
file is given, and print a table. A span line is recognised by its ``name``,
|
|
102
|
+
a job line by its ``argv``; other lines are skipped. --failed keeps only the
|
|
103
|
+
rows whose status is false. --json prints the kept lines as they were
|
|
104
|
+
instead of the table.
|
|
105
|
+
|
|
106
|
+
demo [--seconds <n>] [--fail] [--file <path>]
|
|
107
|
+
Time a call that sleeps for <n> seconds (0.2 by default) and report the
|
|
108
|
+
span on standard error. --fail makes the call raise, to show a failed
|
|
109
|
+
span; the command then exits 1. --file also appends the span to <path>,
|
|
110
|
+
which ``pflog read`` can then read back.
|
|
111
|
+
|
|
112
|
+
--man, --synopsis, --version
|
|
113
|
+
The full manual, its first page, the version.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
RECORD_HELP: str = """
|
|
117
|
+
THE RECORD
|
|
118
|
+
|
|
119
|
+
A Span has these fields, in JSON as a flat object with ``host`` nested:
|
|
120
|
+
|
|
121
|
+
name the span's name; the function's name unless given
|
|
122
|
+
status true when the call returned without error and, if it
|
|
123
|
+
returned an exit status, that status was 0
|
|
124
|
+
returncode the integer returned or the SystemExit code; null otherwise
|
|
125
|
+
errors why a failed span failed, in words; empty on success
|
|
126
|
+
host hostname, user, pid, platform, cwd, timestamp
|
|
127
|
+
startedAt ISO 8601 with the local offset
|
|
128
|
+
endedAt ISO 8601 with the local offset
|
|
129
|
+
duration seconds, from a monotonic clock
|
|
130
|
+
|
|
131
|
+
status, errors, host, startedAt, endedAt and duration are the same fields,
|
|
132
|
+
in the same formats, as hostlib's JobResult carries for a process. A log that
|
|
133
|
+
holds both kinds of line is read by one query.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
REPORTERS_HELP: str = """
|
|
137
|
+
REPORTERS
|
|
138
|
+
|
|
139
|
+
JsonReporter(stream=None) one line of JSON per span; stderr by default
|
|
140
|
+
FileReporter(path) one line of JSON per span, appended to the file
|
|
141
|
+
LogReporter(logger, level) ``name: 4.213 s, ok`` through Python's logging
|
|
142
|
+
TeeReporter(a, b, ...) every span to each reporter given
|
|
143
|
+
NullReporter() nothing
|
|
144
|
+
|
|
145
|
+
Pass one as ``report=`` to ``timed`` or ``span_open``. Any object with a
|
|
146
|
+
``report(span)`` method is a reporter.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
EXAMPLES: str = """
|
|
150
|
+
EXAMPLES
|
|
151
|
+
|
|
152
|
+
Time a ChRIS plugin's main, the span on stderr where CUBE keeps it:
|
|
153
|
+
|
|
154
|
+
@chris_plugin(...)
|
|
155
|
+
@timed("shexec")
|
|
156
|
+
def main(options: Namespace, inputdir: Path, outputdir: Path) -> int:
|
|
157
|
+
...
|
|
158
|
+
|
|
159
|
+
Time a block and keep the spans in a file beside the results:
|
|
160
|
+
|
|
161
|
+
with span_open("recon-all", report=FileReporter(outputdir / "spans.jsonl")):
|
|
162
|
+
job_run(JobSpec(["recon-all", "-all", "-s", subject]))
|
|
163
|
+
|
|
164
|
+
Read a day's spans back, failures only:
|
|
165
|
+
|
|
166
|
+
pflog read --failed /out/*/spans.jsonl
|
|
167
|
+
|
|
168
|
+
See a span without writing any code:
|
|
169
|
+
|
|
170
|
+
pflog demo --seconds 1 --file /tmp/spans.jsonl
|
|
171
|
+
pflog read /tmp/spans.jsonl
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def synopsis(shortOnly: bool = False) -> str:
|
|
176
|
+
"""Assemble the manual text.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
shortOnly: NAME, SYNOPSIS and a brief example only.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
The manual text, without the banner.
|
|
183
|
+
"""
|
|
184
|
+
shortSynopsis: str = (
|
|
185
|
+
"""
|
|
186
|
+
NAME
|
|
187
|
+
|
|
188
|
+
pflog - time a call, keep the record
|
|
189
|
+
|
|
190
|
+
SYNOPSIS
|
|
191
|
+
"""
|
|
192
|
+
+ SYNOPSIS_CLI
|
|
193
|
+
+ """
|
|
194
|
+
|
|
195
|
+
BRIEF EXAMPLE
|
|
196
|
+
|
|
197
|
+
pflog demo --file /tmp/spans.jsonl && pflog read /tmp/spans.jsonl
|
|
198
|
+
"""
|
|
199
|
+
)
|
|
200
|
+
if shortOnly:
|
|
201
|
+
return shortSynopsis
|
|
202
|
+
return shortSynopsis + DESCRIPTION + ARGS + RECORD_HELP + REPORTERS_HELP + EXAMPLES
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def manual_build() -> Manual:
|
|
206
|
+
"""The manual, ready to render."""
|
|
207
|
+
return Manual(
|
|
208
|
+
command="pflog",
|
|
209
|
+
art=BANNER_ART,
|
|
210
|
+
title=BANNER_TITLE,
|
|
211
|
+
text=BANNER_TEXT,
|
|
212
|
+
version=__version__,
|
|
213
|
+
synopsisShort=synopsis(shortOnly=True),
|
|
214
|
+
synopsisFull=synopsis(shortOnly=False),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def parser_build() -> argparse.ArgumentParser:
|
|
219
|
+
"""The command's parser: top-level manual flags, then ``read`` and ``demo``."""
|
|
220
|
+
parser: argparse.ArgumentParser = argparse.ArgumentParser(
|
|
221
|
+
prog="pflog",
|
|
222
|
+
description="Time a call, keep the record. Full manual: pflog --man",
|
|
223
|
+
)
|
|
224
|
+
parser.add_argument("--version", action="version", version=f"pflog {__version__}")
|
|
225
|
+
parser.add_argument("--man", action="store_true", help="print the full manual")
|
|
226
|
+
parser.add_argument("--synopsis", action="store_true", help="print the manual's first page")
|
|
227
|
+
commands: argparse._SubParsersAction[argparse.ArgumentParser] = parser.add_subparsers(dest="command")
|
|
228
|
+
|
|
229
|
+
read: argparse.ArgumentParser = commands.add_parser(
|
|
230
|
+
"read", help="read span and job lines back as a table"
|
|
231
|
+
)
|
|
232
|
+
read.add_argument("--failed", action="store_true", help="only rows whose status is false")
|
|
233
|
+
read.add_argument("--json", action="store_true", help="print the kept lines instead of the table")
|
|
234
|
+
read.add_argument("files", nargs="*", help="files of JSON lines; standard input when none")
|
|
235
|
+
|
|
236
|
+
demo: argparse.ArgumentParser = commands.add_parser("demo", help="time a call and report the span")
|
|
237
|
+
demo.add_argument("--seconds", type=float, default=0.2, help="how long the call sleeps")
|
|
238
|
+
demo.add_argument("--fail", action="store_true", help="make the call raise")
|
|
239
|
+
demo.add_argument("--file", default=None, help="also append the span to this file")
|
|
240
|
+
return parser
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@dataclass
|
|
244
|
+
class Row:
|
|
245
|
+
"""One line of the table ``read`` prints.
|
|
246
|
+
|
|
247
|
+
Attributes:
|
|
248
|
+
startedAt: when the span or job started.
|
|
249
|
+
duration: seconds.
|
|
250
|
+
status: how it ended.
|
|
251
|
+
label: the span's name or the job's command.
|
|
252
|
+
line: the JSON line it came from.
|
|
253
|
+
|
|
254
|
+
Example:
|
|
255
|
+
>>> Row("t", 1.0, True, "x", "{}").status
|
|
256
|
+
True
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
startedAt: str
|
|
260
|
+
duration: float
|
|
261
|
+
status: bool
|
|
262
|
+
label: str
|
|
263
|
+
line: str
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def row_fromRecord(record: dict[str, Any], line: str) -> Row | None:
|
|
267
|
+
"""A table row from one parsed line, or ``None`` when the line is neither a span nor a job."""
|
|
268
|
+
if "name" in record:
|
|
269
|
+
label: str = str(record["name"])
|
|
270
|
+
elif "argv" in record:
|
|
271
|
+
label = " ".join(str(part) for part in record["argv"])
|
|
272
|
+
else:
|
|
273
|
+
return None
|
|
274
|
+
return Row(
|
|
275
|
+
startedAt=str(record.get("startedAt", "")),
|
|
276
|
+
duration=float(record.get("duration", 0.0) or 0.0),
|
|
277
|
+
status=bool(record.get("status", False)),
|
|
278
|
+
label=label,
|
|
279
|
+
line=line,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def records_read(lines: Iterable[str]) -> list[Row]:
|
|
284
|
+
"""Parse JSON lines into rows, skipping what is not JSON or not a record."""
|
|
285
|
+
rows: list[Row] = []
|
|
286
|
+
for raw in lines:
|
|
287
|
+
line: str = raw.rstrip("\n")
|
|
288
|
+
if not line.strip():
|
|
289
|
+
continue
|
|
290
|
+
try:
|
|
291
|
+
record: Any = json.loads(line)
|
|
292
|
+
except json.JSONDecodeError:
|
|
293
|
+
continue
|
|
294
|
+
if not isinstance(record, dict):
|
|
295
|
+
continue
|
|
296
|
+
row: Row | None = row_fromRecord(record, line)
|
|
297
|
+
if row is not None:
|
|
298
|
+
rows.append(row)
|
|
299
|
+
return rows
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def table_render(rows: list[Row]) -> str:
|
|
303
|
+
"""The rows as aligned text, a header first."""
|
|
304
|
+
header: str = f"{'STARTED':<25} {'DURATION':>10} {'STATUS':<6} NAME"
|
|
305
|
+
body: list[str] = [
|
|
306
|
+
f"{row.startedAt:<25} {row.duration:>10.3f} {'ok' if row.status else 'failed':<6} {row.label}"
|
|
307
|
+
for row in rows
|
|
308
|
+
]
|
|
309
|
+
return "\n".join([header, *body]) + "\n"
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def lines_open(files: list[str]) -> list[str]:
|
|
313
|
+
"""Every line of the files, or of standard input when there are none.
|
|
314
|
+
|
|
315
|
+
Raises:
|
|
316
|
+
OSError: a file cannot be read.
|
|
317
|
+
"""
|
|
318
|
+
if not files:
|
|
319
|
+
return sys.stdin.readlines()
|
|
320
|
+
lines: list[str] = []
|
|
321
|
+
for name in files:
|
|
322
|
+
lines.extend(Path(name).read_text().splitlines(keepends=True))
|
|
323
|
+
return lines
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def read_command(args: argparse.Namespace, parser: argparse.ArgumentParser, out: TextIO | None = None) -> int:
|
|
327
|
+
"""``pflog read``.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
0; 2 when a file cannot be read.
|
|
331
|
+
"""
|
|
332
|
+
stream: TextIO = out if out is not None else sys.stdout
|
|
333
|
+
try:
|
|
334
|
+
rows: list[Row] = records_read(lines_open(args.files))
|
|
335
|
+
except OSError as err:
|
|
336
|
+
parser.error(str(err))
|
|
337
|
+
if args.failed:
|
|
338
|
+
rows = [row for row in rows if not row.status]
|
|
339
|
+
if args.json:
|
|
340
|
+
stream.write("".join(row.line + "\n" for row in rows))
|
|
341
|
+
else:
|
|
342
|
+
stream.write(table_render(rows))
|
|
343
|
+
return 0
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def demo_command(args: argparse.Namespace) -> int:
|
|
347
|
+
"""``pflog demo``: time a sleep, reporting on stderr and optionally to a file.
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
0; 1 when ``--fail`` made the call raise.
|
|
351
|
+
"""
|
|
352
|
+
reporter: SpanReporter = JsonReporter()
|
|
353
|
+
if args.file:
|
|
354
|
+
reporter = TeeReporter(reporter, FileReporter(args.file))
|
|
355
|
+
try:
|
|
356
|
+
with span_open("demo", reporter):
|
|
357
|
+
time.sleep(max(args.seconds, 0.0))
|
|
358
|
+
if args.fail:
|
|
359
|
+
raise RuntimeError("the demo was asked to fail")
|
|
360
|
+
except RuntimeError:
|
|
361
|
+
return EXIT_FAILED
|
|
362
|
+
return 0
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
366
|
+
"""Run the command.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
argv: arguments without the program name; ``None`` reads ``sys.argv``.
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
The exit status.
|
|
373
|
+
"""
|
|
374
|
+
parser: argparse.ArgumentParser = parser_build()
|
|
375
|
+
args: argparse.Namespace = parser.parse_args(argv)
|
|
376
|
+
if args.man or args.synopsis:
|
|
377
|
+
manual_print(manual_build(), shortOnly=not args.man)
|
|
378
|
+
return 0
|
|
379
|
+
if args.command == "read":
|
|
380
|
+
return read_command(args, parser)
|
|
381
|
+
if args.command == "demo":
|
|
382
|
+
return demo_command(args)
|
|
383
|
+
parser.print_help()
|
|
384
|
+
return EXIT_USAGE
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
if __name__ == "__main__":
|
|
388
|
+
sys.exit(main())
|