ppqs 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.
- ppqs-1.0.dist-info/METADATA +140 -0
- ppqs-1.0.dist-info/RECORD +7 -0
- ppqs-1.0.dist-info/WHEEL +5 -0
- ppqs-1.0.dist-info/entry_points.txt +2 -0
- ppqs-1.0.dist-info/licenses/LICENSE +18 -0
- ppqs-1.0.dist-info/top_level.txt +1 -0
- py_proj_quick_scripts/__init__.py +281 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ppqs
|
|
3
|
+
Version: 1.0
|
|
4
|
+
Summary: Python project quick scripts
|
|
5
|
+
Author-email: Karl Wette <karl.wette@anu.edu.au>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/kwwette/ppqs
|
|
8
|
+
Project-URL: Issues, https://github.com/kwwette/ppqs/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: tomli>=1.1.0; python_version < "3.11"
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# Python project quick scripts
|
|
18
|
+
|
|
19
|
+
**Define simple development scripts in `pyproject.toml`**
|
|
20
|
+
|
|
21
|
+
Developing a Python project usually involves many tasks: setting up, installing
|
|
22
|
+
prerequisites, building, testing, cleaning up build products, etc. It can be
|
|
23
|
+
difficult to remember which program to run to perform a given task for a given
|
|
24
|
+
project, particularly when many different tools are being used.
|
|
25
|
+
|
|
26
|
+
**ppqs** defines simple (*"quick"*) scripts in `pyproject.toml`, and provides a
|
|
27
|
+
command-line utility -- `ppqs` -- to run them. In this way **ppqs** hides the
|
|
28
|
+
details of whatever dependency/virtual environment manager, build front-end,
|
|
29
|
+
test harness, etc. the project uses behind a simple interface.
|
|
30
|
+
|
|
31
|
+
Here is an example of **ppqs** being used for its own project:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
$ ppqs --help
|
|
35
|
+
Quick scripts for Python project: ppqs
|
|
36
|
+
|
|
37
|
+
Usage: ppqs {init,lint,build,test,clean}
|
|
38
|
+
|
|
39
|
+
Scripts:
|
|
40
|
+
init Initialise project
|
|
41
|
+
lint Perform linting checks
|
|
42
|
+
build Build project
|
|
43
|
+
test Run tests
|
|
44
|
+
clean Clean up build files
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Scripts are always run from the project root directory, i.e. the directory
|
|
48
|
+
containing `pyproject.toml`. `ppqs` traverses back through parent directories
|
|
49
|
+
until it finds the root directory. It is therefore safe to run `ppqs` from any
|
|
50
|
+
sub-directory within the project directory tree.
|
|
51
|
+
|
|
52
|
+
## Script definition in `pyproject.toml`
|
|
53
|
+
|
|
54
|
+
**ppqs** scripts are simple lists of commands which are run in sequence. If a
|
|
55
|
+
command errors, the remainder of the script is aborted.
|
|
56
|
+
|
|
57
|
+
Scripts are defined in `pyproject.toml` under the `[tool.ppqs.scripts]`
|
|
58
|
+
section. Script names may contain only lowercase letters (`[a-z]`) and dashes
|
|
59
|
+
(`-`). Scripts may be single or multi-line strings: commands are separated by
|
|
60
|
+
newlines, and command arguments are separated by spaces.
|
|
61
|
+
|
|
62
|
+
```toml
|
|
63
|
+
[tool.ppqs.scripts]
|
|
64
|
+
init = "command"
|
|
65
|
+
lint = """
|
|
66
|
+
command
|
|
67
|
+
"""
|
|
68
|
+
build = """
|
|
69
|
+
command1 -v
|
|
70
|
+
command2 -q
|
|
71
|
+
"""
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Scripts may also be defined as lists of lists, i.e. a list of commands, each of
|
|
75
|
+
which is a list of arguments:
|
|
76
|
+
|
|
77
|
+
```toml
|
|
78
|
+
[tool.ppqs.scripts]
|
|
79
|
+
test = [
|
|
80
|
+
["command1"],
|
|
81
|
+
["command2", "-vv"],
|
|
82
|
+
]
|
|
83
|
+
clean = [
|
|
84
|
+
["command"],
|
|
85
|
+
]
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
If a script contains an ellipsis (`...`) it is replaced with any additional
|
|
89
|
+
arguments passed to `ppqs`. For example, the following script:
|
|
90
|
+
|
|
91
|
+
```toml
|
|
92
|
+
[tool.ppqs.scripts]
|
|
93
|
+
print-something = "echo ..."
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
may be run as
|
|
97
|
+
```bash
|
|
98
|
+
$ ppqs print-something Hi
|
|
99
|
+
Hi
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Scripts may also be defined in their own section, which permit a few options:
|
|
103
|
+
|
|
104
|
+
```toml
|
|
105
|
+
[tool.ppqs.scripts.init]
|
|
106
|
+
description = "Initialise project"
|
|
107
|
+
print_header = true
|
|
108
|
+
script = """
|
|
109
|
+
command
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
[tool.ppqs.scripts.build]
|
|
113
|
+
# default description = "Run build script"
|
|
114
|
+
# default print_header = false
|
|
115
|
+
script = [
|
|
116
|
+
["command1", "-v"],
|
|
117
|
+
["command2", "-q"],
|
|
118
|
+
]
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
where:
|
|
124
|
+
|
|
125
|
+
* `description` [optional]: description of the script which appears in `ppqs
|
|
126
|
+
--help`. Default is `Run {name} script` where `name` is the script name.
|
|
127
|
+
|
|
128
|
+
* `print_header` [optional]: If true, print a header before running each command
|
|
129
|
+
in the script. The header consists of the command to be run, centred on the
|
|
130
|
+
console and padded with `*`s. Default is false.
|
|
131
|
+
|
|
132
|
+
Commands are *not* parsed to the shell, so e.g. wildcards are not expanded. If
|
|
133
|
+
the features of a shell script are needed (wildcards, conditional statements,
|
|
134
|
+
etc.), one can write a helper script, e.g. `scripts/lots-to-do.py`, which may
|
|
135
|
+
then be called by `ppqs` as:
|
|
136
|
+
|
|
137
|
+
```toml
|
|
138
|
+
[tool.ppqs.scripts]
|
|
139
|
+
lots-to-do = [["python", "scripts/lots-to-do.py"]]
|
|
140
|
+
```
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
ppqs-1.0.dist-info/licenses/LICENSE,sha256=eGcCXI2x3rBIZftKoQK1TGM8N0zvxcO1BST6hmUQUEw,1058
|
|
2
|
+
py_proj_quick_scripts/__init__.py,sha256=MFkaVsUeR-hEe_nJU66dHyRYW5psE-V3FAilmty0wT8,8276
|
|
3
|
+
ppqs-1.0.dist-info/METADATA,sha256=BL_M0pKpddsD4-D0sdO1Nr_nKvmUbdFCKPOfBSkzKzQ,3959
|
|
4
|
+
ppqs-1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
5
|
+
ppqs-1.0.dist-info/entry_points.txt,sha256=P-MsiSlLTTFGJD6XLQmARAch2MBh9NA0rtwKSa3Ro8Q,51
|
|
6
|
+
ppqs-1.0.dist-info/top_level.txt,sha256=SMckK8lcW3XcfBV0cZ2JTZW5lKt58wwlILx8di4XbfA,22
|
|
7
|
+
ppqs-1.0.dist-info/RECORD,,
|
ppqs-1.0.dist-info/WHEEL
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Copyright 2025 Karl Wette
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
4
|
+
this software and associated documentation files (the “Software”), to deal in
|
|
5
|
+
the Software without restriction, including without limitation the rights to
|
|
6
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
|
7
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
|
8
|
+
subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
15
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
16
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
17
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
18
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
py_proj_quick_scripts
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import textwrap
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
if sys.version_info >= (3, 11): # pragma: no cover
|
|
10
|
+
import tomllib
|
|
11
|
+
else: # pragma: no cover
|
|
12
|
+
import tomli as tomllib
|
|
13
|
+
|
|
14
|
+
__author__ = "Karl Wette"
|
|
15
|
+
__version__ = "1.0"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MissingPyProjectError(Exception):
|
|
19
|
+
"""
|
|
20
|
+
Raise if 'pyproject.toml' could not be found.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class InvalidScriptError(Exception):
|
|
27
|
+
"""
|
|
28
|
+
Raise for invalid 'pyproject.toml' script errors.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, pyproject_path, msg):
|
|
32
|
+
self.pyproject_path = pyproject_path
|
|
33
|
+
self.msg = msg
|
|
34
|
+
super().__init__(self.msg)
|
|
35
|
+
|
|
36
|
+
def __str__(self):
|
|
37
|
+
return f"{self.pyproject_path}: {self.msg}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def find_pyproject():
|
|
41
|
+
"""
|
|
42
|
+
Look for pyproject.toml in current/parent directories.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
# Start in the current directory
|
|
46
|
+
current_dir = starting_dir = Path.cwd()
|
|
47
|
+
|
|
48
|
+
# Traverse down directory parents until just before root
|
|
49
|
+
while len(current_dir.parts) > 1:
|
|
50
|
+
|
|
51
|
+
# Stop when user no longer has write access to parent directory
|
|
52
|
+
# - excludes any pyproject.toml in home directory, /tmp, etc.
|
|
53
|
+
if not os.access(current_dir.parent, os.W_OK):
|
|
54
|
+
break
|
|
55
|
+
|
|
56
|
+
# If pyproject.toml is in this directory, return its path
|
|
57
|
+
pyproject_path = current_dir / "pyproject.toml"
|
|
58
|
+
if os.access(pyproject_path, os.F_OK | os.R_OK):
|
|
59
|
+
return pyproject_path
|
|
60
|
+
|
|
61
|
+
# Look in parent directory
|
|
62
|
+
current_dir = current_dir.parent
|
|
63
|
+
|
|
64
|
+
msg = f"'pyproject.toml' could not be found in '{starting_dir}' or its parent directories"
|
|
65
|
+
raise MissingPyProjectError(msg)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_scripts(pyproject_path):
|
|
69
|
+
"""
|
|
70
|
+
Parse scripts from pyproject.toml.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
# Read pyproject.toml
|
|
74
|
+
pyproject_toml = tomllib.load(pyproject_path.open("rb"))
|
|
75
|
+
project_name = pyproject_toml["project"]["name"]
|
|
76
|
+
try:
|
|
77
|
+
scripts_toml = pyproject_toml["tool"]["ppqs"]["scripts"]
|
|
78
|
+
except KeyError:
|
|
79
|
+
scripts_toml = {}
|
|
80
|
+
if not scripts_toml:
|
|
81
|
+
msg = "does not contain a non-empty '[tool.ppqs.scripts]' section"
|
|
82
|
+
raise InvalidScriptError(pyproject_path, msg)
|
|
83
|
+
|
|
84
|
+
# Parse scripts
|
|
85
|
+
scripts = {}
|
|
86
|
+
for script_name, script_toml in scripts_toml.items():
|
|
87
|
+
|
|
88
|
+
# Check name
|
|
89
|
+
if script_name.startswith("-"):
|
|
90
|
+
msg = "script name '{script_name}' may not start with '-'"
|
|
91
|
+
raise InvalidScriptError(pyproject_path, msg)
|
|
92
|
+
invalid_chars = re.sub(r"[a-z-]", "", script_name)
|
|
93
|
+
if len(invalid_chars) > 0:
|
|
94
|
+
msg = f"script name '{script_name}' may not contain characters '{invalid_chars}'"
|
|
95
|
+
raise InvalidScriptError(pyproject_path, msg)
|
|
96
|
+
|
|
97
|
+
# Create default description
|
|
98
|
+
script_description = f"Run {script_name} script"
|
|
99
|
+
script_print_header = False
|
|
100
|
+
script_commands_toml = script_toml
|
|
101
|
+
|
|
102
|
+
if isinstance(script_toml, dict):
|
|
103
|
+
|
|
104
|
+
# Check for invalid keys
|
|
105
|
+
invalid_keys = [
|
|
106
|
+
k
|
|
107
|
+
for k in script_toml.keys()
|
|
108
|
+
if k not in ("description", "print_header", "script")
|
|
109
|
+
]
|
|
110
|
+
if len(invalid_keys) > 0:
|
|
111
|
+
invalid_keys_str = "', '".join(invalid_keys)
|
|
112
|
+
msg = (
|
|
113
|
+
f"script '{script_name}' may not contain keys '{invalid_keys_str}'"
|
|
114
|
+
)
|
|
115
|
+
raise InvalidScriptError(pyproject_path, msg)
|
|
116
|
+
|
|
117
|
+
script_description = str(script_toml.get("description", script_description))
|
|
118
|
+
script_print_header = bool(
|
|
119
|
+
script_toml.get("print_header", script_print_header)
|
|
120
|
+
)
|
|
121
|
+
script_commands_toml = script_toml["script"]
|
|
122
|
+
|
|
123
|
+
msg = f"script '{script_name}' may be either a string or a list of lists"
|
|
124
|
+
if isinstance(script_commands_toml, str):
|
|
125
|
+
|
|
126
|
+
# Parse a script as a string
|
|
127
|
+
script_commands = [
|
|
128
|
+
line.split()
|
|
129
|
+
for line in script_commands_toml.splitlines()
|
|
130
|
+
if len(line.strip()) > 0
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
elif isinstance(script_commands_toml, list):
|
|
134
|
+
|
|
135
|
+
# Parse a script as a list of lists
|
|
136
|
+
script_commands = []
|
|
137
|
+
for line in script_commands_toml:
|
|
138
|
+
if not isinstance(line, list):
|
|
139
|
+
raise InvalidScriptError(pyproject_path, msg)
|
|
140
|
+
script_line = []
|
|
141
|
+
for arg in line:
|
|
142
|
+
if isinstance(arg, list):
|
|
143
|
+
|
|
144
|
+
# List arguments are treated as paths
|
|
145
|
+
arg_path = Path(*arg)
|
|
146
|
+
if arg_path.is_absolute():
|
|
147
|
+
msg2 = f"path argument '{arg_path}' must be a relative path"
|
|
148
|
+
raise InvalidScriptError(pyproject_path, msg2)
|
|
149
|
+
|
|
150
|
+
script_line.append(str(arg_path))
|
|
151
|
+
|
|
152
|
+
else:
|
|
153
|
+
script_line.append(arg)
|
|
154
|
+
|
|
155
|
+
script_commands.append(script_line)
|
|
156
|
+
|
|
157
|
+
else:
|
|
158
|
+
raise InvalidScriptError(pyproject_path, msg)
|
|
159
|
+
|
|
160
|
+
scripts[script_name] = {
|
|
161
|
+
"description": script_description,
|
|
162
|
+
"print_header": script_print_header,
|
|
163
|
+
"commands": script_commands,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return project_name, scripts
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def print_help(project_name, scripts):
|
|
170
|
+
"""
|
|
171
|
+
Print help.
|
|
172
|
+
"""
|
|
173
|
+
script_names = ",".join(scripts.keys())
|
|
174
|
+
max_script_name_len = max(len(script_name) for script_name in scripts)
|
|
175
|
+
list_of_scripts = "\n".join(
|
|
176
|
+
" "
|
|
177
|
+
+ script_name.ljust(max_script_name_len, " ")
|
|
178
|
+
+ " "
|
|
179
|
+
+ script["description"][0].upper()
|
|
180
|
+
+ script["description"][1:]
|
|
181
|
+
for script_name, script in scripts.items()
|
|
182
|
+
)
|
|
183
|
+
print(
|
|
184
|
+
textwrap.dedent(
|
|
185
|
+
f"""\
|
|
186
|
+
Quick scripts for Python project: {project_name}
|
|
187
|
+
|
|
188
|
+
Usage: ppqs {{{script_names}}}
|
|
189
|
+
|
|
190
|
+
Scripts:
|
|
191
|
+
"""
|
|
192
|
+
)
|
|
193
|
+
+ list_of_scripts
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def run_script(script, argv, cwd):
|
|
198
|
+
"""
|
|
199
|
+
Run a script.
|
|
200
|
+
"""
|
|
201
|
+
col_width = shutil.get_terminal_size().columns
|
|
202
|
+
for command in script["commands"]:
|
|
203
|
+
cmd = []
|
|
204
|
+
|
|
205
|
+
# Replace "..." in command with arguments
|
|
206
|
+
for arg in command:
|
|
207
|
+
if arg == "...":
|
|
208
|
+
cmd.extend(argv)
|
|
209
|
+
else:
|
|
210
|
+
cmd.append(arg)
|
|
211
|
+
|
|
212
|
+
# Print header
|
|
213
|
+
if script["print_header"]:
|
|
214
|
+
cmd_str = " ".join(cmd)
|
|
215
|
+
print(f" {cmd_str} ".center(col_width, "*"))
|
|
216
|
+
|
|
217
|
+
# Run command
|
|
218
|
+
retn = subprocess.run(cmd, stdin=subprocess.DEVNULL, shell=False, cwd=cwd)
|
|
219
|
+
if retn.returncode != 0:
|
|
220
|
+
raise SystemExit(retn.returncode)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def regular_cli(argv):
|
|
224
|
+
"""
|
|
225
|
+
Parse regular command line.
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
# Parse scripts
|
|
229
|
+
pyproject_path = find_pyproject()
|
|
230
|
+
project_name, scripts = parse_scripts(pyproject_path)
|
|
231
|
+
|
|
232
|
+
# Parse command line
|
|
233
|
+
if argv[0] in ("-h", "-help", "--help"):
|
|
234
|
+
print_help(project_name, scripts)
|
|
235
|
+
raise SystemExit(1)
|
|
236
|
+
elif argv[0] not in scripts:
|
|
237
|
+
msg = f"unknown script '{argv[0]}'"
|
|
238
|
+
raise InvalidScriptError(pyproject_path, msg)
|
|
239
|
+
else:
|
|
240
|
+
cwd = pyproject_path.parent
|
|
241
|
+
run_script(scripts[argv[0]], argv[1:], cwd)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def cli(*argv):
|
|
245
|
+
"""
|
|
246
|
+
Parse command line.
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
argv = [str(a) for a in (argv or sys.argv[1:] or ["--help"])]
|
|
250
|
+
|
|
251
|
+
if argv[0] == "--bash-completion": # pragma: no cover
|
|
252
|
+
|
|
253
|
+
# Perform Bash completion
|
|
254
|
+
# - suppress any error output
|
|
255
|
+
try:
|
|
256
|
+
pyproject_path = find_pyproject()
|
|
257
|
+
_, scripts = parse_scripts(pyproject_path)
|
|
258
|
+
line = os.environ["COMP_LINE"]
|
|
259
|
+
words = line.split()
|
|
260
|
+
if line.endswith(" ") and len(words) == 1:
|
|
261
|
+
|
|
262
|
+
# Complete full script name, e.g.:
|
|
263
|
+
# $ ppqs <TAB><TAB>
|
|
264
|
+
for script_name in scripts:
|
|
265
|
+
print(script_name)
|
|
266
|
+
|
|
267
|
+
if not line.endswith(" ") and len(words) == 2:
|
|
268
|
+
|
|
269
|
+
# Complete partial script name, e.g.:
|
|
270
|
+
# $ ppqs nam<TAB>
|
|
271
|
+
for script_name in scripts:
|
|
272
|
+
if script_name.startswith(words[-1]):
|
|
273
|
+
print(script_name)
|
|
274
|
+
|
|
275
|
+
except Exception:
|
|
276
|
+
pass
|
|
277
|
+
|
|
278
|
+
else:
|
|
279
|
+
|
|
280
|
+
# Parse regular command line
|
|
281
|
+
regular_cli(argv)
|