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 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
@@ -0,0 +1,10 @@
1
+ """``python -m pflog``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from pflog.cli.commands import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
pflog/cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """The ``pflog`` command."""
pflog/cli/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """``python -m pflog.cli``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from pflog.cli.commands import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
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())