shexec 2.2.0__tar.gz → 2.3.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shexec
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Run a shell-type command over every file of an input tree: a ChRIS DS plugin on pfdo_run
5
5
  Home-page: https://github.com/FNNDSC/pl-shexec
6
6
  Author: FNNDSC
@@ -142,7 +142,7 @@ pl-shexec is a library as well as a plugin, in the same way pfdo_run is a librar
142
142
  ))
143
143
  main = PLUGIN.main
144
144
 
145
- ``PluginSpec`` holds everything a plugin changes: its name and version, which become its command, its span and its ``--version``; its title, category and resource limits for ChRIS; the defaults of its flags, above all ``--exec``, which ChRIS shows as the defaults and the user can override; and its words on the manual page, over the sections every plugin shares. ``plugin_build`` returns the parser with those defaults applied, the manual, and ``main`` already wrapped in pflog's ``timed`` and ``chris_plugin``, ready to be the console script. The engine, the flags, the records and the exit status are not in the spec, because they are the same for every plugin on the stack, and a fix to them reaches every plugin on its next build.
145
+ ``PluginSpec`` holds everything a plugin changes: its name and version, which become its command, its span and its ``--version``; its title, category and resource limits for ChRIS; the defaults of its flags, above all ``--exec``, which ChRIS shows as the defaults and the user can override; and its words on the manual page, over the sections every plugin shares. A plugin may also decline engine flags it should not offer: ``omit=("threads", "threadsSplit")`` removes them from its parser, its ChRIS description and its manual, and fixes their values, as pl-freesurfer does because two ``recon-all`` runs in one container exhaust its memory. ``plugin_build`` returns the parser with those defaults applied, the manual, and ``main`` already wrapped in pflog's ``timed`` and ``chris_plugin``, ready to be the console script. The engine, the flags, the records and the exit status are not in the spec, because they are the same for every plugin on the stack, and a fix to them reaches every plugin on its next build.
146
146
 
147
147
  The rest of pl-freesurfer is a ``pyproject.toml`` naming ``chris_plugin`` and ``shexec`` as its dependencies, the first so that ``chris_plugin_info`` finds the plugin, a ``setup.cfg``, a Dockerfile that starts from the FreeSurfer image and installs the package, tests, and docs. pl-shexec itself is the first plugin built this way; its ``__main__`` is the same call with an empty default command. The pf* stack's ``docs/plugin.adoc`` has the template.
148
148
 
@@ -113,7 +113,7 @@ pl-shexec is a library as well as a plugin, in the same way pfdo_run is a librar
113
113
  ))
114
114
  main = PLUGIN.main
115
115
 
116
- ``PluginSpec`` holds everything a plugin changes: its name and version, which become its command, its span and its ``--version``; its title, category and resource limits for ChRIS; the defaults of its flags, above all ``--exec``, which ChRIS shows as the defaults and the user can override; and its words on the manual page, over the sections every plugin shares. ``plugin_build`` returns the parser with those defaults applied, the manual, and ``main`` already wrapped in pflog's ``timed`` and ``chris_plugin``, ready to be the console script. The engine, the flags, the records and the exit status are not in the spec, because they are the same for every plugin on the stack, and a fix to them reaches every plugin on its next build.
116
+ ``PluginSpec`` holds everything a plugin changes: its name and version, which become its command, its span and its ``--version``; its title, category and resource limits for ChRIS; the defaults of its flags, above all ``--exec``, which ChRIS shows as the defaults and the user can override; and its words on the manual page, over the sections every plugin shares. A plugin may also decline engine flags it should not offer: ``omit=("threads", "threadsSplit")`` removes them from its parser, its ChRIS description and its manual, and fixes their values, as pl-freesurfer does because two ``recon-all`` runs in one container exhaust its memory. ``plugin_build`` returns the parser with those defaults applied, the manual, and ``main`` already wrapped in pflog's ``timed`` and ``chris_plugin``, ready to be the console script. The engine, the flags, the records and the exit status are not in the spec, because they are the same for every plugin on the stack, and a fix to them reaches every plugin on its next build.
117
117
 
118
118
  The rest of pl-freesurfer is a ``pyproject.toml`` naming ``chris_plugin`` and ``shexec`` as its dependencies, the first so that ``chris_plugin_info`` finds the plugin, a ``setup.cfg``, a Dockerfile that starts from the FreeSurfer image and installs the package, tests, and docs. pl-shexec itself is the first plugin built this way; its ``__main__`` is the same call with an empty default command. The pf* stack's ``docs/plugin.adoc`` has the template.
