Skip to content

Commit

Permalink
feat: run commands in a shell to support shell syntax
Browse files Browse the repository at this point in the history
Also remove --verbose mode as it isn't needed anymore
  • Loading branch information
Danthewaann committed Mar 10, 2024
1 parent 62b8904 commit 192ac40
Show file tree
Hide file tree
Showing 7 changed files with 77 additions and 297 deletions.
20 changes: 13 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,30 @@ pip install pyallel
Once installed, you can run `pyallel` to see usage information, like so:

```
usage: pyallel [-h] [-t] [-n] [-V] [-v] [--colour {yes,no,auto}] [commands ...]
usage: pyallel [-h] [-t] [-n] [-V] [--colour {yes,no,auto}] [commands ...]
Run and handle the output of multiple executables in pyallel (as in parallel)
positional arguments:
commands list of quoted commands to run e.g "mypy ." "black ."
can provide environment variables to each command like so:
each command is executed inside a shell, so shell syntax is supported as
if you were running the command directly in a shell, some examples are below:
"MYPY_FORCE_COLOR=1 mypy ." <- provide environment variables
"mypy | tee -a mypy.log" <- use pipes to redirect output
"cat > test.log < other.log" <- use input and output redirection
"mypy .; pytest ." <- run commands one at a time in sequence
"echo \$SHELL" or "\$(echo mypy .)" <- expand variables and commands to evaluate (must be escaped)
"pytest . && mypy . || echo failed!" <- use AND (&&) and OR (||) to run commands conditionally
"MYPY_FORCE_COLOR=1 mypy ."
options:
-h, --help show this help message and exit
-t, --no-timer don't time how long each command is taking
-n, --non-interactive
run in non-interactive mode
-V, --verbose run in verbose mode
-v, --version print version and exit
-V, --version print version and exit
--colour {yes,no,auto}
colour terminal output, defaults to "auto"
```
Expand Down Expand Up @@ -82,8 +88,8 @@ python -m venv .venv && source .venv/bin/activate && pip install . -r requiremen

## TODOs

- [ ] Maybe add support for allowing commands to contain shell idiom's (such as piping
e.g. `echo hi | tee test.log`)
- [ ] Maybe add support to allow the user to provide stdin for commands that request it
(such as a REPL)
- [ ] Add custom parsing of command output to support filtering for errors (like vim's
`errorformat`)
- [ ] Allow list of files to be provided to supply as input arguments to each command
Expand Down
3 changes: 0 additions & 3 deletions src/pyallel/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,12 @@ def main_loop(
colours: Colours,
interactive: bool = False,
timer: bool = False,
verbose: bool = False,
) -> int:
process_group = ProcessGroup.from_commands(
*commands,
colours=colours,
interactive=interactive,
timer=timer,
verbose=verbose,
)

return process_group.stream()
Expand Down Expand Up @@ -57,7 +55,6 @@ def run(*args: str) -> int:
colours=colours,
interactive=interactive,
timer=parsed_args.timer,
verbose=parsed_args.verbose,
)
except InvalidExecutableErrors as e:
exit_code = 1
Expand Down
21 changes: 10 additions & 11 deletions src/pyallel/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ class Arguments:
commands: list[str]
interactive: bool
timer: bool
verbose: bool
version: bool

def __repr__(self) -> str:
Expand All @@ -20,11 +19,18 @@ def __repr__(self) -> str:
return msg


