runall 0.1.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.
runall/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """Run shell commands over a set of input files with a terminal progress display.
2
+
3
+ The ``runall`` package lets a user register Python functions with the
4
+ :func:`runthat` decorator. Each registered function receives the path of an
5
+ input file and returns a shell command. The command is executed for every
6
+ input file that matches a glob pattern, while a Textual-based terminal user
7
+ interface (:class:`runall.tui.RunAllApp`) displays one progress bar per
8
+ registered function and a running list of the processed files.
9
+
10
+ Public names:
11
+ CONFIG: A default configuration dictionary.
12
+ discover_input_files: Resolves the input files described by a configuration.
13
+ runthat: Decorator that registers a function in a configuration.
14
+ """
15
+
16
+ from .config import CONFIG
17
+ from .processor import discover_input_files
18
+ from .tui import runthat
19
+
20
+ __all__ = ["CONFIG", "discover_input_files", "runthat"]
runall/config.py ADDED
@@ -0,0 +1,28 @@
1
+ """Default configuration for the ``runall`` package.
2
+
3
+ A configuration is a plain dictionary. The same structure may be defined by
4
+ the user and passed to :func:`runall.tui.runthat` and
5
+ :class:`runall.tui.RunAllApp`.
6
+ """
7
+
8
+ CONFIG = {
9
+ "runthat": {},
10
+ "input_dir": "instances/ht0_*.txt",
11
+ "output_dir": "output",
12
+ "output_preffix": "out_",
13
+ "output_header": "execucao,arquivo",
14
+ "err_dir": "err",
15
+ }
16
+ """Default configuration dictionary.
17
+
18
+ Keys:
19
+ runthat: Registry that maps function names to the wrapped callables. It is
20
+ populated by the :func:`runall.tui.runthat` decorator.
21
+ input_dir: Glob pattern that selects the input files.
22
+ output_dir: Directory that receives the standard output of each command.
23
+ output_preffix: Prefix of every output file name. The key name keeps its
24
+ historical spelling.
25
+ output_header: Header line written once at the top of each new output file.
26
+ err_dir: Directory that receives the executed commands and their standard
27
+ error output.
28
+ """
runall/processor.py ADDED
@@ -0,0 +1,22 @@
1
+ """Discovery of the input files that ``runall`` processes."""
2
+
3
+ import glob
4
+
5
+ from .config import CONFIG
6
+
7
+
8
+ def discover_input_files(config: dict | None = None) -> list[str]:
9
+ """Return the input files that match the configured glob pattern.
10
+
11
+ Args:
12
+ config: Configuration dictionary whose ``"input_dir"`` entry holds the
13
+ glob pattern. When ``None``, the default
14
+ :data:`runall.config.CONFIG` is used.
15
+
16
+ Returns:
17
+ The matching paths in lexicographic order. The list is empty when no
18
+ file matches the pattern.
19
+ """
20
+ if config is None:
21
+ config = CONFIG
22
+ return sorted(glob.glob(config["input_dir"]))
runall/tui.py ADDED
@@ -0,0 +1,280 @@
1
+ """Command registration and terminal user interface for ``runall``.
2
+
3
+ This module provides the :func:`runthat` decorator, which registers functions
4
+ that build shell commands, and :class:`RunAllApp`, a Textual application that
5
+ runs every registered function over every input file while it displays
6
+ progress.
7
+ """
8
+
9
+ import asyncio
10
+ import functools
11
+ import pathlib
12
+ import subprocess
13
+
14
+ from textual.app import App, ComposeResult
15
+ from textual.containers import Horizontal, Vertical, VerticalScroll
16
+ from textual.widgets import Label, ProgressBar
17
+
18
+ # from .config import CONFIG
19
+ from .processor import discover_input_files
20
+
21
+
22
+ def _output_path(config: dict, label: str) -> pathlib.Path:
23
+ """Build the path of the output file for a registered function.
24
+
25
+ Args:
26
+ config: Configuration dictionary.
27
+ label: Name of the registered function.
28
+
29
+ Returns:
30
+ The path ``<output_dir>/<output_preffix><label>.csv``.
31
+ """
32
+ return pathlib.Path(config["output_dir"]) / f"{config['output_preffix']}{label}.csv"
33
+
34
+
35
+ def _err_path(config: dict, label: str) -> pathlib.Path:
36
+ """Build the path of the error log file for a registered function.
37
+
38
+ Args:
39
+ config: Configuration dictionary.
40
+ label: Name of the registered function.
41
+
42
+ Returns:
43
+ The path ``<err_dir>/<output_preffix><label>.csv``.
44
+ """
45
+ return pathlib.Path(config["err_dir"]) / f"{config['output_preffix']}{label}.csv"
46
+
47
+
48
+ def _redirect_output(
49
+ config: dict, label: str, proc: subprocess.CompletedProcess, cmd: str
50
+ ) -> None:
51
+ """Persist the result of a command to the output and error files.
52
+
53
+ The standard output is appended to the output file. When that file does
54
+ not exist yet, it is created with the configured header line first. The
55
+ executed command and its standard error output are appended to the error
56
+ file. Missing directories are created as needed.
57
+
58
+ Args:
59
+ config: Configuration dictionary.
60
+ label: Name of the registered function that produced the command.
61
+ proc: Completed process whose streams are persisted.
62
+ cmd: Shell command that was executed.
63
+ """
64
+ out_path = _output_path(config, label)
65
+ out_path.parent.mkdir(parents=True, exist_ok=True)
66
+ if not out_path.exists():
67
+ with open(out_path, "w", encoding="utf-8") as f:
68
+ f.write(f"{config['output_header']}\n")
69
+ with open(out_path, "a", encoding="utf-8") as f:
70
+ f.write(proc.stdout)
71
+
72
+ err_path = _err_path(config, label)
73
+ err_path.parent.mkdir(parents=True, exist_ok=True)
74
+ with open(err_path, "a", encoding="utf-8") as f:
75
+ f.write(f"$ {cmd}\n")
76
+ if proc.stderr:
77
+ f.write(proc.stderr)
78
+
79
+
80
+ def runthat(config: dict):
81
+ """Create a decorator that registers a command-building function.
82
+
83
+ The decorated function receives the path of an input file and must return
84
+ a shell command as a string. The returned wrapper executes that command
85
+ through the shell, writes its output through :func:`_redirect_output`, and
86
+ returns the completed process. The wrapper is stored in
87
+ ``config["runthat"]`` under the name of the decorated function.
88
+
89
+ The command runs with ``shell=True``. Input file names are therefore
90
+ interpolated into a shell command line, and the function must quote them
91
+ when they may contain special characters. The exit status of the command
92
+ is recorded in the returned process but is not treated as an error.
93
+
94
+ Args:
95
+ config: Configuration dictionary. It must contain a ``"runthat"``
96
+ dictionary, which receives the registered wrapper.
97
+
98
+ Returns:
99
+ A decorator that registers a function and returns its wrapper.
100
+ """
101
+
102
+ def decorator(func):
103
+ """Wrap and register a command-building function.
104
+
105
+ Args:
106
+ func: Function that maps an input file path to a shell command.
107
+
108
+ Returns:
109
+ The wrapper that executes the command for a given input file.
110
+ """
111
+
112
+ @functools.wraps(func)
113
+ def wrapper(filename: str) -> subprocess.CompletedProcess:
114
+ """Execute the command built for an input file.
115
+
116
+ Args:
117
+ filename: Path of the input file.
118
+
119
+ Returns:
120
+ The completed process with captured text output.
121
+ """
122
+ cmd = func(filename)
123
+ proc = subprocess.run(cmd, shell=True, capture_output=True, text=True)
124
+ _redirect_output(config, func.__name__, proc, cmd)
125
+ return proc
126
+
127
+ config["runthat"][func.__name__] = wrapper
128
+ return wrapper
129
+
130
+ return decorator
131
+
132
+
133
+ CSS = """
134
+ #ls {
135
+ height: 75%;
136
+ width: 90%;
137
+ }
138
+
139
+ #ls .fn-row {
140
+ height: 1;
141
+ padding: 0;
142
+ align: left middle;
143
+ }
144
+
145
+ #ls .fn-label {
146
+ width: 12;
147
+ }
148
+
149
+ #ls ProgressBar {
150
+ width: 1fr;
151
+ }
152
+
153
+ Bar > .bar--bar {
154
+ color: $primary;
155
+ }
156
+
157
+ Bar.running > .bar--bar {
158
+ color: #ffa500;
159
+ }
160
+
161
+ Bar.done > .bar--bar {
162
+ color: $success;
163
+ }
164
+
165
+ #li {
166
+ height: 15;
167
+ max-height: 25%;
168
+ margin: 0 1;
169
+ border: round $primary;
170
+ background: $surface;
171
+ }
172
+
173
+ #li Label {
174
+ padding: 0 1;
175
+ }
176
+ """
177
+ """Textual stylesheet of the application.
178
+
179
+ The progress bar of the function that is currently running is drawn in orange.
180
+ """
181
+
182
+
183
+ class RunAllApp(App):
184
+ """Textual application that runs registered functions over input files.
185
+
186
+ The interface shows one progress bar per registered function and a scrolling
187
+ list of the input files in the order in which they are processed. For each
188
+ input file, every registered function runs in registration order. The
189
+ application exits when all files have been processed.
190
+
191
+ Attributes:
192
+ config: Configuration dictionary shared with the registered functions.
193
+ files: Input files to process. It is assigned when the application is
194
+ mounted.
195
+ """
196
+
197
+ TITLE = "RunAll"
198
+ CSS = CSS
199
+
200
+ def __init__(self, config: dict, **kwargs: object) -> None:
201
+ """Initialize the application.
202
+
203
+ Args:
204
+ config: Configuration dictionary. Its ``"runthat"`` entry holds the
205
+ registered functions and its ``"input_dir"`` entry selects the
206
+ input files.
207
+ **kwargs: Additional keyword arguments forwarded to
208
+ :class:`textual.app.App`.
209
+ """
210
+ super().__init__(**kwargs)
211
+ self.config = config
212
+
213
+ def compose(self) -> ComposeResult:
214
+ """Build the widget tree.
215
+
216
+ Yields:
217
+ One labeled progress bar for each registered function, followed by
218
+ a scrollable container that lists the processed files.
219
+ """
220
+ total = len(discover_input_files(self.config))
221
+ with Vertical(id="ls"):
222
+ for label in self.config["runthat"]:
223
+ with Horizontal(id=f"fn-row-{label}", classes="fn-row"):
224
+ yield Label(label, classes="fn-label")
225
+ yield ProgressBar(
226
+ total=total,
227
+ show_percentage=True,
228
+ show_eta=False,
229
+ id=f"bar-{label}",
230
+ )
231
+ with VerticalScroll(id="li"):
232
+ yield Label("")
233
+
234
+ def on_mount(self) -> None:
235
+ """Start processing once the interface is mounted.
236
+
237
+ The application exits with a message, without processing anything,
238
+ when no function is registered or no input file matches the configured
239
+ pattern.
240
+ """
241
+ if not self.config["runthat"]:
242
+ self.exit(message="Nenhuma função registrada com @runthat.")
243
+ return
244
+ self.files = discover_input_files(self.config)
245
+ if not self.files:
246
+ self.exit(message=f"Nenhum arquivo encontrado: {self.config['input_dir']}")
247
+ return
248
+ self.run_worker(self.process_all())
249
+
250
+ async def process_all(self) -> None:
251
+ """Run every registered function over every input file.
252
+
253
+ Files are processed sequentially. For each file, the registered
254
+ functions run one after another in a worker thread so that the
255
+ interface stays responsive. The progress bar of the running function is
256
+ highlighted and advances when the function finishes. The application
257
+ exits after the last file.
258
+ """
259
+ total = len(self.files)
260
+ for index, filename in enumerate(self.files, start=1):
261
+ await self._add_file_line(index, total, filename)
262
+ for label in self.config["runthat"]:
263
+ pb = self.query_one(f"#bar-{label}", ProgressBar)
264
+ bar = pb.query_one("#bar")
265
+ bar.set_class(True, "running")
266
+ await asyncio.to_thread(self.config["runthat"][label], filename)
267
+ bar.set_class(False, "running")
268
+ pb.advance(1)
269
+ self.exit(message="Processamento concluído.")
270
+
271
+ async def _add_file_line(self, index: int, total: int, filename: str) -> None:
272
+ """Append a line for the file being processed to the file list.
273
+
274
+ Args:
275
+ index: One-based position of the file.
276
+ total: Total number of input files.
277
+ filename: Path of the file.
278
+ """
279
+ line = Label(f"[{index}/{total}] {filename}")
280
+ await self.query_one("#li", VerticalScroll).mount(line)
@@ -0,0 +1,160 @@
1
+ Metadata-Version: 2.5
2
+ Name: runall
3
+ Version: 0.1.0
4
+ Summary: Run functions registered by @runthat decorator with a set of progress bars.
5
+ Project-URL: Homepage, https://github.com/tavaresneto/pyrunall
6
+ Project-URL: Repository, https://github.com/tavaresneto/pyrunall
7
+ Project-URL: Issues, https://github.com/tavaresneto/pyrunall/issues
8
+ Author: Roberto Fernandes Tavares Neto
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Requires-Python: >=3.10
12
+ Requires-Dist: textual>=8.2.8
13
+ Description-Content-Type: text/markdown
14
+
15
+ # runall
16
+
17
+ `runall` is a Python package that runs shell commands over a set of input files
18
+ and displays the progress in a terminal user interface (TUI). Commands are
19
+ declared as ordinary Python functions registered with the `@runthat` decorator.
20
+ For every function, the interface shows a progress bar (the bar of the function
21
+ that is currently running is highlighted in orange) and a scrolling list of the
22
+ files that have been processed.
23
+
24
+ ## Features
25
+
26
+ - Registers any number of command-building functions with a single decorator.
27
+ - Selects input files with a glob pattern.
28
+ - Displays one progress bar per function and a running list of processed files.
29
+ - Writes the standard output of every command to a CSV file per function.
30
+ - Logs each executed command and its standard error output to a separate file.
31
+
32
+ ## Requirements
33
+
34
+ - Python 3.10 or later
35
+ - [Textual](https://textual.textualize.io/) 8.2.8 or later (installed automatically)
36
+ - [uv](https://docs.astral.sh/uv/) (recommended for development)
37
+
38
+ ## Installation
39
+
40
+ ```sh
41
+ pip install runall
42
+ ```
43
+
44
+ ```sh
45
+ uv add runall
46
+ ```
47
+
48
+ ## Usage
49
+
50
+ 1. Create a configuration dictionary (see [Configuration](#configuration)).
51
+ 2. Decorate one or more functions with `@runthat(config=config)`. Each function
52
+ receives the path of an input file and returns the shell command to execute.
53
+ 3. Start the interface with `RunAllApp(config).run()`.
54
+
55
+ For every input file, `runall` executes each registered function in the order in
56
+ which it was defined. The commands run sequentially.
57
+
58
+ ### Example
59
+
60
+ The complete example below is available in `examples/demo.py`. It registers three
61
+ functions that each wait five seconds and then print one line:
62
+
63
+ ```python
64
+ # from runall.config import CONFIG
65
+ from runall.tui import RunAllApp, runthat
66
+
67
+ config = {
68
+ "runthat": {},
69
+ "input_dir": "instances/ht0_*.txt",
70
+ "output_dir": "output",
71
+ "output_preffix": "out_",
72
+ "output_header": "execucao,arquivo",
73
+ "err_dir": "err",
74
+ }
75
+
76
+ @runthat(config=config)
77
+ def exec1(filename: str) -> str:
78
+ return f'sleep 5;echo "[1] {filename}"'
79
+
80
+
81
+ @runthat(config=config)
82
+ def exec2(filename: str) -> str:
83
+ return f'sleep 5;echo "[2] {filename}"'
84
+
85
+
86
+ @runthat(config=config)
87
+ def exec3(filename: str) -> str:
88
+ return f'sleep 5;echo "[3] {filename}"'
89
+
90
+
91
+ def main() -> None:
92
+ RunAllApp(config).run()
93
+
94
+
95
+ if __name__ == "__main__":
96
+ main()
97
+ ```
98
+
99
+ Run it from the repository root, so that the relative paths in the configuration
100
+ resolve correctly:
101
+
102
+ ```sh
103
+ uv run python examples/demo.py
104
+ ```
105
+
106
+ ## Configuration
107
+
108
+ The configuration is a plain dictionary with the following keys:
109
+
110
+ | Key | Description |
111
+ | ---------------- | -------------------------------------------------------------------------------------------- |
112
+ | `runthat` | Registry of decorated functions. Start it as an empty dictionary; `@runthat` fills it in. |
113
+ | `input_dir` | Glob pattern that selects the input files, for example `instances/ht0_*.txt`. |
114
+ | `output_dir` | Directory that receives the standard output of each function. |
115
+ | `output_preffix` | Prefix of every output file name. The key keeps its original spelling. |
116
+ | `output_header` | Header line written once at the top of each newly created output file. |
117
+ | `err_dir` | Directory that receives the executed commands and their standard error output. |
118
+
119
+ A default configuration is also available as `runall.CONFIG`.
120
+
121
+ ## Output files
122
+
123
+ For a function named `exec1` and the configuration above, `runall` writes:
124
+
125
+ - `output/out_exec1.csv`: the header line followed by the standard output of every
126
+ execution of `exec1`.
127
+ - `err/out_exec1.csv`: for every execution, the line `$ <command>` followed by
128
+ the standard error output of the command, if any.
129
+
130
+ Both files are opened in append mode, so results accumulate across runs. Remove
131
+ the output directories before a run to start from a clean state. Commands should
132
+ print lines that match the columns of `output_header`.
133
+
134
+ ## Security considerations
135
+
136
+ Commands are executed through the shell (`shell=True`), and the file name is
137
+ inserted into the command string that your function returns. Quote file names
138
+ (for example with `shlex.quote`) if they may contain spaces or shell
139
+ metacharacters, and never register commands built from untrusted input.
140
+
141
+ The exit status of a command is not treated as an error: a failing command is
142
+ recorded in the error log and processing continues with the next command.
143
+
144
+ ## Project layout
145
+
146
+ ```text
147
+ src/runall/
148
+ ├── __init__.py Public API: CONFIG, discover_input_files, runthat
149
+ ├── config.py Default configuration
150
+ ├── processor.py Input file discovery
151
+ └── tui.py The @runthat decorator and the RunAllApp interface
152
+ examples/
153
+ └── demo.py Complete usage example
154
+ instances/ Sample input files used by the example
155
+ ```
156
+
157
+ ## License
158
+
159
+ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file
160
+ for details.
@@ -0,0 +1,8 @@
1
+ runall/__init__.py,sha256=8jbxNNSIvsT0-T-hlXxhAGYlviiqnLLR5SdxHdchqiw,885
2
+ runall/config.py,sha256=mi0h46cIIDtf7TSnmtmOlqe3PGPCG-GU2xIHxUSd3-A,1024
3
+ runall/processor.py,sha256=CwC4DWn_Fl5g1Gk_DhwkW-v1OP28C0fM_JxBB6No0Ck,665
4
+ runall/tui.py,sha256=GjtBhPKwghBbePDRr1it6HCtE8ZHwOKcgpTgPTL3CcA,9242
5
+ runall-0.1.0.dist-info/METADATA,sha256=fJ7iQzIk0YpkiCGeihk-YLWcY1ku25aoftkPfrnqS5Q,5615
6
+ runall-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
7
+ runall-0.1.0.dist-info/licenses/LICENSE,sha256=Ty-96cRm7x4TchIZBW3ES6B_1OC8YX-32EfqozKmL5Q,1087
8
+ runall-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Roberto Fernandes Tavares Neto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.