119
119
 
@@ -14,7 +14,7 @@ Dependencies:
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
- __version__: str = "2.2.0"
17
+ __version__: str = "2.3.0"
18
18
 
19
19
  from shexec.models import (
20
20
  DirNode,
@@ -81,6 +81,10 @@ class PluginSpec:
81
81
  minCpu: the CPU ChRIS reserves, in millicores, as ``1000m``.
82
82
  minGpu: GPUs required; 0 for none.
83
83
  defaults: flag defaults, ``exec`` above all; a derived plugin's usual command.
84
+ omit: engine flags this plugin does not offer, by destination name, as
85
+ ``("threads", "threadsSplit")``. Each is removed from the parser, so
86
+ neither ChRIS's description nor the manual shows it, and its value is
87
+ pinned to the engine's default, or to ``defaults`` when that names it.
84
88
  bannerArt: the ASCII art on the manual's first page.
85
89
  bannerTitle: the one-line title under it.
86
90
  bannerText: the banner prose; ``{version}`` in it is replaced.
@@ -101,6 +105,7 @@ class PluginSpec:
101
105
  minCpu: str = "1000m"
102
106
  minGpu: int = 0
103
107
  defaults: dict[str, Any] = field(default_factory=dict)
108
+ omit: tuple[str, ...] = ()
104
109
  bannerArt: str = DEFAULT_BANNER_ART
105
110
  bannerTitle: str = ""
106
111
  bannerText: str = ""
@@ -12,6 +12,7 @@ the first call of it; pl-freesurfer is the second, with FreeSurfer in the image
12
12
  Key components:
13
13
  - plugin_build: the factory
14
14
  - synopsis_build: the manual text for a spec
15
+ - parser_omit, manualText_omit: take flags a plugin does not offer out of its parser and manual
15
16
  - PLUGIN_SIGNATURE: the signature chris_plugin inspects
16
17
 
17
18
  Typical usage:
@@ -30,7 +31,8 @@ from __future__ import annotations
30
31
 
31
32
  import argparse
32
33
  import inspect
33
- from collections.abc import Callable
34
+ import re
35
+ from collections.abc import Callable, Sequence
34
36
  from pathlib import Path
35
37
 
36
38
  from chris_plugin import chris_plugin
@@ -51,6 +53,47 @@ PLUGIN_SIGNATURE: inspect.Signature = inspect.Signature(
51
53
  )
52
54
 
53
55
 
56
+ ENTRY_LINE: re.Pattern[str] = re.compile(r"^ {8}(?:\[--(?P<flag>[A-Za-z][\w-]*)|[\[<])")
57
+
58
+
59
+ def manualText_omit(text: str, flags: Sequence[str]) -> str:
60
+ r"""The shared manual text without the entries for the given flags.
61
+
62
+ The shared sections write each flag in one of two forms, both indented eight
63
+ spaces: a synopsis line, ``[--threads <numThreads>]`` ending in a continuation
64
+ backslash, and an ARGS entry, that line followed by its prose up to the next
65
+ entry, which opens with ``[`` or ``<``, or the next section heading. Both are
66
+ removed. A synopsis line that loses its successor keeps no dangling backslash.
67
+
68
+ Args:
69
+ text: a block of the shared manual, ``CLI_DS`` or ``ARGS_SYNOPSIS_DS``.
70
+ flags: flag names without dashes.
71
+
72
+ Returns:
73
+ The block without those flags.
74
+
75
+ Example:
76
+ >>> manualText_omit(" [--a] \\\n [--b]\n", ["b"])
77
+ ' [--a]\n'
78
+ """
79
+ if not flags:
80
+ return text
81
+ omitted: set[str] = set(flags)
82
+ kept: list[str] = []
83
+ skipping: bool = False
84
+ for line in text.splitlines(keepends=True):
85
+ match: re.Match[str] | None = ENTRY_LINE.match(line)
86
+ if match:
87
+ skipping = match.group("flag") in omitted
88
+ elif skipping and line.strip() and not line.startswith(" " * 8):
89
+ skipping = False
90
+ if not skipping:
91
+ kept.append(line)
92
+ if kept and text.rstrip().endswith("]") and kept[-1].rstrip().endswith("\\"):
93
+ kept[-1] = kept[-1].rstrip()[:-1].rstrip() + "\n"
94
+ return "".join(kept)
95
+
96
+
54
97
  def synopsis_build(spec: PluginSpec, shortOnly: bool = False) -> str:
55
98
  """The manual text for a plugin: its NAME, SYNOPSIS and brief example, then the shared sections.
56
99
 
@@ -70,13 +113,20 @@ def synopsis_build(spec: PluginSpec, shortOnly: bool = False) -> str:
70
113
  SYNOPSIS
71
114
 
72
115
  {spec.name} \\"""
73
- + CLI_DS
116
+ + manualText_omit(CLI_DS, spec.omit)
74
117
  + "\n\n BRIEF EXAMPLE\n"
75
118
  + spec.briefExample
76
119
  )
