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 +43 -0
- pftag/__main__.py +284 -0
- pftag/functions.py +257 -0
- pftag/models.py +164 -0
- pftag/names.py +42 -0
- pftag/pftag.py +441 -0
- pftag-3.0.0.dist-info/METADATA +103 -0
- pftag-3.0.0.dist-info/RECORD +12 -0
- pftag-3.0.0.dist-info/WHEEL +5 -0
- pftag-3.0.0.dist-info/entry_points.txt +2 -0
- pftag-3.0.0.dist-info/licenses/LICENSE +21 -0
- pftag-3.0.0.dist-info/top_level.txt +1 -0
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)
|