-
Notifications
You must be signed in to change notification settings - Fork 31
implement revamped textual-gs-cli #597
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
c4bae
wants to merge
103
commits into
main
Choose a base branch
from
charles/textual-gs-cli
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 98 commits
Commits
Show all changes
103 commits
Select commit
Hold shift + click to select a range
996e480
Integrate gs building code for MacOS systems (#552) (#580)
c4bae ced0983
develop layout of textual cli, with logs, cli, cmds, and misc panels
c4bae 99168c8
shift input widget into cli panel
c4bae 8e80f2d
defined constructor for CmdButton class for button label customization
c4bae 2d4d3b9
display intro and intro prompt in cli panel
c4bae 8dde3c6
display input from input widget in CLI panel
c4bae 8601945
allow execution of commands (basic ones) with arguments
c4bae d545817
add data table widget
c4bae ac53891
fix cli crash from blank input
c4bae e92491e
remove direct printing to cli upon executing print_logs
c4bae ee278ab
run cli commands in threads to avoid blocking
c4bae 4938edb
allow the cli panel to be scrollable when text overflows
c4bae 86f526f
allow help commands to be entered into cli
c4bae e53bc09
change ui theme to orbital colors (red, blue, white)
c4bae 6c227c0
enable cmd button functionality
c4bae 35221f5
implement exit (KeyboardInterrupt alternative) method for print_logs …
c4bae 930f671
add textual to requirements.txt
c4bae 822201a
format files
c4bae f74176b
fix mypy errors
c4bae e5ca241
fix mypy errors
c4bae d547df0
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 7601300
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 7d81f63
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 31ce559
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae f422550
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 650074b
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 7781b4d
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 648387f
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae bff4cc9
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae c5c1ead
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae e213c4c
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 9797482
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae c436e82
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 6d11561
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 730f7a6
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae c68e610
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae f11303b
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae cdc5cc6
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 4035a68
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 6d29c71
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae f074884
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae da454c5
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae ad682cc
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 9c04b1a
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 0ef9a1e
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 39a6caa
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 1536cf7
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae acb61e9
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae ba3ecf3
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 628dea2
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 42e8398
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae a3607ab
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 5ff01a7
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 1a93453
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 196a794
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 5ea9517
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae b88160e
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae bbd5c1f
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae cbf47f0
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 4b23f40
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae fe1c58c
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae dd229d3
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae d3e331a
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 600deed
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 65dcbdc
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 534ad16
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 369d8ae
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 08029d6
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae cf23e0a
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 02a4db8
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae ad2fa87
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae f41b21d
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae cf0ad08
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae a85f6cf
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae e6faf21
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 53b6acc
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 565c5c4
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 032c98f
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 2f23030
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 971fbfd
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 99f0cf9
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae dbd9619
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 53407e6
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae bad6995
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 796e1d6
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae c200c3a
fix ruff formatting
c4bae acb56a4
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae e81f33c
flash "logs file not found" message in red
c4bae 854f11c
redesign layout and allow for auto scrolling in containers
c4bae f0bb79c
redesign layout and allow for auto scrolling in containers
c4bae 75b97dc
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 70aab1d
fix pre-commit test fails
c4bae 867ecdb
update docstring for cmdbutton class
c4bae b11f65b
implement requested changes from pr
c4bae fcf88aa
implement requested changes from pr
c4bae 24cf503
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae b50da83
remove function argument from poll
c4bae 02b2873
Merge branch 'main' into charles/textual-gs-cli
c4bae a2dfe9c
move cli flies into new directory
c4bae bd103d2
Merge branch 'charles/textual-gs-cli' of github.com:UWOrbital/OBC-fir…
c4bae 11f5f52
reformat file
c4bae 094e065
move cli.tcss file and update requirements.txt
c4bae 066f6b3
Merge branch 'main' into charles/textual-gs-cli
c4bae File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,319 @@ | ||
| import io | ||
| import sys | ||
| import threading | ||
| from collections.abc import Callable | ||
| from sys import argv | ||
| from typing import cast | ||
|
|
||
| from serial import Serial | ||
| from textual import on | ||
| from textual.app import App, ComposeResult | ||
| from textual.containers import HorizontalGroup, HorizontalScroll, ScrollableContainer, VerticalScroll | ||
| from textual.reactive import reactive | ||
| from textual.widget import Widget | ||
| from textual.widgets import Button, DataTable, Input, Label, Static | ||
|
|
||
| from gs.backend.ground_station_cli import GroundStationShell | ||
|
|
||
| if len(argv) == 2: | ||
| COM_PORT = argv[1] | ||
| shell = GroundStationShell(COM_PORT) | ||
|
|
||
|
|
||
| class CliPanel(ScrollableContainer): | ||
| """ | ||
| CLI panel class, handling command input and output displays | ||
| """ | ||
|
|
||
| cli_output = reactive("") | ||
|
|
||
| def __init__(self, *widgets: Widget, id: str | None = None) -> None: # noqa: A002 | ||
| """ | ||
| Initialize the CLI panel and set up output redirection | ||
| """ | ||
| super().__init__(*widgets, id=id) | ||
| self.shell = shell | ||
| self.cli_output_panel: Static | None = None | ||
| # Hold the original output stream | ||
| self.sys_stdout = sys.stdout | ||
|
|
||
| # Buf creates a StringIO instance, storing what is printed from the cli through a redirection of stdout | ||
| self.buffer = io.StringIO() | ||
|
|
||
| # Redirects the output stream (sys.stdout) to buffer, and anything printed will now be written to buffer | ||
| sys.stdout = self.buffer | ||
|
|
||
| # Redirects the output stream of the gs shell to buffer, allowing help descriptions to be displayed | ||
| self.shell.stdout = sys.stdout | ||
| # Redirects the "error" output stream of the gs shell to buffer | ||
| sys.stderr = sys.stdout | ||
|
|
||
| if self.shell.intro is not None: | ||
| print(self.shell.intro) | ||
|
|
||
| def on_mount(self) -> None: | ||
| """ | ||
| Set up periodic CLI output refresh and initialize the output panel | ||
| """ | ||
| self.output_refresh = self.set_interval(1 / 120, self.update_cli) | ||
| self.cli_output_panel = self.query_one("#cli-output-panel", Static) | ||
|
|
||
| # Buffer.getvalue() returns the contents of the string buffer as a str | ||
| self.cli_output = self.buffer.getvalue() | ||
|
|
||
| def on_unmount(self) -> None: | ||
| """ | ||
| Restore original output streams when the CLI panel is unmounted | ||
| """ | ||
| # Upon exitting the cli, restore all original output streams | ||
| sys.stdout = self.sys_stdout | ||
| sys.stderr = self.sys_stdout | ||
| self.shell.stdout = self.sys_stdout | ||
|
|
||
| def watch_cli_output(self, cli_output: str) -> None: | ||
| """ | ||
| Update the CLI output panel with the latest CLI output | ||
| """ | ||
| if self.cli_output_panel: | ||
| self.cli_output_panel.update(f"CLI - {COM_PORT}\n" + self.cli_output) | ||
|
|
||
| def update_cli(self) -> None: | ||
| """ | ||
| Refresh the CLI output from the buffer | ||
| """ | ||
| self.cli_output = self.buffer.getvalue() | ||
|
|
||
| # Use threads to ensure blocking commands don't block CLI | ||
| def run_cli_command_in_thread(self, cmd_function: Callable[[str], None], args: str) -> None: | ||
| """ | ||
| Run a CLI command in a separate thread to avoid blocking the UI | ||
| """ | ||
| thread = threading.Thread(target=cmd_function, args=(args,), daemon=True) | ||
| thread.start() | ||
|
|
||
| @on(Input.Submitted) | ||
| def submit_command(self) -> None: | ||
| """ | ||
| Handle command submission from the input widget and dispatch to the shell | ||
| """ | ||
| cli_input = self.query_one(Input) | ||
| print(f"(UW Orbital): {cli_input.value}") | ||
|
|
||
| # Splits the input into two parts: command name and args | ||
| command_parts = cli_input.value.strip().split(maxsplit=1) | ||
| try: | ||
| # Obtain function from gs shell instance based on inputted command | ||
| cmd_function = getattr(self.shell, f"do_{command_parts[0]}") | ||
| args = command_parts[1] if len(command_parts) > 1 and command_parts[1] is not None else "" | ||
|
|
||
| # Make special exception for "exit" command | ||
| if command_parts[0] == "exit": | ||
| self.app.exit() | ||
| return | ||
|
|
||
| # Change print_logs cmd button status to STOP if manually typed in | ||
| if command_parts[0] == "print_logs": | ||
| print("[yellow]Use print_logs button below CMDS to exit polling.[/yellow]") | ||
| # Query_one("#btn-print_logs") returns a widget, so type cast widget to button | ||
| btn = cast(Button, self.app.query_one("#btn-print_logs")) | ||
| btn.label = "STOP" | ||
|
|
||
| # Disable send_conn_request cmd btn if manually typed in | ||
| if command_parts[0] == "send_conn_request": | ||
| btn = cast(Button, self.app.query_one("#btn-send_conn_request")) | ||
| btn.disabled = True | ||
|
|
||
| self.run_cli_command_in_thread(cmd_function, args) | ||
|
|
||
| except (AttributeError, IndexError): | ||
| print(f"*** Unknown syntax: {cli_input.value}") | ||
|
|
||
| self.scroll_to_bottom() | ||
| self.query_one(Input).value = "" | ||
|
|
||
| def scroll_to_bottom(self) -> None: | ||
| """ | ||
| Auto-scroll to bottom of cli panel | ||
| """ | ||
| self.scroll_end() | ||
|
|
||
| def compose(self) -> ComposeResult: | ||
| """ | ||
| Compose the CLI panel widgets | ||
| """ | ||
| yield Static("", id="cli-output-panel") | ||
| yield Input(placeholder="Enter command here:", id="cli-input") | ||
|
|
||
|
|
||
| class CmdButton(HorizontalGroup): | ||
| """ | ||
| A horizontal group containing command buttons for the CLI | ||
| """ | ||
|
|
||
| def __init__(self, cmdname: str) -> None: | ||
| """ | ||
| Initialize the command button with the given command name | ||
| """ | ||
| super().__init__() | ||
| self.cmdname = cmdname | ||
| self.shell = shell | ||
|
|
||
| def on_button_pressed(self, event: Button.Pressed) -> None: | ||
| """ | ||
| Handle the event when the command button is pressed | ||
| """ | ||
| cmd_function = getattr(self.shell, f"do_{self.cmdname}") | ||
| args = "" | ||
|
|
||
| thread = threading.Thread(target=cmd_function, args=(args,), daemon=True) | ||
|
|
||
| if event.button.label != "STOP": | ||
| print(f"(UW Orbital): {self.cmdname}") | ||
| thread.start() | ||
|
|
||
| if self.cmdname != "print_logs": | ||
| event.button.disabled = True | ||
|
|
||
| if self.cmdname == "print_logs": | ||
| if event.button.label == "Run": | ||
| print("[yellow]Use print_logs button below CMDS to exit polling.[/yellow]") | ||
| event.button.label = "STOP" | ||
| else: | ||
| event.button.label = "Run" | ||
| self.shell.stop_printing = True | ||
|
|
||
| def compose(self) -> ComposeResult: | ||
| """ | ||
| Compose the command button and its label | ||
| """ | ||
| yield Label(f"{self.cmdname}", id="button-label") | ||
| yield Button("Run", id=f"btn-{self.cmdname}") | ||
|
|
||
|
|
||
| class TimeTaggedLogs(HorizontalScroll): | ||
| """ | ||
| A horizontal scrollable widget displaying time-tagged command logs | ||
| """ | ||
|
|
||
| def __init__(self, *widgets: Widget, id: str | None = None) -> None: # noqa: A002 | ||
| """ | ||
| Initialize the time-tagged logs table with sample data | ||
| """ | ||
| super().__init__(*widgets, id=id) | ||
| self.rows = [ | ||
| ("CMD", "Time", "Cate1", "Cate2"), | ||
| ("ABC", "ABC", "ABC", "ABC"), | ||
| ("ABC", "ABC", "ABC", "ABC"), | ||
| ("ABC", "ABC", "ABC", "ABC"), | ||
| ("ABC", "ABC", "ABC", "ABC"), | ||
| ("ABC", "ABC", "ABC", "ABC"), | ||
| ("ABC", "ABC", "ABC", "ABC"), | ||
| ("ABC", "ABC", "ABC", "ABC"), | ||
| ("ABC", "ABC", "ABC", "ABC"), | ||
| ("ABC", "ABC", "ABC", "ABC"), | ||
| ("ABC", "ABC", "ABC", "ABC"), | ||
| ("ABC", "ABC", "ABC", "ABC"), | ||
| ] | ||
|
|
||
| def compose(self) -> ComposeResult: | ||
| """ | ||
| Compose the time-tagged logs table and label | ||
| """ | ||
| yield Label("TIME TAGGED CMDS") | ||
| yield DataTable() | ||
|
|
||
| def on_mount(self) -> None: | ||
| """ | ||
| Set up columns and rows for the time-tagged logs table | ||
| """ | ||
| table = self.query_one(DataTable) | ||
| table.add_columns(*self.rows[0]) | ||
| table.add_rows(self.rows[1:]) | ||
|
|
||
|
|
||
| class LogsPanel(Static): | ||
| """ | ||
| A static widget for displaying logs from a file | ||
| """ | ||
|
|
||
| logs = reactive("") | ||
|
|
||
| def on_mount(self) -> None: | ||
| """ | ||
| Set up periodic log refresh for the logs panel | ||
| """ | ||
| self.logs_refresh = self.set_interval(1 / 60, self.update_logs) | ||
|
|
||
| def update_logs(self) -> None: | ||
| """ | ||
| Read and update logs from the log file | ||
| """ | ||
| try: | ||
| with open("gs/backend/logs.log") as logs: | ||
| self.logs = logs.read() | ||
| except FileNotFoundError: | ||
| self.update("[red]Logs file not found. Try running interface from root directory (OBC-Firmware)[/red]") | ||
|
|
||
| def watch_logs(self, logs: str) -> None: | ||
| """ | ||
| Update logs panel with the latest logs | ||
| """ | ||
| self.update("LOGS\n\n" + self.logs) | ||
| self.scroll_to_bottom() | ||
|
|
||
| def scroll_to_bottom(self) -> None: | ||
| """ | ||
| Auto-scroll to bottom of logs panel | ||
| """ | ||
| scrollable_container = self.app.query_one("#logs") | ||
| if scrollable_container: | ||
| scrollable_container.scroll_end() | ||
|
|
||
|
|
||
| class CLIWindow(App[None]): | ||
| """ | ||
| Main Textual application window for the CLI interface | ||
| """ | ||
|
|
||
| CSS_PATH = "cli.tcss" | ||
|
|
||
| def on_mount(self) -> None: | ||
| """ | ||
| Set the theme when application mounts | ||
| """ | ||
| self.theme = "dracula" | ||
|
|
||
| def compose(self) -> ComposeResult: | ||
| """ | ||
| Compose the main application layout with all panels and widgets | ||
| """ | ||
| yield ScrollableContainer(LogsPanel("LOGS"), can_focus=True, id="logs") | ||
| yield CliPanel(id="cli") | ||
| yield VerticalScroll( | ||
| Static("CMDS"), | ||
| CmdButton("print_logs"), | ||
| CmdButton("send_conn_request"), | ||
| CmdButton("start_logging"), | ||
| id="cmd-panel", | ||
| ) | ||
| yield TimeTaggedLogs(id="timetagged") | ||
|
|
||
|
|
||
| def main() -> None: | ||
| """ | ||
| Usage: Entry point for the CLI application; opens the serial port at the com port and runs the ground station cli. | ||
| """ | ||
| if len(argv) != 2: | ||
| print("One argument needed: Com Port") | ||
| return | ||
|
|
||
| ser = Serial(COM_PORT) | ||
c4bae marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| print("Comm port set to: " + str(ser.name)) | ||
| ser.close() | ||
|
|
||
| app = CLIWindow() | ||
| app.run() | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
Usageconvention was referring to the error message when the arguments don't match. The original docstring was fine. Also, provide an exit code if there was a failure.