77
120
  if shortOnly:
78
121
  return shortSynopsis
79
- return shortSynopsis + spec.description + "\n ARGS " + ARGS_SYNOPSIS_DS + TAG_HELP + spec.examples
122
+ return (
123
+ shortSynopsis
124
+ + spec.description
125
+ + "\n ARGS "
126
+ + manualText_omit(ARGS_SYNOPSIS_DS, spec.omit)
127
+ + TAG_HELP
128
+ + spec.examples
129
+ )
80
130
 
81
131
 
82
132
  def manual_build(spec: PluginSpec) -> Manual:
@@ -92,13 +142,57 @@ def manual_build(spec: PluginSpec) -> Manual:
92
142
  )
93
143
 
94
144
 
145
+ def parser_omit(parser: argparse.ArgumentParser, names: Sequence[str]) -> None:
146
+ """Take flags out of a parser, keeping each one's default as a fixed value.
147
+
148
+ argparse has no public way to remove an argument, and the parser is assembled
149
+ from pftree's, pfdo's and pfdo_run's parents, so the flag is removed from the
150
+ three places argparse keeps it: the parser's actions, which chris_plugin
151
+ serialises; the group that lists it in ``--help``; and the option-string map
152
+ the command line is parsed against. Its default stays in the namespace, set
153
+ with ``set_defaults``, so the engine reads the value it always did.
154
+
155
+ Args:
156
+ parser: the assembled parser, changed in place.
157
+ names: destination names, as ``threads``.
158
+
159
+ Raises:
160
+ ValueError: a name the parser does not have; a misspelt omission would
161
+ otherwise leave the flag in place unnoticed.
162
+
163
+ Example:
164
+ >>> p = argparse.ArgumentParser(); _ = p.add_argument("--n", dest="n", default=3)
165
+ >>> parser_omit(p, ["n"]); p.parse_args([]).n
166
+ 3
167
+ """
168
+ # argparse internals: _actions, _action_groups, _group_actions and
169
+ # _option_string_actions are private but stable from 3.11 to 3.14, and
170
+ # chris_plugin itself reads _actions; CI runs every one of those versions.
171
+ actions: dict[str, argparse.Action] = {action.dest: action for action in parser._actions}
172
+ unknown: list[str] = [name for name in names if name not in actions]
173
+ if unknown:
174
+ raise ValueError(f"cannot omit flags the parser does not have: {', '.join(unknown)}")
175
+ for name in names:
176
+ action: argparse.Action = actions[name]
177
+ parser._remove_action(action)
178
+ for group in parser._action_groups:
179
+ if action in group._group_actions:
180
+ group._group_actions.remove(action)
181
+ for optionString in action.option_strings:
182
+ parser._option_string_actions.pop(optionString, None)
183
+ parser.set_defaults(**{name: action.default})
184
+
185
+
95
186
  def parser_forSpec(spec: PluginSpec) -> argparse.ArgumentParser:
96
- """pfdo_run's parser named for the plugin, its defaults set from the spec.
187
+ """pfdo_run's parser named for the plugin, its omitted flags removed, its defaults set from the spec.
97
188
 
98
189
  The defaults are set on the parser rather than applied afterwards, so the
99
190
  description ``chris_plugin_info`` serialises carries them and ChRIS shows them.
