pftag 3.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.
pftag/__init__.py ADDED
@@ -0,0 +1,43 @@
1
+ """pftag: a string template processor.
2
+
3
+ Text with ``%{tags}`` in it is resolved at run time: each tag names a value from the
4
+ core tags (``%{timestamp}``, ``%{hostname}``, ...) or from dictionaries the caller
5
+ registers, and may be piped through functions, ``%{file|rmext|upper}``. Both a library
6
+ and a command.
7
+
8
+ Key components:
9
+ - Pftag: the engine, with ``process`` for text and ``argv_process`` for commands
10
+ - TagOptions, TagResult, ArgvResult: options in, results out
11
+ - FunctionRegistry: the functions, extensible
12
+ - __main__: the ``pftag`` command
13
+
14
+ Typical usage:
15
+ from pftag import Pftag, TagOptions
16
+ Pftag(TagOptions(lookups={"run": {"subject": "sub-01"}}))("%{subject}-%{date}.log").result
17
+
18
+ Dependencies:
19
+ - faker, optionally, for the dcmname function (``pip install 'pftag[names]'``)
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ __version__: str = "3.0.0"
25
+
26
+ from pftag.functions import FunctionError, FunctionRegistry
27
+ from pftag.models import ArgvResult, FunctionCall, TagOptions, TagResult, TagToken
28
+ from pftag.pftag import CORE_TAGS, Pftag, TagSyntaxError, coreTag_value
29
+
30
+ __all__ = [
31
+ "CORE_TAGS",
32
+ "ArgvResult",
33
+ "FunctionCall",
34
+ "FunctionError",
35
+ "FunctionRegistry",
36
+ "Pftag",
37
+ "TagOptions",
38
+ "TagResult",
39
+ "TagSyntaxError",
40
+ "TagToken",
41
+ "__version__",
42
+ "coreTag_value",
43
+ ]
pftag/__main__.py ADDED
@@ -0,0 +1,284 @@
1
+ """The ``pftag`` command line and its manual.
2
+
3
+ Key components:
4
+ - parser_build: the arguments
5
+ - synopsis: the manual text
6
+ - main: the entry point, honouring an explicit ``argv``
7
+
8
+ Typical usage:
9
+ pftag --tag "run-%{timestamp|replace(:,-)}-on-%{hostname}.log"
10
+ pftag --lookupDictAdd '{"s": {"user": "chris"}}' --tag "%{user|md5(8)}"
11
+ pftag --man
12
+
13
+ Dependencies:
14
+ - pftag.pftag, pftag.models
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import logging
22
+ import sys
23
+ from collections.abc import Sequence
24
+
25
+ from pftag import __version__
26
+ from pftag.models import TagOptions, TagResult
27
+ from pftag.pftag import Pftag
28
+
29
+ BANNER: str = r"""
30
+ __ _
31
+ / _|| |
32
+ _ __ | |_ | |_ __ _ __ _
33
+ | '_ \| _|| __| / _` | / _` |
34
+ | |_) | | | |_ | (_| || (_| |
35
+ | .__/|_| \__| \__,_| \__, |
36
+ | | __/ |
37
+ |_| |___/
38
+
39
+ string template processor
40
+
41
+ Text with %{tags} in it, resolved when the text is used. A tag
42
+ names a value and may pipe it through functions:
43
+
44
+ %{inputWorkingFile|rmext}.jpg
45
+ %{timestamp|replace(:,-)|md5(6)}
46
+
47
+ -- version {version} --
48
+ """
49
+
50
+ CLI_SELF: str = """
51
+ --tag <template> \\
52
+ [--lookupDictAdd <json>] \\
53
+ [--strict] \\
54
+ [--json] \\
55
+ [--verbosity <level>] \\
56
+ [--man] [--synopsis] [--version]"""
57
+
58
+ ARGS_SYNOPSIS: str = """
59
+ ARGUMENTS
60
+
61
+ --tag <template>
62
+ The template to process: any text, with tags written %{...}.
63
+
64
+ [--lookupDictAdd <json>]
65
+ Named lookup dictionaries to add, as a JSON object
66
+
67
+ '{"secrets": {"CUBEuser": "chris", "CUBEpassword": "chris1234"}}'
68
+
69
+ or a list of such objects. Each adds a group of tags, here
70
+ %{CUBEuser} and %{CUBEpassword}, that can be piped through functions
71
+ like any tag. A tag name is an identifier: letters, digits and
72
+ underscores, not starting with a digit. A name may be defined by one
73
+ dictionary only, unless it is always written qualified: %{secrets.CUBEuser}.
74
+
75
+ [--strict]
76
+ An unknown tag, or a bare %name of a known name outside braces (a 1.x
77
+ template), fails the run (exit 1) instead of being left as written.
78
+
79
+ [--json]
80
+ Print the whole result as JSON: the text, the tags found, the unknown
81
+ and 1.x-style tags, the functions applied, any errors.
82
+
83
+ [--verbosity <level>]
84
+ 0 (the default) prints only the result; 1 adds warnings about 1.x-style
85
+ tags; higher levels log more.
86
+
87
+ [--man] [--synopsis] [--version]
88
+ The full manual, the brief one, the version.
89
+ """
90
+
91
+ GRAMMAR: str = """
92
+ GRAMMAR
93
+
94
+ A tag is %{ followed by a head, optional functions, and }:
95
+
96
+ %{name}
97
+ %{name|function}
98
+ %{name|function(arg, arg)|function}
99
+ %{"literal text"|function}
100
+
101
+ Nothing outside %{...} is a tag: printf "%s", date +%Y%m%d and %20 in
102
+ a URL pass through untouched. Inside the braces % is ordinary, so
103
+ %{timestamp|strftime(%Y%m%d)} works. \\%{ is a literal "%{".
104
+
105
+ The head is a tag name, qualified with its dictionary's name when
106
+ two dictionaries define it (%{secrets.user}), or a quoted string that
107
+ starts a chain from literal text. Functions follow in pipe order,
108
+ left to right, each receiving the previous result. Arguments are
109
+ comma-separated inside parentheses, bare or quoted; a bare argument
110
+ is trimmed and may not contain , ) | or }; inside quotes, \\" \\' and
111
+ \\\\ escape. A function with no arguments may be written with or
112
+ without ().
113
+
114
+ Text after the closing } is kept: %{file|rmext}.txt keeps ".txt".
115
+ An unknown name is left as written and reported (see --strict). A
116
+ function that fails leaves its tag as written and fails the run.
117
+ Values are substituted once; a value containing %{...} is not
118
+ processed again.
119
+ """
120
+
121
+ TAGS_AND_FUNCTIONS: str = """
122
+ TAGS
123
+
124
+ %{timestamp} the current local time, ISO 8601 with offset
125
+ %{date} today's date, YYYY-MM-DD
126
+ %{hostname} the host name (in a container, the container id)
127
+ %{platform} platform.system(), such as Linux
128
+ %{user} the login name, or the numeric uid without a passwd entry
129
+ %{pid} the process id
130
+
131
+ plus every tag in a registered lookup dictionary. %{core.timestamp}
132
+ reaches a core tag that a dictionary has shadowed.
133
+
134
+ FUNCTIONS
135
+
136
+ rmext drop the last extension: brain.nii.gz -> brain.nii
137
+ ext the last extension without its dot: gz
138
+ basename the last path component
139
+ dirname the path without its last component
140
+ replace(a, b) replace every a with b, literally
141
+ re(pattern, repl) replace every regular-expression match
142
+ upper, lower change case
143
+ strip[(chars)] drop surrounding whitespace, or the given chars
144
+ pad(width[, char]) left-pad with char (default 0): 7 -> 0007
145
+ slice(stop) / slice(start, stop)
146
+ characters by index, Python style
147
+ md5[(n)], sha256[(n)] hex digest, cut to n characters
148
+ strftime(format) format an ISO 8601 timestamp: %{timestamp|strftime(%Y%m%d)}
149
+ mask(pattern) per position, * keeps the character, anything
150
+ else replaces it; positions past the pattern
151
+ are kept
152
+ dcmname[(seed)] a LAST^FIRST pseudonym, stable for a seed (the
153
+ value itself when no seed is given). Needs
154
+ pip install 'pftag[names]'. A seeded hash
155
+ wearing a name, not anonymization.
156
+
157
+ Carrying protected text safely, hashed rather than written:
158
+
159
+ %{"Subject12345"|sha256(8)}
160
+ """
161
+
162
+ EXAMPLES: str = """
163
+ EXAMPLES
164
+
165
+ pftag --tag "run-%{timestamp}-on-%{hostname}.log"
166
+ pftag --tag "run-%{timestamp|replace(:,-)}-on-%{hostname}.log"
167
+ pftag --tag "run-%{timestamp|strftime(%Y%m%d-%H%M)}.log"
168
+ pftag --lookupDictAdd '{"s": {"user": "chris"}}' --tag "user %{user}, hash %{user|md5(8)}"
169
+ pftag --tag "100% done, %{date}"
170
+ pftag --tag "%{nope}" --strict # exit 1
171
+ """
172
+
173
+ VERBOSITY_LOG_LEVELS: dict[int, int] = {0: logging.ERROR, 1: logging.WARNING, 2: logging.INFO}
174
+
175
+
176
+ def synopsis(shortOnly: bool = False) -> str:
177
+ """The manual text.
178
+
179
+ Args:
180
+ shortOnly: NAME and SYNOPSIS only.
181
+
182
+ Returns:
183
+ The text, without the banner.
184
+ """
185
+ short: str = (
186
+ """
187
+ NAME
188
+
189
+ pftag - resolve %{tags} in a template, with optional functions
190
+
191
+ SYNOPSIS
192
+
193
+ pftag \\"""
194
+ + CLI_SELF
195
+ + "\n"
196
+ )
197
+ if shortOnly:
198
+ return short
199
+ description: str = """
200
+ DESCRIPTION
201
+
202
+ pftag is both a command and a library for processing templates: text
203
+ whose %{tags} are resolved when the text is used, each optionally
204
+ piped through functions. The core tags describe the machine and the
205
+ moment; a program adds its own tags as named lookup dictionaries.
206
+ The pf* family uses it wherever a user writes a template on the
207
+ command line, such as pfdo_run's --exec string and pflog's
208
+ telemetry URLs. See docs/tags.adoc for the full reference.
209
+ """
210
+ return short + description + ARGS_SYNOPSIS + GRAMMAR + TAGS_AND_FUNCTIONS + EXAMPLES
211
+
212
+
213
+ def parser_build() -> argparse.ArgumentParser:
214
+ """The command's parser.
215
+
216
+ Returns:
217
+ The parser; ``--help`` is the quick reference and points at ``--man``.
218
+ """
219
+ parser: argparse.ArgumentParser = argparse.ArgumentParser(
220
+ prog="pftag",
221
+ description="Resolve %%{tags} in a template, optionally through piped functions. Manual: pftag --man",
222
+ formatter_class=argparse.RawDescriptionHelpFormatter,
223
+ )
224
+ parser.add_argument("--tag", dest="tag", default="", help="the template to process")
225
+ parser.add_argument("--lookupDictAdd", dest="lookupDictAdd", default="", help="lookup dictionaries, JSON")
226
+ parser.add_argument("--strict", dest="strict", action="store_true", help="unknown or 1.x-style tags fail")
227
+ parser.add_argument("--json", dest="json", action="store_true", help="print the whole result as JSON")
228
+ parser.add_argument("--verbosity", dest="verbosity", type=int, default=0, help="0 prints only the result")
229
+ parser.add_argument("--man", dest="man", action="store_true", help="show the full manual and exit")
230
+ parser.add_argument("--synopsis", dest="synopsis", action="store_true", help="show the brief manual")
231
+ parser.add_argument("--version", action="version", version=f"pftag {__version__}")
232
+ return parser
233
+
234
+
235
+ def options_fromArgs(args: argparse.Namespace) -> TagOptions:
236
+ """Turn parsed arguments into options."""
237
+ return TagOptions(strict=args.strict, tag=args.tag, json=args.json, verbosity=args.verbosity)
238
+
239
+
240
+ def manual_print(shortOnly: bool) -> None:
241
+ """Print the banner and the manual."""
242
+ print(BANNER.replace("{version}", __version__))
243
+ print(synopsis(shortOnly))
244
+
245
+
246
+ def main(argv: Sequence[str] | None = None) -> int:
247
+ """Run the command.
248
+
249
+ Args:
250
+ argv: arguments without the program name; ``None`` reads ``sys.argv``.
251
+
252
+ Returns:
253
+ 0 when the template was processed; 1 when ``--strict`` met an unknown or
254
+ 1.x-style tag, a tag did not parse, or a function failed; 2 on bad arguments.
255
+ """
256
+ parser: argparse.ArgumentParser = parser_build()
257
+ args: argparse.Namespace = parser.parse_args(argv)
258
+ if args.man or args.synopsis:
259
+ manual_print(shortOnly=not args.man)
260
+ return 0
261
+ if not args.tag:
262
+ parser.error("--tag is required")
263
+ options: TagOptions = options_fromArgs(args)
264
+ logging.basicConfig(
265
+ level=VERBOSITY_LOG_LEVELS.get(options.verbosity, logging.DEBUG), format="%(message)s"
266
+ )
267
+ try:
268
+ tagger: Pftag = Pftag(options)
269
+ if args.lookupDictAdd:
270
+ tagger.lookups_addFromJson(args.lookupDictAdd)
271
+ except ValueError as err:
272
+ parser.error(str(err))
273
+ result: TagResult = tagger.process(options.tag)
274
+ if options.json:
275
+ print(json.dumps(result.toDict(), indent=4, sort_keys=True))
276
+ else:
277
+ print(result.result)
278
+ for error in result.errors:
279
+ print(error, file=sys.stderr)
280
+ return 0 if result.status else 1
281
+
282
+
283
+ if __name__ == "__main__":
284
+ sys.exit(main())
pftag/functions.py ADDED
@@ -0,0 +1,257 @@
1
+ """The functions a tag's value can be piped through.
2
+
3
+ A function takes the value so far and the arguments written in the template, and
4
+ returns new text. Functions chain left to right: ``%{timestamp|replace(:,-)|md5(6)}``
5
+ replaces the colons in the timestamp and hashes the result to six characters. The
6
+ registry maps names to functions and is extensible; the pseudonym generator lives in
7
+ ``pftag.names`` and is registered only when its dependency is installed.
8
+
9
+ Key components:
10
+ - TagFunction: the signature every function has
11
+ - FunctionRegistry: name to function, with the built-ins preloaded
12
+ - the built-ins: path parts, replacement, case, padding, slicing, hashing,
13
+ masking, time formatting
14
+
15
+ Typical usage:
16
+ registry = FunctionRegistry()
17
+ registry.add("shout", lambda value, args: value.upper() + "!")
18
+
19
+ Dependencies:
20
+ - standard library only
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import re
27
+ from collections.abc import Callable
28
+ from datetime import datetime
29
+ from pathlib import PurePosixPath
30
+
31
+ TagFunction = Callable[[str, list[str]], str]
32
+
33
+ MASK_KEEP: str = "*"
34
+
35
+
36
+ class FunctionError(ValueError):
37
+ """A function was given arguments it cannot work with, or does not exist."""
38
+
39
+
40
+ def args_count(name: str, args: list[str], atLeast: int, atMost: int | None = None) -> None:
41
+ """Fail clearly on a wrong number of arguments.
42
+
43
+ Args:
44
+ name: the function, for the message.
45
+ args: the arguments given.
46
+ atLeast: the minimum.
47
+ atMost: the maximum; None for no limit.
48
+
49
+ Raises:
50
+ FunctionError: when the count is outside the range.
51
+ """
52
+ if len(args) < atLeast or (atMost is not None and len(args) > atMost):
53
+ expected: str = str(atLeast) if atMost == atLeast else f"{atLeast} to {atMost or 'any'}"
54
+ raise FunctionError(f"{name} takes {expected} argument(s), got {len(args)}: {args}")
55
+
56
+
57
+ def int_arg(name: str, text: str) -> int:
58
+ """An integer argument, or a clear failure."""
59
+ try:
60
+ return int(text)
61
+ except ValueError as err:
62
+ raise FunctionError(f"{name}: {text!r} is not an integer") from err
63
+
64
+
65
+ def rmext(value: str, args: list[str]) -> str:
66
+ """Drop the last extension: ``brain.nii.gz`` becomes ``brain.nii``."""
67
+ args_count("rmext", args, 0, 0)
68
+ suffix: str = PurePosixPath(value).suffix
69
+ return value[: -len(suffix)] if suffix else value
70
+
71
+
72
+ def ext(value: str, args: list[str]) -> str:
73
+ """The last extension without its dot: ``brain.nii.gz`` gives ``gz``."""
74
+ args_count("ext", args, 0, 0)
75
+ return PurePosixPath(value).suffix.lstrip(".")
76
+
77
+
78
+ def basename(value: str, args: list[str]) -> str:
79
+ """The last path component."""
80
+ args_count("basename", args, 0, 0)
81
+ return PurePosixPath(value).name
82
+
83
+
84
+ def dirname(value: str, args: list[str]) -> str:
85
+ """The path without its last component."""
86
+ args_count("dirname", args, 0, 0)
87
+ return str(PurePosixPath(value).parent)
88
+
89
+
90
+ def replace(value: str, args: list[str]) -> str:
91
+ """Replace every occurrence of the first argument with the second, literally."""
92
+ args_count("replace", args, 2, 2)
93
+ return value.replace(args[0], args[1])
94
+
95
+
96
+ def re_sub(value: str, args: list[str]) -> str:
97
+ """Replace every match of a regular expression with the second argument."""
98
+ args_count("re", args, 2, 2)
99
+ try:
100
+ return re.sub(args[0], args[1], value)
101
+ except re.error as err:
102
+ raise FunctionError(f"re: bad pattern {args[0]!r}: {err}") from err
103
+
104
+
105
+ def upper(value: str, args: list[str]) -> str:
106
+ """Upper case."""
107
+ args_count("upper", args, 0, 0)
108
+ return value.upper()
109
+
110
+
111
+ def lower(value: str, args: list[str]) -> str:
112
+ """Lower case."""
113
+ args_count("lower", args, 0, 0)
114
+ return value.lower()
115
+
116
+
117
+ def strip(value: str, args: list[str]) -> str:
118
+ """Drop leading and trailing whitespace, or the characters in the argument."""
119
+ args_count("strip", args, 0, 1)
120
+ return value.strip(args[0]) if args else value.strip()
121
+
122
+
123
+ def pad(value: str, args: list[str]) -> str:
124
+ """Left-pad to a width with a character (default ``0``): ``pad(4)`` turns ``7`` into ``0007``."""
125
+ args_count("pad", args, 1, 2)
126
+ width: int = int_arg("pad", args[0])
127
+ fill: str = args[1] if len(args) > 1 else "0"
128
+ if len(fill) != 1:
129
+ raise FunctionError(f"pad: fill must be one character, not {fill!r}")
130
+ return value.rjust(width, fill)
131
+
132
+
133
+ def slice_(value: str, args: list[str]) -> str:
134
+ """Characters from the first index up to the second, Python style; one argument means a prefix."""
135
+ args_count("slice", args, 1, 2)
136
+ if len(args) == 1:
137
+ return value[: int_arg("slice", args[0])]
138
+ return value[int_arg("slice", args[0]) : int_arg("slice", args[1])]
139
+
140
+
141
+ def md5(value: str, args: list[str]) -> str:
142
+ """The MD5 hex digest, cut to the first argument's length when given."""
143
+ args_count("md5", args, 0, 1)
144
+ digest: str = hashlib.md5(value.encode("utf-8")).hexdigest() # a tag, not a secret
145
+ return digest[: int_arg("md5", args[0])] if args else digest
146
+
147
+
148
+ def sha256(value: str, args: list[str]) -> str:
149
+ """The SHA-256 hex digest, cut to the first argument's length when given."""
150
+ args_count("sha256", args, 0, 1)
151
+ digest: str = hashlib.sha256(value.encode("utf-8")).hexdigest()
152
+ return digest[: int_arg("sha256", args[0])] if args else digest
153
+
154
+
155
+ def strftime(value: str, args: list[str]) -> str:
156
+ """Format an ISO 8601 timestamp with a ``strftime`` pattern: ``strftime(%Y%m%d)``."""
157
+ args_count("strftime", args, 1, 1)
158
+ try:
159
+ moment: datetime = datetime.fromisoformat(value)
160
+ except ValueError as err:
161
+ raise FunctionError(f"strftime: {value!r} is not an ISO 8601 timestamp") from err
162
+ return moment.strftime(args[0])
163
+
164
+
165
+ def mask(value: str, args: list[str]) -> str:
166
+ """Mask character by character.
167
+
168
+ For each position of the pattern, ``*`` keeps the value's character and any other
169
+ character replaces it; positions past the pattern are kept, so the result is never
170
+ shorter than the value.
171
+ """
172
+ args_count("mask", args, 1, 1)
173
+ pattern: str = args[0]
174
+ out: list[str] = []
175
+ for index, char in enumerate(value):
176
+ if index < len(pattern) and pattern[index] != MASK_KEEP:
177
+ out.append(pattern[index])
178
+ else:
179
+ out.append(char)
180
+ return "".join(out)
181
+
182
+
183
+ class FunctionRegistry:
184
+ """Name to function, with the built-ins preloaded.
185
+
186
+ Attributes:
187
+ functions: the registered functions by name.
188
+
189
+ Example:
190
+ >>> FunctionRegistry().apply("upper", "abc", [])
191
+ 'ABC'
192
+ """
193
+
194
+ def __init__(self) -> None:
195
+ self.functions: dict[str, TagFunction] = {
196
+ "rmext": rmext,
197
+ "ext": ext,
198
+ "basename": basename,
199
+ "dirname": dirname,
200
+ "replace": replace,
201
+ "re": re_sub,
202
+ "upper": upper,
203
+ "lower": lower,
204
+ "strip": strip,
205
+ "pad": pad,
206
+ "slice": slice_,
207
+ "md5": md5,
208
+ "sha256": sha256,
209
+ "strftime": strftime,
210
+ "mask": mask,
211
+ }
212
+ self.names_register()
213
+
214
+ def names_register(self) -> None:
215
+ """Register the pseudonym functions when their dependency is installed."""
216
+ try:
217
+ from pftag.names import dcmname
218
+ except ImportError:
219
+ return
220
+ self.functions["dcmname"] = dcmname
221
+
222
+ def add(self, name: str, function: TagFunction) -> None:
223
+ """Register a function, replacing any of the same name.
224
+
225
+ Args:
226
+ name: the name used in templates.
227
+ function: ``(value, args) -> str``.
228
+ """
229
+ self.functions[name] = function
230
+
231
+ def has(self, name: str) -> bool:
232
+ """Whether a function is registered."""
233
+ return name in self.functions
234
+
235
+ def names(self) -> list[str]:
236
+ """The registered names, sorted."""
237
+ return sorted(self.functions)
238
+
239
+ def apply(self, name: str, value: str, args: list[str]) -> str:
240
+ """Run a function.
241
+
242
+ Args:
243
+ name: a registered name.
244
+ value: the text so far.
245
+ args: the arguments written in the template.
246
+
247
+ Returns:
248
+ The function's result.
249
+
250
+ Raises:
251
+ FunctionError: if the name is unknown or the arguments are unusable.
252
+ """
253
+ if name not in self.functions:
254
+ if name == "dcmname":
255
+ raise FunctionError("dcmname needs the names extra: pip install 'pftag[names]'")
256
+ raise FunctionError(f"unknown function {name!r}; known: {', '.join(self.names())}")
257
+ return self.functions[name](value, args)