ppqs 1.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.
ppqs-1.0/LICENSE 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.
ppqs-1.0/PKG-INFO ADDED
@@ -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
+ ```
ppqs-1.0/README.md ADDED
@@ -0,0 +1,124 @@
1
+ # Python project quick scripts
2
+
3
+ **Define simple development scripts in `pyproject.toml`**
4
+
5
+ Developing a Python project usually involves many tasks: setting up, installing
6
+ prerequisites, building, testing, cleaning up build products, etc. It can be
7
+ difficult to remember which program to run to perform a given task for a given
8
+ project, particularly when many different tools are being used.
9
+
10
+ **ppqs** defines simple (*"quick"*) scripts in `pyproject.toml`, and provides a
11
+ command-line utility -- `ppqs` -- to run them. In this way **ppqs** hides the
12
+ details of whatever dependency/virtual environment manager, build front-end,
13
+ test harness, etc. the project uses behind a simple interface.
14
+
15
+ Here is an example of **ppqs** being used for its own project:
16
+
17
+ ```bash
18
+ $ ppqs --help
19
+ Quick scripts for Python project: ppqs
20
+
21
+ Usage: ppqs {init,lint,build,test,clean}
22
+
23
+ Scripts:
24
+ init Initialise project
25
+ lint Perform linting checks
26
+ build Build project
27
+ test Run tests
28
+ clean Clean up build files
29
+ ```
30
+
31
+ Scripts are always run from the project root directory, i.e. the directory
32
+ containing `pyproject.toml`. `ppqs` traverses back through parent directories
33
+ until it finds the root directory. It is therefore safe to run `ppqs` from any
34
+ sub-directory within the project directory tree.
35
+
36
+ ## Script definition in `pyproject.toml`
37
+
38
+ **ppqs** scripts are simple lists of commands which are run in sequence. If a
39
+ command errors, the remainder of the script is aborted.
40
+
41
+ Scripts are defined in `pyproject.toml` under the `[tool.ppqs.scripts]`
42
+ section. Script names may contain only lowercase letters (`[a-z]`) and dashes
43
+ (`-`). Scripts may be single or multi-line strings: commands are separated by
44
+ newlines, and command arguments are separated by spaces.
45
+
46
+ ```toml
47
+ [tool.ppqs.scripts]
48
+ init = "command"
49
+ lint = """
50
+ command
51
+ """
52
+ build = """
53
+ command1 -v
54
+ command2 -q
55
+ """
56
+ ```
57
+
58
+ Scripts may also be defined as lists of lists, i.e. a list of commands, each of
59
+ which is a list of arguments:
60
+
61
+ ```toml
62
+ [tool.ppqs.scripts]
63
+ test = [
64
+ ["command1"],
65
+ ["command2", "-vv"],
66
+ ]
67
+ clean = [
68
+ ["command"],
69
+ ]
70
+ ```
71
+
72
+ If a script contains an ellipsis (`...`) it is replaced with any additional
73
+ arguments passed to `ppqs`. For example, the following script:
74
+
75
+ ```toml
76
+ [tool.ppqs.scripts]
77
+ print-something = "echo ..."
78
+ ```
79
+
80
+ may be run as
81
+ ```bash
82
+ $ ppqs print-something Hi
83
+ Hi
84
+ ```
85
+
86
+ Scripts may also be defined in their own section, which permit a few options:
87
+
88
+ ```toml
89
+ [tool.ppqs.scripts.init]
90
+ description = "Initialise project"
91
+ print_header = true
92
+ script = """
93
+ command
94
+ """
95
+
96
+ [tool.ppqs.scripts.build]
97
+ # default description = "Run build script"
98
+ # default print_header = false
99
+ script = [
100
+ ["command1", "-v"],
101
+ ["command2", "-q"],
102
+ ]
103
+ """
104
+
105
+ ```
106
+
107
+ where:
108
+
109
+ * `description` [optional]: description of the script which appears in `ppqs
110
+ --help`. Default is `Run {name} script` where `name` is the script name.
111
+
112
+ * `print_header` [optional]: If true, print a header before running each command
113
+ in the script. The header consists of the command to be run, centred on the
114
+ console and padded with `*`s. Default is false.
115
+
116
+ Commands are *not* parsed to the shell, so e.g. wildcards are not expanded. If
117
+ the features of a shell script are needed (wildcards, conditional statements,
118
+ etc.), one can write a helper script, e.g. `scripts/lots-to-do.py`, which may
119
+ then be called by `ppqs` as:
120
+
121
+ ```toml
122
+ [tool.ppqs.scripts]
123
+ lots-to-do = [["python", "scripts/lots-to-do.py"]]
124
+ ```
@@ -0,0 +1,66 @@
1
+ [build-system]
2
+ requires = ["setuptools"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ppqs"
7
+ version = "1.0"
8
+ description = "Python project quick scripts"
9
+ authors = [
10
+ { name = "Karl Wette", email = "karl.wette@anu.edu.au" },
11
+ ]
12
+ requires-python = ">=3.9"
13
+ dependencies = [
14
+ "tomli>=1.1.0; python_version<'3.11'"
15
+ ]
16
+ readme = "README.md"
17
+ license = "MIT"
18
+ license-files = ["LICENSE"]
19
+ classifiers = [
20
+ "Programming Language :: Python :: 3",
21
+ "Operating System :: OS Independent",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/kwwette/ppqs"
26
+ Issues = "https://github.com/kwwette/ppqs/issues"
27
+
28
+ [project.scripts]
29
+ ppqs = "py_proj_quick_scripts:cli"
30
+
31
+ [tool.pytest.ini_options]
32
+ minversion = "6.0"
33
+ addopts = "--cov=py_proj_quick_scripts --cov-report=term-missing --cov-fail-under=100"
34
+ testpaths = ["test"]
35
+
36
+ [tool.ppqs.scripts.init]
37
+ description = "Initialise project"
38
+ print_header = true
39
+ script = """
40
+ python -m pip install -r requirements-dev.txt
41
+ pre-commit install
42
+ python -m pip install -e .
43
+ """
44
+
45
+ [tool.ppqs.scripts.lint]
46
+ description = "Perform linting checks"
47
+ script = """
48
+ pre-commit run -a
49
+ """
50
+
51
+ [tool.ppqs.scripts.build]
52
+ description = "Build project"
53
+ script = """
54
+ git clean -Xdf
55
+ python -m build
56
+ """
57
+
58
+ [tool.ppqs.scripts.test]
59
+ description = "Run tests"
60
+ script = """
61
+ pytest ...
62
+ """
63
+
64
+ [tool.ppqs.scripts.clean]
65
+ description = "Clean up build files"
66
+ script = "git clean -Xdf"
ppqs-1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/ppqs.egg-info/PKG-INFO
5
+ src/ppqs.egg-info/SOURCES.txt
6
+ src/ppqs.egg-info/dependency_links.txt
7
+ src/ppqs.egg-info/entry_points.txt
8
+ src/ppqs.egg-info/requires.txt
9
+ src/ppqs.egg-info/top_level.txt
10
+ src/py_proj_quick_scripts/__init__.py
11
+ test/test_cli.py
12
+ test/test_find_pyproject.py
13
+ test/test_parse_scripts.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ppqs = py_proj_quick_scripts:cli
@@ -0,0 +1,3 @@
1
+
2
+ [:python_version < "3.11"]
3
+ tomli>=1.1.0
@@ -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)
@@ -0,0 +1,73 @@
1
+ import pytest
2
+
3
+ from py_proj_quick_scripts import InvalidScriptError, cli
4
+
5
+ from . import in_dir, write_file
6
+
7
+
8
+ @pytest.fixture
9
+ def pyproject_path(tmp_path):
10
+ """
11
+ Write pyproject.toml for command-line tests.
12
+ """
13
+
14
+ pyproject_path = tmp_path / "pyproject.toml"
15
+ pyproject_toml = """
16
+ [project]
17
+ name = "test"
18
+ [tool.ppqs.scripts.exit]
19
+ description = "Exits"
20
+ print_header = true
21
+ script = "python -c 'import sys; sys.exit(1)'"
22
+ [tool.ppqs.scripts.echo]
23
+ description = "Echoes"
24
+ script = [
25
+ ["python", "-c", "import sys; print(*sys.argv[1:])", "..."],
26
+ ]
27
+ """
28
+
29
+ write_file(pyproject_path, pyproject_toml)
30
+
31
+ return pyproject_path
32
+
33
+
34
+ def test_help(pyproject_path):
35
+ """
36
+ Test command line help.
37
+ """
38
+
39
+ with in_dir(pyproject_path.parent):
40
+ with pytest.raises(SystemExit):
41
+ cli("--help")
42
+
43
+
44
+ def test_notest(pyproject_path):
45
+ """
46
+ Test a non-existent test.
47
+ """
48
+
49
+ with in_dir(pyproject_path.parent):
50
+ with pytest.raises(InvalidScriptError):
51
+ cli("notest")
52
+
53
+
54
+ def test_exit(pyproject_path):
55
+ """
56
+ Test exit() script.
57
+ """
58
+
59
+ with in_dir(pyproject_path.parent):
60
+ with pytest.raises(SystemExit):
61
+ cli("exit")
62
+
63
+
64
+ def test_echo(pyproject_path, capfd):
65
+ """
66
+ Test echo() script.
67
+ """
68
+
69
+ with in_dir(pyproject_path.parent):
70
+ cli("echo", "Hello")
71
+ captured = capfd.readouterr()
72
+ assert captured.out == "Hello\n"
73
+ assert captured.err == ""
@@ -0,0 +1,30 @@
1
+ import pytest
2
+
3
+ from py_proj_quick_scripts import (
4
+ MissingPyProjectError,
5
+ find_pyproject,
6
+ )
7
+
8
+ from . import in_dir, write_file
9
+
10
+
11
+ def test_find_pyproject(tmp_path):
12
+ """
13
+ Test find_pyproject().
14
+ """
15
+
16
+ with in_dir(tmp_path):
17
+ with pytest.raises(MissingPyProjectError):
18
+ find_pyproject()
19
+
20
+ pyproject_path = tmp_path / "pyproject.toml"
21
+ write_file(pyproject_path, "[tool.ppqs.scripts]")
22
+
23
+ with in_dir(pyproject_path.parent):
24
+ assert find_pyproject() == pyproject_path
25
+
26
+ with in_dir(pyproject_path.parent / "subdir"):
27
+ assert find_pyproject() == pyproject_path
28
+
29
+ with in_dir(pyproject_path.parent / "subdir" / "subdir"):
30
+ assert find_pyproject() == pyproject_path
@@ -0,0 +1,248 @@
1
+ from contextlib import nullcontext
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ from py_proj_quick_scripts import (
7
+ InvalidScriptError,
8
+ parse_scripts,
9
+ )
10
+
11
+ from . import in_dir, write_file
12
+
13
+
14
+ @pytest.mark.parametrize(
15
+ "script_toml,expectation",
16
+ [
17
+ pytest.param(
18
+ """
19
+ [some.section]
20
+ """,
21
+ pytest.raises(
22
+ InvalidScriptError, match=r"non-empty '\[tool.ppqs.scripts\]' section"
23
+ ),
24
+ id="missing section",
25
+ ),
26
+ pytest.param(
27
+ """
28
+ [tool.ppqs.scripts]
29
+ """,
30
+ pytest.raises(
31
+ InvalidScriptError, match=r"non-empty '\[tool.ppqs.scripts\]' section"
32
+ ),
33
+ id="empty section",
34
+ ),
35
+ pytest.param(
36
+ """
37
+ [tool.ppqs.scripts]
38
+ init = 123
39
+ """,
40
+ pytest.raises(
41
+ InvalidScriptError, match="either a string or a list of lists"
42
+ ),
43
+ id="wrong type",
44
+ ),
45
+ pytest.param(
46
+ """
47
+ [tool.ppqs.scripts]
48
+ init = "pip install -r requirements.txt"
49
+ """,
50
+ nullcontext(
51
+ {
52
+ "init": {
53
+ "description": "Run init script",
54
+ "print_header": False,
55
+ "commands": [["pip", "install", "-r", "requirements.txt"]],
56
+ }
57
+ }
58
+ ),
59
+ id="string script",
60
+ ),
61
+ pytest.param(
62
+ """
63
+ [tool.ppqs.scripts]
64
+ init = '''
65
+ pip install -r requirements.txt
66
+ pre-commit install
67
+ '''
68
+ """,
69
+ nullcontext(
70
+ {
71
+ "init": {
72
+ "description": "Run init script",
73
+ "print_header": False,
74
+ "commands": [
75
+ ["pip", "install", "-r", "requirements.txt"],
76
+ ["pre-commit", "install"],
77
+ ],
78
+ }
79
+ }
80
+ ),
81
+ id="multi-line string script",
82
+ ),
83
+ pytest.param(
84
+ """
85
+ [tool.ppqs.scripts]
86
+ -invalid-name = "something"
87
+ """,
88
+ pytest.raises(InvalidScriptError, match="may not start with '-'"),
89
+ id="script name starts with dash",
90
+ ),
91
+ pytest.param(
92
+ """
93
+ [tool.ppqs.scripts]
94
+ name_contains-2-invalid-characters = "something"
95
+ """,
96
+ pytest.raises(InvalidScriptError, match="may not contain characters '_2'"),
97
+ id="script name invalid characters",
98
+ ),
99
+ pytest.param(
100
+ """
101
+ [tool.ppqs.scripts]
102
+ some-script = { description = "something", another_key = "invalid"}
103
+ """,
104
+ pytest.raises(
105
+ InvalidScriptError, match="may not contain keys 'another_key'"
106
+ ),
107
+ id="script invalid keys",
108
+ ),
109
+ pytest.param(
110
+ """
111
+ [tool.ppqs.scripts.init]
112
+ description = "Initialise project"
113
+ script = "pip install -r requirements.txt"
114
+ """,
115
+ nullcontext(
116
+ {
117
+ "init": {
118
+ "description": "Initialise project",
119
+ "print_header": False,
120
+ "commands": [["pip", "install", "-r", "requirements.txt"]],
121
+ }
122
+ }
123
+ ),
124
+ id="string script with description",
125
+ ),
126
+ pytest.param(
127
+ """
128
+ [tool.ppqs.scripts.init]
129
+ description = "Initialise project"
130
+ script = [
131
+ "some_commands",
132
+ ]
133
+ """,
134
+ pytest.raises(
135
+ InvalidScriptError, match="either a string or a list of lists"
136
+ ),
137
+ id="script is not list of lists",
138
+ ),
139
+ pytest.param(
140
+ """
141
+ [tool.ppqs.scripts]
142
+ init = [
143
+ ["pip", "install", "-r", "requirements.txt"],
144
+ ]
145
+ """,
146
+ nullcontext(
147
+ {
148
+ "init": {
149
+ "description": "Run init script",
150
+ "print_header": False,
151
+ "commands": [["pip", "install", "-r", "requirements.txt"]],
152
+ }
153
+ }
154
+ ),
155
+ id="list script",
156
+ ),
157
+ pytest.param(
158
+ """
159
+ [tool.ppqs.scripts.init]
160
+ description = "Initialise project"
161
+ print_header = true
162
+ script = [
163
+ ["pip", "install", "-r", "requirements.txt"],
164
+ ]
165
+ """,
166
+ nullcontext(
167
+ {
168
+ "init": {
169
+ "description": "Initialise project",
170
+ "print_header": True,
171
+ "commands": [["pip", "install", "-r", "requirements.txt"]],
172
+ }
173
+ }
174
+ ),
175
+ id="list script with description",
176
+ ),
177
+ pytest.param(
178
+ """
179
+ [tool.ppqs.scripts.init]
180
+ description = "Initialise project"
181
+ script = [
182
+ ["pip", "install", "-r", "requirements.txt"],
183
+ ["pre-commit", "install"],
184
+ ]
185
+ """,
186
+ nullcontext(
187
+ {
188
+ "init": {
189
+ "description": "Initialise project",
190
+ "print_header": False,
191
+ "commands": [
192
+ ["pip", "install", "-r", "requirements.txt"],
193
+ ["pre-commit", "install"],
194
+ ],
195
+ }
196
+ }
197
+ ),
198
+ id="multi-line list script with description",
199
+ ),
200
+ pytest.param(
201
+ """
202
+ [tool.ppqs.scripts]
203
+ paths = [
204
+ ["echo", ["/tmp"]],
205
+ ]
206
+ """,
207
+ pytest.raises(InvalidScriptError, match="must be a relative path"),
208
+ id="list script with absolute path",
209
+ ),
210
+ pytest.param(
211
+ """
212
+ [tool.ppqs.scripts]
213
+ paths = [
214
+ ["echo", ["a", "b"]],
215
+ ]
216
+ """,
217
+ nullcontext(
218
+ {
219
+ "paths": {
220
+ "description": "Run paths script",
221
+ "print_header": False,
222
+ "commands": [["echo", str(Path("a", "b"))]],
223
+ }
224
+ }
225
+ ),
226
+ id="list script with relative path",
227
+ ),
228
+ ],
229
+ )
230
+ def test_parse_scripts(tmp_path, script_toml, expectation):
231
+ """
232
+ Test parse_scripts().
233
+ """
234
+
235
+ pyproject_path = tmp_path / "pyproject.toml"
236
+ pyproject_toml = f"""
237
+ [project]
238
+ name = "test"
239
+ {script_toml}
240
+ """
241
+
242
+ write_file(pyproject_path, pyproject_toml)
243
+
244
+ with in_dir(tmp_path):
245
+ with expectation as expected_result:
246
+ project_name, scripts = parse_scripts(pyproject_path)
247
+ assert project_name == "test"
248
+ assert scripts == expected_result