pyesh 1.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.
- pyesh/__init__.py +6 -0
- pyesh/__main__.py +8 -0
- pyesh/_builtin_child.py +24 -0
- pyesh/_job_child.py +25 -0
- pyesh/api.py +246 -0
- pyesh/backends.py +247 -0
- pyesh/builtins.py +503 -0
- pyesh/cli.py +192 -0
- pyesh/config.py +60 -0
- pyesh/console.py +270 -0
- pyesh/execution.py +451 -0
- pyesh/help.py +129 -0
- pyesh/history.py +61 -0
- pyesh/jobs.py +371 -0
- pyesh/parsing.py +315 -0
- pyesh/plugins.py +229 -0
- pyesh/profiles.py +231 -0
- pyesh/prompt.py +137 -0
- pyesh/python_pipes.py +106 -0
- pyesh/python_runtime.py +170 -0
- pyesh/shell.py +1083 -0
- pyesh/terminal.py +350 -0
- pyesh/user_files.py +336 -0
- pyesh-1.0.0.dist-info/METADATA +110 -0
- pyesh-1.0.0.dist-info/RECORD +29 -0
- pyesh-1.0.0.dist-info/WHEEL +5 -0
- pyesh-1.0.0.dist-info/entry_points.txt +2 -0
- pyesh-1.0.0.dist-info/licenses/LICENSE +21 -0
- pyesh-1.0.0.dist-info/top_level.txt +1 -0
pyesh/__init__.py
ADDED
pyesh/__main__.py
ADDED
pyesh/_builtin_child.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# _builtin_child.py
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
# Resolve the installed/source package beside this file, independent of cwd.
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
9
|
+
from pyesh.builtins import run_builtin
|
|
10
|
+
from pyesh.plugins import PluginManager
|
|
11
|
+
|
|
12
|
+
def main(arguments: Optional[List[str]] = None) -> Optional[int]:
|
|
13
|
+
"""Run a stateless built-in or an explicitly selected plugin command."""
|
|
14
|
+
values = sys.argv[1:] if arguments is None else arguments
|
|
15
|
+
if len(values) >= 3 and values[0] == "--plugin":
|
|
16
|
+
manager = PluginManager()
|
|
17
|
+
manager.load_enabled([values[1]])
|
|
18
|
+
return manager.run(values[2:]) if not manager.errors else 1
|
|
19
|
+
if not values or values[0] not in ("echo", "printf", "pwd"):
|
|
20
|
+
return 2
|
|
21
|
+
return run_builtin(values)
|
|
22
|
+
|
|
23
|
+
if __name__ == "__main__":
|
|
24
|
+
raise SystemExit(main())
|
pyesh/_job_child.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# _job_child.py
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import signal
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
def main() -> None:
|
|
8
|
+
"""Configure the requested process group and execute the child.
|
|
9
|
+
|
|
10
|
+
Returns:
|
|
11
|
+
None. A successful call replaces the helper process.
|
|
12
|
+
"""
|
|
13
|
+
group, error_fd = int(sys.argv[1]), int(sys.argv[2])
|
|
14
|
+
os.set_inheritable(error_fd, False)
|
|
15
|
+
try:
|
|
16
|
+
os.setpgid(0, group)
|
|
17
|
+
for name in ("SIGINT", "SIGQUIT", "SIGTSTP", "SIGTTIN", "SIGTTOU", "SIGPIPE"):
|
|
18
|
+
signal.signal(getattr(signal, name), signal.SIG_DFL)
|
|
19
|
+
os.execvpe(sys.argv[3], sys.argv[3:], os.environ)
|
|
20
|
+
except OSError as error:
|
|
21
|
+
os.write(error_fd, str(error.errno).encode("ascii"))
|
|
22
|
+
os._exit(127)
|
|
23
|
+
|
|
24
|
+
if __name__ == "__main__":
|
|
25
|
+
main()
|
pyesh/api.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# api.py
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Iterable, Mapping, Optional, Sequence
|
|
5
|
+
from .execution import run_pipeline
|
|
6
|
+
from .parsing import Command
|
|
7
|
+
from .shell import SessionState, execute_command_line, execute_script
|
|
8
|
+
|
|
9
|
+
class PyeshSession:
|
|
10
|
+
"""A reusable command-execution session for Python applications.
|
|
11
|
+
|
|
12
|
+
The session preserves controls changed by built-ins, such as verbose mode,
|
|
13
|
+
across calls. Commands use the hosting Python process's working directory,
|
|
14
|
+
environment, and standard streams.
|
|
15
|
+
"""
|
|
16
|
+
def __init__(self, verbose: bool = False, plugins: Optional[Iterable[str]] = None) -> None:
|
|
17
|
+
"""Create an automation session.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
verbose: Whether to trace resolved built-ins and process launches.
|
|
21
|
+
plugins: Installed plugin entry-point names to load explicitly.
|
|
22
|
+
"""
|
|
23
|
+
self._state = SessionState(verbose=verbose)
|
|
24
|
+
if plugins is not None:
|
|
25
|
+
self._state.plugins.load_enabled(plugins)
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def plugin_errors(self) -> Mapping[str, str]:
|
|
29
|
+
"""Return plugin loading failures keyed by plugin name.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
A copy of loading error messages.
|
|
33
|
+
"""
|
|
34
|
+
return self._state.plugins.errors
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def verbose(self) -> bool:
|
|
38
|
+
"""Return whether execution tracing is enabled.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The current verbose setting.
|
|
42
|
+
"""
|
|
43
|
+
return self._state.verbose
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def exit_requested(self) -> bool:
|
|
47
|
+
"""Return whether the session received the ``exit`` built-in.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
``True`` after an exit request.
|
|
51
|
+
"""
|
|
52
|
+
return self._state.exit_requested
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def exit_status(self) -> int:
|
|
56
|
+
"""Return the status retained by an explicit exit request.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
The normalized process exit status.
|
|
60
|
+
"""
|
|
61
|
+
return self._state.exit_status
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def cwd(self) -> Path:
|
|
65
|
+
"""Return the session working directory.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The absolute session working directory.
|
|
69
|
+
"""
|
|
70
|
+
return self._state.cwd
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def environment(self) -> Mapping[str, str]:
|
|
74
|
+
"""Return a copy of the session child environment.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Environment variables keyed by name.
|
|
78
|
+
"""
|
|
79
|
+
return dict(self._state.environment)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def variables(self) -> Mapping[str, object]:
|
|
83
|
+
"""Return a copy of user-visible session variables.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Python-backed values keyed by variable name.
|
|
87
|
+
"""
|
|
88
|
+
return {key: value for key, value in self._state.python.namespace.items()
|
|
89
|
+
if not key.startswith("__") and key not in ("sh", "capture", "env", "argv")}
|
|
90
|
+
|
|
91
|
+
def set_cwd(self, path) -> None:
|
|
92
|
+
"""Set the isolated session working directory.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
path: Existing directory to use.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
None.
|
|
99
|
+
"""
|
|
100
|
+
candidate = Path(path).expanduser().resolve(strict=True)
|
|
101
|
+
if not candidate.is_dir():
|
|
102
|
+
raise NotADirectoryError(str(candidate))
|
|
103
|
+
self._state.cwd = candidate
|
|
104
|
+
self._state.environment["PWD"] = str(candidate)
|
|
105
|
+
|
|
106
|
+
def set_environment(self, name: str, value: Optional[str]) -> None:
|
|
107
|
+
"""Set or remove one session environment variable.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
name: Environment variable name.
|
|
111
|
+
value: String value, or ``None`` to remove the name.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
None.
|
|
115
|
+
"""
|
|
116
|
+
if value is None:
|
|
117
|
+
self._state.environment.pop(name, None)
|
|
118
|
+
else:
|
|
119
|
+
self._state.environment[name] = str(value)
|
|
120
|
+
|
|
121
|
+
def run(self, command_line: str) -> int:
|
|
122
|
+
"""Execute one pyesh command line.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
command_line: Commands and supported pyesh operators to execute.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
The final executed job's exit status. The ``exit`` built-in returns
|
|
129
|
+
the requested status and sets ``exit_requested``.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
ValueError: If the command line has invalid syntax.
|
|
133
|
+
"""
|
|
134
|
+
if self._state.exit_requested:
|
|
135
|
+
return self._state.exit_status
|
|
136
|
+
return execute_command_line(command_line, state=self._state)
|
|
137
|
+
|
|
138
|
+
def run_all(self, command_lines: Iterable[str], stop_on_error: bool = False) -> int:
|
|
139
|
+
"""Execute command lines in order.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
command_lines: Command lines to execute.
|
|
143
|
+
stop_on_error: Stop after the first nonzero status when true.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
The final executed status, or zero for an empty iterable. Execution
|
|
147
|
+
also stops when a command requests session exit.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
ValueError: If any command line has invalid syntax.
|
|
151
|
+
"""
|
|
152
|
+
status = 0
|
|
153
|
+
for command_line in command_lines:
|
|
154
|
+
status = self.run(command_line)
|
|
155
|
+
if self.exit_requested or (stop_on_error and status != 0):
|
|
156
|
+
break
|
|
157
|
+
return status
|
|
158
|
+
|
|
159
|
+
def run_script(self, source: str, filename: str = "<string>", argv: Iterable[str] = ()) -> int:
|
|
160
|
+
"""Execute native pyesh source in this session.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
source: Complete pyesh source text.
|
|
164
|
+
filename: Diagnostic name and ``$0`` value.
|
|
165
|
+
argv: Positional arguments exposed through ``argv`` and ``$1`` onward.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
The final command or explicit exit status.
|
|
169
|
+
"""
|
|
170
|
+
self._state.argv0 = filename
|
|
171
|
+
self._state.argv[:] = list(argv)
|
|
172
|
+
return execute_script(source, self._state, filename)
|
|
173
|
+
|
|
174
|
+
def run_file(self, path, argv: Iterable[str] = ()) -> int:
|
|
175
|
+
"""Read and execute a UTF-8 pyesh script.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
path: Script path.
|
|
179
|
+
argv: Positional script arguments.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
The final command or explicit exit status.
|
|
183
|
+
"""
|
|
184
|
+
source_path = Path(path).expanduser().resolve(strict=True)
|
|
185
|
+
return self.run_script(source_path.read_text(encoding="utf-8"), str(source_path), argv)
|
|
186
|
+
|
|
187
|
+
def run_argv(self, arguments: Sequence[str]) -> int:
|
|
188
|
+
"""Execute an argument vector without parsing shell syntax.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
arguments: Program followed by literal arguments.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
The child process exit status.
|
|
195
|
+
"""
|
|
196
|
+
if not arguments:
|
|
197
|
+
return 0
|
|
198
|
+
status = run_pipeline([Command(arguments=list(arguments))], jobs=self._state.jobs,
|
|
199
|
+
cwd=self._state.cwd, environment=self._state.environment,
|
|
200
|
+
verbose=self._state.verbose, pipefail=self._state.pipefail)
|
|
201
|
+
self._state.last_status = status
|
|
202
|
+
return status
|
|
203
|
+
|
|
204
|
+
def close(self) -> None:
|
|
205
|
+
"""Hang up jobs still managed by this session.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
None.
|
|
209
|
+
"""
|
|
210
|
+
self._state.jobs.shutdown()
|
|
211
|
+
|
|
212
|
+
def __enter__(self):
|
|
213
|
+
"""Enter a managed session context.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
This session.
|
|
217
|
+
"""
|
|
218
|
+
return self
|
|
219
|
+
|
|
220
|
+
def __exit__(self, exc_type, exc_value, traceback) -> None:
|
|
221
|
+
"""Close the session when leaving a managed context.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
exc_type: Active exception type, when present.
|
|
225
|
+
exc_value: Active exception value, when present.
|
|
226
|
+
traceback: Active exception traceback, when present.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
None.
|
|
230
|
+
"""
|
|
231
|
+
self.close()
|
|
232
|
+
|
|
233
|
+
def run_command(command_line: str, verbose: bool = False) -> int:
|
|
234
|
+
"""Execute one command line in a new pyesh automation session.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
command_line: Commands and supported pyesh operators to execute.
|
|
238
|
+
verbose: Whether to trace resolved execution.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
The final command status.
|
|
242
|
+
|
|
243
|
+
Raises:
|
|
244
|
+
ValueError: If the command line has invalid syntax.
|
|
245
|
+
"""
|
|
246
|
+
return PyeshSession(verbose=verbose).run(command_line)
|
pyesh/backends.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# backends.py
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shlex
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Iterable, List, Optional
|
|
9
|
+
|
|
10
|
+
class RuntimeUnavailableError(RuntimeError):
|
|
11
|
+
"""Raised when a script requires an interpreter that is not installed."""
|
|
12
|
+
|
|
13
|
+
def active_virtual_environment(environment=None) -> Optional[Path]:
|
|
14
|
+
"""Resolve the virtual environment active for this pyesh process.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
environment: Optional session environment mapping.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
The environment root reported by the running interpreter or
|
|
21
|
+
``VIRTUAL_ENV``, or ``None`` when neither identifies a directory.
|
|
22
|
+
"""
|
|
23
|
+
if sys.prefix != getattr(sys, "base_prefix", sys.prefix):
|
|
24
|
+
prefix = Path(sys.prefix)
|
|
25
|
+
if prefix.is_dir():
|
|
26
|
+
return prefix.resolve()
|
|
27
|
+
configured = (os.environ if environment is None else environment).get("VIRTUAL_ENV")
|
|
28
|
+
if configured:
|
|
29
|
+
root = Path(configured).expanduser()
|
|
30
|
+
if root.is_dir():
|
|
31
|
+
return root.resolve()
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
def virtual_environment_scripts(root: Path) -> Path:
|
|
35
|
+
"""Return the executable directory for a virtual environment.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
root: Virtual-environment root.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
``Scripts`` on Windows or ``bin`` on other platforms.
|
|
42
|
+
"""
|
|
43
|
+
return root / ("Scripts" if os.name == "nt" else "bin")
|
|
44
|
+
|
|
45
|
+
def active_python_executable(environment=None) -> str:
|
|
46
|
+
"""Choose the active venv Python, falling back to this interpreter.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
environment: Optional session environment mapping.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Resolved Python executable path.
|
|
53
|
+
"""
|
|
54
|
+
root = active_virtual_environment(environment)
|
|
55
|
+
if root is not None:
|
|
56
|
+
scripts = virtual_environment_scripts(root)
|
|
57
|
+
names = (
|
|
58
|
+
("python.exe", "python")
|
|
59
|
+
if os.name == "nt"
|
|
60
|
+
else ("python", "python3")
|
|
61
|
+
)
|
|
62
|
+
for name in names:
|
|
63
|
+
candidate = scripts / name
|
|
64
|
+
if candidate.is_file() and os.access(str(candidate), os.X_OK):
|
|
65
|
+
# Preserve the venv launcher path instead of resolving its
|
|
66
|
+
# symlink to the base interpreter. Python uses that path to
|
|
67
|
+
# locate the environment's adjacent pyvenv.cfg.
|
|
68
|
+
return str(candidate.absolute())
|
|
69
|
+
return str(Path(sys.executable).resolve())
|
|
70
|
+
|
|
71
|
+
def is_explicit_path(value: str) -> bool:
|
|
72
|
+
"""Determine whether command text explicitly addresses a filesystem path.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
value: Command name or path as entered after parsing.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
``True`` for absolute paths and values containing a platform path
|
|
79
|
+
separator. A bare filename is deliberately not a local path.
|
|
80
|
+
"""
|
|
81
|
+
path = Path(value)
|
|
82
|
+
separators = [separator for separator in (os.sep, os.altsep) if separator]
|
|
83
|
+
return path.is_absolute() or any(separator in value for separator in separators)
|
|
84
|
+
|
|
85
|
+
def _first_executable(candidates: Iterable[Optional[str]], environment=None) -> Optional[str]:
|
|
86
|
+
"""Return the first existing executable from candidate names or paths.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
candidates: Executable names, absolute paths, or missing values.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
The resolved executable path, or ``None`` when none can be used.
|
|
93
|
+
"""
|
|
94
|
+
for candidate in candidates:
|
|
95
|
+
if not candidate:
|
|
96
|
+
continue
|
|
97
|
+
discovered = shutil.which(candidate, path=(environment or os.environ).get("PATH"))
|
|
98
|
+
if discovered:
|
|
99
|
+
return discovered
|
|
100
|
+
path = Path(candidate).expanduser()
|
|
101
|
+
if path.is_file() and os.access(str(path), os.X_OK):
|
|
102
|
+
return str(path)
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
def discover_bash(environment=None) -> Optional[str]:
|
|
106
|
+
"""Discover Bash, including common Git for Windows installations.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
environment: Optional environment used for lookup.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
An executable path, or ``None`` when Bash is unavailable.
|
|
113
|
+
"""
|
|
114
|
+
values = os.environ if environment is None else environment
|
|
115
|
+
program_files = values.get("ProgramFiles")
|
|
116
|
+
program_files_x86 = values.get("ProgramFiles(x86)")
|
|
117
|
+
local_app_data = values.get("LOCALAPPDATA")
|
|
118
|
+
return _first_executable(
|
|
119
|
+
[
|
|
120
|
+
"bash",
|
|
121
|
+
str(Path(program_files) / "Git" / "bin" / "bash.exe")
|
|
122
|
+
if program_files
|
|
123
|
+
else None,
|
|
124
|
+
str(Path(program_files_x86) / "Git" / "bin" / "bash.exe")
|
|
125
|
+
if program_files_x86
|
|
126
|
+
else None,
|
|
127
|
+
str(Path(local_app_data) / "Programs" / "Git" / "bin" / "bash.exe")
|
|
128
|
+
if local_app_data
|
|
129
|
+
else None,
|
|
130
|
+
], values
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def discover_powershell(environment=None) -> Optional[str]:
|
|
134
|
+
"""Discover PowerShell Core or Windows PowerShell.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
environment: Optional environment used for lookup.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
An executable path, preferring cross-platform ``pwsh``, or ``None``.
|
|
141
|
+
"""
|
|
142
|
+
values = os.environ if environment is None else environment
|
|
143
|
+
system_root = values.get("SystemRoot")
|
|
144
|
+
windows_powershell = (
|
|
145
|
+
str(Path(system_root) / "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe")
|
|
146
|
+
if system_root
|
|
147
|
+
else None
|
|
148
|
+
)
|
|
149
|
+
return _first_executable(["pwsh", "powershell", windows_powershell], values)
|
|
150
|
+
|
|
151
|
+
def _read_shebang(script: Path) -> Optional[List[str]]:
|
|
152
|
+
"""Read an interpreter argument vector from a script's first line.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
script: Candidate script path.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Parsed shebang arguments, or ``None``.
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
with script.open("rb") as source:
|
|
162
|
+
first_line = source.readline(4097)
|
|
163
|
+
except OSError:
|
|
164
|
+
return None
|
|
165
|
+
if not first_line.startswith(b"#!") or len(first_line) > 4096:
|
|
166
|
+
return None
|
|
167
|
+
try:
|
|
168
|
+
return shlex.split(first_line[2:].decode("utf-8").strip(), posix=True) or None
|
|
169
|
+
except (UnicodeDecodeError, ValueError):
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
def _known_shebang_command(shebang: List[str], script: Path, environment=None) -> Optional[List[str]]:
|
|
173
|
+
"""Prepare a portable command for a recognized shebang interpreter.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
shebang: Parsed interpreter and arguments.
|
|
177
|
+
script: Script path.
|
|
178
|
+
environment: Optional lookup environment.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
Prepared argv, or ``None`` for an unknown interpreter.
|
|
182
|
+
"""
|
|
183
|
+
interpreter = Path(shebang[0]).name.lower()
|
|
184
|
+
interpreter_arguments = shebang[1:]
|
|
185
|
+
if interpreter in ("env", "env.exe") and len(interpreter_arguments) == 1:
|
|
186
|
+
interpreter = Path(interpreter_arguments[0]).name.lower()
|
|
187
|
+
interpreter_arguments = []
|
|
188
|
+
|
|
189
|
+
if interpreter in ("python", "python3", "python.exe", "python3.exe"):
|
|
190
|
+
return [active_python_executable(environment)] + interpreter_arguments + [str(script)]
|
|
191
|
+
if interpreter in ("bash", "bash.exe"):
|
|
192
|
+
bash = discover_bash(environment)
|
|
193
|
+
if bash is None:
|
|
194
|
+
raise RuntimeUnavailableError("Bash is required for {0}; install Bash or Git for Windows".format(script))
|
|
195
|
+
return [bash] + interpreter_arguments + [str(script)]
|
|
196
|
+
if interpreter in ("pwsh", "pwsh.exe", "powershell", "powershell.exe"):
|
|
197
|
+
powershell = discover_powershell(environment)
|
|
198
|
+
if powershell is None:
|
|
199
|
+
raise RuntimeUnavailableError("PowerShell is required for {0}; install pwsh or Windows PowerShell".format(script))
|
|
200
|
+
return [powershell] + interpreter_arguments + [str(script)]
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
def prepare_command(arguments: List[str], environment=None) -> List[str]:
|
|
204
|
+
"""Select an explicit interpreter for a recognized script file.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
arguments: Program or script path followed by arguments.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
A new direct-execution argument list. Non-script commands are returned
|
|
211
|
+
unchanged in a new list.
|
|
212
|
+
|
|
213
|
+
Raises:
|
|
214
|
+
RuntimeUnavailableError: If a Bash or PowerShell script is requested
|
|
215
|
+
and its real runtime cannot be discovered.
|
|
216
|
+
"""
|
|
217
|
+
if not arguments:
|
|
218
|
+
return []
|
|
219
|
+
script = Path(arguments[0])
|
|
220
|
+
if not is_explicit_path(arguments[0]):
|
|
221
|
+
# Bare names belong to PATH lookup. This prevents an untrusted file in
|
|
222
|
+
# the current directory from silently becoming executable shell input.
|
|
223
|
+
return list(arguments)
|
|
224
|
+
if not script.is_file():
|
|
225
|
+
return list(arguments)
|
|
226
|
+
|
|
227
|
+
shebang = _read_shebang(script)
|
|
228
|
+
if shebang is not None:
|
|
229
|
+
prepared = _known_shebang_command(shebang, script, environment)
|
|
230
|
+
if prepared is not None:
|
|
231
|
+
return prepared + arguments[1:]
|
|
232
|
+
return shebang + [str(script)] + arguments[1:]
|
|
233
|
+
|
|
234
|
+
suffix = script.suffix.lower()
|
|
235
|
+
if suffix == ".py":
|
|
236
|
+
return [active_python_executable(environment), str(script)] + arguments[1:]
|
|
237
|
+
if suffix == ".sh":
|
|
238
|
+
bash = discover_bash(environment)
|
|
239
|
+
if bash is None:
|
|
240
|
+
raise RuntimeUnavailableError("Bash is required for {0}; install Bash or Git for Windows".format(script))
|
|
241
|
+
return [bash, str(script)] + arguments[1:]
|
|
242
|
+
if suffix == ".ps1":
|
|
243
|
+
powershell = discover_powershell(environment)
|
|
244
|
+
if powershell is None:
|
|
245
|
+
raise RuntimeUnavailableError("PowerShell is required for {0}; install pwsh or Windows PowerShell".format(script))
|
|
246
|
+
return [powershell, "-NoProfile", "-File", str(script)] + arguments[1:]
|
|
247
|
+
return list(arguments)
|