COMMANDS_HELP = """list of quoted commands to run e.g "mypy ." "black ."
COMMANDS_HELP = r"""list of quoted commands to run e.g "mypy ." "black ."
can provide environment variables to each command like so:
each command is executed inside a shell, so shell syntax is supported as
if you were running the command directly in a shell, some examples are below:
"MYPY_FORCE_COLOR=1 mypy ." <- provide environment variables
"mypy | tee -a mypy.log" <- use pipes to redirect output
"cat > test.log < other.log" <- use input and output redirection
"mypy .; pytest ." <- run commands one at a time in sequence
"echo \$SHELL" or "\$(echo mypy .)" <- expand variables and commands to evaluate (must be escaped)
"pytest . && mypy . || echo failed!" <- use AND (&&) and OR (||) to run commands conditionally
"MYPY_FORCE_COLOR=1 mypy ."
"""


Expand Down Expand Up @@ -57,13 +63,6 @@ def create_parser() -> ArgumentParser:
)
parser.add_argument(
"-V",
"--verbose",
help="run in verbose mode",
action="store_true",
default=False,
)
parser.add_argument(
"-v",
"--version",
help="print version and exit",
action="store_true",
Expand Down
49 changes: 6 additions & 43 deletions src/pyallel/process.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
from __future__ import annotations

import os
import shlex
import shutil
import signal
import subprocess
import tempfile
Expand Down Expand Up @@ -39,7 +36,6 @@ class ProcessGroup:
processes: list[Process]
interactive: bool = False
timer: bool = False
verbose: bool = False
output: dict[UUID, list[str]] = field(default_factory=lambda: defaultdict(list))
process_lines: list[int] = field(default_factory=list)
completed_processes: set[UUID] = field(default_factory=set)
Expand Down Expand Up @@ -99,7 +95,7 @@ def stream_non_interactive(self) -> int:
running_process is None
and process.id not in self.completed_processes
):
output += self._get_command_status(process, verbose=self.verbose)
output += self._get_command_status(process)
output += "\n"
running_process = process
elif running_process is not process:
Expand Down Expand Up @@ -138,7 +134,6 @@ def stream_non_interactive(self) -> int:
output += self._get_command_status(
process,
passed=process.return_code() == 0,
verbose=self.verbose,
timer=self.timer,
)
output += f"\n{self.colours.dim_on}=>{self.colours.dim_off} \n"
Expand Down Expand Up @@ -189,7 +184,6 @@ def _get_command_status(
process: Process,
icon: str | None = None,
passed: bool | None = None,
verbose: bool = False,
timer: bool = False,
) -> str:
if passed is True:
Expand All @@ -207,12 +201,7 @@ def _get_command_status(
if not icon:
msg += "..."

output = f"{self.colours.dim_on}=>{self.colours.dim_off} {self.colours.white_bold}[{self.colours.reset_colour}{self.colours.blue_bold}{process.name}"

if verbose:
output += f" {' '.join(process.args)}"

output += f"{self.colours.reset_colour}{self.colours.white_bold}]{self.colours.reset_colour}{colour} {msg} {icon}{self.colours.reset_colour}"
output = f"{self.colours.dim_on}=>{self.colours.dim_off} {self.colours.white_bold}[{self.colours.reset_colour}{self.colours.blue_bold}{process.command}{self.colours.reset_colour}{self.colours.white_bold}]{self.colours.reset_colour}{colour} {msg} {icon}{self.colours.reset_colour}"

if timer:
end = process.end
Expand Down Expand Up @@ -240,7 +229,6 @@ def from_commands(
colours: Colours,
interactive: bool = False,
timer: bool = False,
verbose: bool = False,
) -> ProcessGroup:
processes: list[Process] = []
errors: list[InvalidExecutableError] = []
Expand All @@ -258,7 +246,6 @@ def from_commands(
processes=processes,
interactive=interactive,
timer=timer,
verbose=verbose,
colours=colours,
)

Expand Down Expand Up @@ -293,15 +280,13 @@ def complete_output(self, tail: int = 20, all: bool = False) -> str:
output += self._get_command_status(
process,
passed=process.return_code() == 0,
verbose=self.verbose,
timer=self.timer,
)
output += "\n"
else:
output += self._get_command_status(
process,
icon=constants.ICONS[self.icon],
verbose=self.verbose,
timer=self.timer,
)
output += "\n"
Expand Down Expand Up @@ -339,9 +324,7 @@ def complete_output(self, tail: int = 20, all: bool = False) -> str:
@dataclass
class Process:
id: UUID = field(repr=False, compare=False)
name: str
args: list[str] = field(default_factory=list)
env: dict[str, str] = field(default_factory=dict)
command: str
start: float = 0.0
end: float = 0.0
_fd: BinaryIO | None = field(init=False, repr=False, compare=False, default=None)
Expand All @@ -354,11 +337,11 @@ def run(self) -> None:
fd, fd_name = tempfile.mkstemp()
self._fd = open(fd_name, "rb")
self._process = subprocess.Popen(
[self.name, *self.args],
self.command,
stdin=subprocess.DEVNULL,
stdout=fd,
stderr=subprocess.STDOUT,
env=self.env,
shell=True,
)

def __del__(self) -> None:
Expand Down Expand Up @@ -403,24 +386,4 @@ def wait(self) -> int:

@classmethod
def from_command(cls, command: str) -> Process:
env = os.environ.copy()
args = command.split()

parsed_args: list[str] = []
for arg in args:
if "=" in arg:
name, env_value = arg.split("=")
env[name] = env_value
else:
parsed_args.append(arg)

if not shutil.which(parsed_args[0]):
raise InvalidExecutableError(parsed_args[0])

str_args = shlex.split(" ".join(parsed_args[1:]))
return cls(
id=uuid4(),
name=parsed_args[0],
args=str_args,
env=env,
)
return cls(id=uuid4(), command=command)
6 changes: 0 additions & 6 deletions tests/assets/test_process_interrupt_with_trapped_output.sh

This file was deleted.

Loading

0 comments on commit 192ac40

Please sign in to comment.