Skip to content

Commit

Permalink
add pipestatus to ShellCommandResult
Browse files Browse the repository at this point in the history
  • Loading branch information
adamhl8 committed Mar 20, 2023
1 parent 74311a1 commit e20b0fe
Show file tree
Hide file tree
Showing 6 changed files with 187 additions and 106 deletions.
30 changes: 17 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,11 @@ Also, users of [fish](https://github.com/fish-shell/fish-shell) might know that

### Similar Projects

- [zx](https://github.com/google/zx)
- [zxpy](https://github.com/tusharsadhwani/zxpy)
- [shellpy](https://github.com/lamerman/shellpy)
- [plumbum](https://github.com/tomerfiliba/plumbum)

ShellRunner is very similar to zxpy but aims to be more simple in its implementation and has a focus on adding safety to scripts.
ShellRunner is very similar to zxpy and shellpy but aims to be more simple in its implementation and has a focus on adding safety to scripts.

## Advanced Usage

Expand All @@ -111,30 +110,35 @@ X("echo hello | string match hello")

### Shell Command Result

`X` returns a `ShellCommandResult` (`NamedTuple`) containing the output of the command and a list of its exit status(es), accessed via `.out` and `.status` respectively.
`X` returns a `ShellCommandResult` (`NamedTuple`) containing the following:

- `out: str`: The `stdout` and `stderr` of the command.
- `status: int`: The overall exit status of the command. If the command was a pipeline that failed, `status` will be equal to the status of the last failing command (like bash's `pipefail`).
- `pipestatus: list[int]`: A list of statuses for each command in the pipeline.

```python
result = X("echo hello")
print(f'Got output "{result.out}" with exit status {result.status}')
print(f'Got output "{result.out}" with exit status {result.status} / {result.pipestatus}')
# Or unpack
output, status = X("echo hello")
output, status, pipestatus = X("echo hello")
# output = "hello"
# status = [0]
# status = 0
# pipestatus = [0]
```

`status` will contain the exit status of every command in a pipeline:

```python
statuses = X("echo hello | grep hello").status
# statuses = [0, 0]
result = X("(exit 1) | (exit 2) | echo hello")
# result.out = "hello"
# result.status = 2
# result.pipestatus = [1, 2, 0]
```

If using a shell that does not support `PIPESTATUS` such as `sh`, you will only ever get the status of the last command in a pipeline. **This also means that in this case ShellRunner cannot detect if an error occured in a pipeline:**

```python
status = X("grep hello /non/existent/file | tee new_file").status
# if invoked with e.g. bash: ShellCommandError is raised
# if invoked with sh: No exception is raised and status = [0]
result = X("(exit 1) | echo hello")
# if invoked with bash: ShellCommandError is raised, status = 1, pipestatus = [1, 0]
# if invoked with sh: No exception is raised, status = 0, pipestatus = [0]
```

### Exception Handling
Expand Down
40 changes: 20 additions & 20 deletions pdm.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "python-shellrunner"
version = "0.2.0"
version = "0.3.0"
description = "Write safe shell scripts in Python."
authors = [
{name = "adamhl8", email = "[email protected]"},
Expand Down Expand Up @@ -29,7 +29,7 @@ keywords = ["shell", "scripting", "bash", "zsh", "fish"]
[tool.pdm.dev-dependencies]
dev = [
"black>=23.1.0",
"ruff>=0.0.254",
"ruff>=0.0.257",
"pytest>=7.2.2",
"types-psutil>=5.9.5.10",
"pyroma>=4.2",
Expand Down
25 changes: 18 additions & 7 deletions src/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,46 @@

class ShellCommandResult(NamedTuple):
out: str
status: list[int]
status: int
pipestatus: list[int]


class ShellCommandError(ChildProcessError):
class ShellRunnerError(RuntimeError):
pass


class ShellCommandError(ShellRunnerError):
def __init__(self, message: str, result: ShellCommandResult):
super().__init__(message)
self.out = result.out
self.status = result.status
self.pipestatus = result.pipestatus


class ShellResolutionError(ShellRunnerError):
pass


class EnvironmentVariableError(ValueError):
class EnvironmentVariableError(ShellRunnerError):
pass


# Returns the full path of parent process/shell. That way commands are executed using the same shell that invoked this script.
# TODO test if executable is a shell, if not default to bash
def get_parent_shell_path() -> Path:
try:
return Path(f"/proc/{os.getppid()}/exe").readlink().resolve(strict=True)
except:
print("An error occured when trying to get the path of the parent shell:")
raise
except Exception as e: # noqa: BLE001
message = "An error occured when trying to get the path of the parent shell."
raise ShellResolutionError(message) from e


# Returns the full path of a given path or executable name. e.g. "/bin/bash" or "bash"
def resolve_shell_path(shell: str) -> Path:
which_shell = which(shell, os.X_OK)
if which_shell is None:
message = f'Unable to resolve the path to the executable: "{shell}". It is either not on your PATH or the specified file is not executable.'
raise FileNotFoundError(message)
raise ShellResolutionError(message)
return Path(which_shell).resolve(strict=True)


Expand Down
Loading

0 comments on commit e20b0fe

Please sign in to comment.