191
+ They are set after the omission, so a spec can pin an omitted flag to a value
192
+ of its own.
100
193
  """
101
194
  parser: argparse.ArgumentParser = parser_build(standalone=False, command=spec.name, version=spec.version)
195
+ parser_omit(parser, spec.omit)
102
196
  parser.set_defaults(**spec.defaults)
103
197
  return parser
104
198
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shexec
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Run a shell-type command over every file of an input tree: a ChRIS DS plugin on pfdo_run
5
5
  Home-page: https://github.com/FNNDSC/pl-shexec
6
6
  Author: FNNDSC
@@ -142,7 +142,7 @@ pl-shexec is a library as well as a plugin, in the same way pfdo_run is a librar
142
142
  ))
143
143
  main = PLUGIN.main
144
144
 
145
- ``PluginSpec`` holds everything a plugin changes: its name and version, which become its command, its span and its ``--version``; its title, category and resource limits for ChRIS; the defaults of its flags, above all ``--exec``, which ChRIS shows as the defaults and the user can override; and its words on the manual page, over the sections every plugin shares. ``plugin_build`` returns the parser with those defaults applied, the manual, and ``main`` already wrapped in pflog's ``timed`` and ``chris_plugin``, ready to be the console script. The engine, the flags, the records and the exit status are not in the spec, because they are the same for every plugin on the stack, and a fix to them reaches every plugin on its next build.
145
+ ``PluginSpec`` holds everything a plugin changes: its name and version, which become its command, its span and its ``--version``; its title, category and resource limits for ChRIS; the defaults of its flags, above all ``--exec``, which ChRIS shows as the defaults and the user can override; and its words on the manual page, over the sections every plugin shares. A plugin may also decline engine flags it should not offer: ``omit=("threads", "threadsSplit")`` removes them from its parser, its ChRIS description and its manual, and fixes their values, as pl-freesurfer does because two ``recon-all`` runs in one container exhaust its memory. ``plugin_build`` returns the parser with those defaults applied, the manual, and ``main`` already wrapped in pflog's ``timed`` and ``chris_plugin``, ready to be the console script. The engine, the flags, the records and the exit status are not in the spec, because they are the same for every plugin on the stack, and a fix to them reaches every plugin on its next build.
146
146
 
147
147
  The rest of pl-freesurfer is a ``pyproject.toml`` naming ``chris_plugin`` and ``shexec`` as its dependencies, the first so that ``chris_plugin_info`` finds the plugin, a ``setup.cfg``, a Dockerfile that starts from the FreeSurfer image and installs the package, tests, and docs. pl-shexec itself is the first plugin built this way; its ``__main__`` is the same call with an empty default command. The pf* stack's ``docs/plugin.adoc`` has the template.
148
148
 
@@ -90,3 +90,42 @@ def test_shexecItself_isBuiltByTheFactory() -> None:
90
90
  assert (
91
91
  SPEC.name == "shexec" and PLUGIN.parser.get_default("exec") == "" and PLUGIN.main.__name__ == "shexec"
92
92
  )
93
+
94
+
95
+ def test_omittedFlags_absentFromParserDescriptionAndManual_valuePinned(
96
+ capsys: pytest.CaptureFixture[str],
97
+ ) -> None:
98
+ plugin: Plugin = plugin_build(spec_make(omit=("threads", "threadsSplit")), singleton=False)
99
+ names: set[str] = {parameter["name"] for parameter in serialize(plugin.parser)}
100
+ assert "threads" not in names and "threadsSplit" not in names
101
+ assert "verbosity" in names
102
+ options: argparse.Namespace = plugin.parser.parse_args([])
103
+ assert options.threads == 0 and options.threadsSplit == 1
104
+ with pytest.raises(SystemExit) as raised:
105
+ plugin.parser.parse_args(["--threads", "4"])
106
+ assert raised.value.code == 2
107
+ capsys.readouterr()
108
+ assert plugin.main(plugin.parser.parse_args(["--man"]), Path("/tmp"), Path("/tmp")) == 0
109
+ manual: str = capsys.readouterr().out
110
+ assert "[--threads" not in manual and "[--threadsSplit" not in manual
111
+ assert "[--verbosity" in manual
112
+
113
+
114
+ def test_omittedFlag_pinnedBySpecDefault() -> None:
115
+ plugin: Plugin = plugin_build(
116
+ spec_make(omit=("threads",), defaults={"exec": COUNT_LINES, "threads": 2}), singleton=False
117
+ )
118
+ assert plugin.parser.parse_args([]).threads == 2
119
+ assert "threads" not in {parameter["name"] for parameter in serialize(plugin.parser)}
120
+
121
+
122
+ def test_omittedFlag_unknownName_refused() -> None:
123
+ with pytest.raises(ValueError, match="threadz"):
124
+ plugin_build(spec_make(omit=("threadz",)), singleton=False)
125
+
126
+
127
+ def test_omittedFlags_derivedPluginStillRuns(tmp_path: Path) -> None:
128
+ inputDir, outputDir = tree_make(tmp_path)
129
+ plugin: Plugin = plugin_build(spec_make(omit=("threads", "threadsSplit")), singleton=False)
130
+ assert plugin.main(plugin.parser.parse_args(["--verbosity", "0"]), inputDir, outputDir) == 0
131
+ assert (outputDir / "sub-01" / "jobs.jsonl").exists()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes