Repository: golemfactory/dapp-manager (language: python)

Issue:
Rename yagna_dapp_manager to dapp_manager
Resolves #37 

Also adds basic docs to public CLI commands. These docs are visible as help text for the commands.


Discussion / hints:

Fix patch:
diff --git a/README.md b/README.md
index de33575..4135788 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,30 @@
 ### YAGNA DAPP MANAGER
 
 Manage a dapp.
+
+### Shell completion
+This program supports shell completion for all of its commands, as well as existing dApp IDs (where applicable).
+
+To enable completion, use the `autocomplete` command with your shell of choice:
+* **bash**:
+    ```
+    $ dapp-manager autocomplete bash
+    ```
+
+* **zsh**:
+    ```
+    $ dapp-manager autocomplete zsh
+    ```
+
+* **fish**:
+    ```
+    $ dapp-manager autocomplete fish
+    ```
+
+
+The completion functions are defined in `dapp_manager/autocomplete/scripts`.
+
+Should the entrypoint name ever change, those files will need to be updated as well.
+
+**WARNING** Completion will **NOT WORK** when `autocomplete` is invoked with `python -m dapp_manager`.
+Only the installed entrypoint (i.e. `dapp-manager`) is supported. To have it available, run `poetry install`.
diff --git a/cli_sample_usage.sh b/cli_sample_usage.sh
index b9191ca..e5d1a78 100755
--- a/cli_sample_usage.sh
+++ b/cli_sample_usage.sh
@@ -1,12 +1,12 @@
 function dm () {
-    python3 -m yagna_dapp_manager "$@"
+    python3 -m dapp_manager "$@"
 }
 
 echo "1. Current apps"
 dm list
 
 echo "What about app 'no_such_app'?"
-dm raw-state --app-id no_such_app
+dm read state no_such_app
 echo "Exit code: $?"
 
 APP_ID_1=$(DAPP_RUNNER_EXEC=tests/assets/mock_dapp_runner.py dm start --config ttt.yml zzz.yml)
@@ -24,40 +24,40 @@ dm list
 sleep 1
 
 echo "6. Print state file for $APP_ID_1"
-dm raw-state --app-id $APP_ID_1
+dm read state $APP_ID_1
 
 echo "7. Print data file for $APP_ID_1"
-dm raw-data --app-id $APP_ID_1
+dm read data $APP_ID_1
 
 sleep 2
 
 echo "8. Print state file for $APP_ID_1"
-dm raw-state --app-id $APP_ID_1
+dm read state $APP_ID_1
 
 echo "9. Print data file for $APP_ID_1"
-dm raw-data --app-id $APP_ID_1
+dm read data $APP_ID_1
 
 echo "10. Print data file for $APP_ID_2"
-dm raw-data --app-id $APP_ID_2
+dm read data $APP_ID_2
 
 echo "11. Stopping $APP_ID_1 with default timeout (should succeed -> app_id printed)"
-dm stop --app-id $APP_ID_1
+dm stop $APP_ID_1
 
-echo "12. Stopping $APP_ID_2 with short   timeout (should fail -> no print)"
-dm stop --app-id $APP_ID_2 --timeout 1
+echo "12. Stopping $APP_ID_2 with short timeout (should fail -> no print)"
+dm stop --timeout 1 $APP_ID_2
 
 echo "13. Killing  $APP_ID_2"
-dm kill --app-id $APP_ID_2
+dm kill $APP_ID_2
 
-echo "14. Check raw-state of $APP_ID_1 without --no-ensure-alive"
-dm raw-state --app-id $APP_ID_1
+echo "14. Check read state of $APP_ID_1 without --no-ensure-alive"
+dm read state $APP_ID_1
 echo "Exit code: $?"
 
-echo "15. Check raw-state of $APP_ID_1 with    --no-ensure-alive"
-dm raw-state --app-id $APP_ID_1 --no-ensure-alive
+echo "15. Check read state of $APP_ID_1 with --no-ensure-alive"
+dm read state --no-ensure-alive $APP_ID_1
 
-echo "16. Check raw-state of $APP_ID_2 with    --no-ensure-alive"
-dm raw-state --app-id $APP_ID_2 --no-ensure-alive
+echo "16. Check read state of $APP_ID_2 with --no-ensure-alive"
+dm read state --no-ensure-alive $APP_ID_2 
 
 echo "17. Check app list again"
 dm list
diff --git a/dapp_manager/__init__.py b/dapp_manager/__init__.py
new file mode 100644
index 0000000..c82af17
--- /dev/null
+++ b/dapp_manager/__init__.py
@@ -0,0 +1,2 @@
+from dapp_manager.dapp_manager import DappManager
+from dapp_manager.exceptions import UnknownApp
diff --git a/yagna_dapp_manager/__main__.py b/dapp_manager/__main__.py
similarity index 54%
rename from yagna_dapp_manager/__main__.py
rename to dapp_manager/__main__.py
index ec1ddd6..3b0f5db 100644
--- a/yagna_dapp_manager/__main__.py
+++ b/dapp_manager/__main__.py
@@ -5,16 +5,18 @@
 
 import click
 
-from yagna_dapp_manager import DappManager
-from yagna_dapp_manager.exceptions import DappManagerException
+from dapp_manager import DappManager
+from dapp_manager.autocomplete import install_autocomplete
+from dapp_manager.exceptions import DappManagerException
+
+
+def _app_id_autocomplete(_ctx, _param, incomplete):
+    return [app_id for app_id in DappManager.list() if app_id.startswith(incomplete)]
 
 
 def _with_app_id(wrapped_func):
-    wrapped_func = click.option(
-        "--app-id",
-        type=str,
-        required=True,
-        help="ID of an existing distributed application.",
+    wrapped_func = click.argument(
+        "app-id", type=str, shell_complete=_app_id_autocomplete
     )(wrapped_func)
     return wrapped_func
 
@@ -60,6 +62,7 @@ def _cli():
 )
 @_capture_api_exceptions
 def start(descriptors: Tuple[Path], *, config: Path):
+    """Start a new app using the provided descriptor and config files."""
     dapp = DappManager.start(*descriptors, config=config)
     print(dapp.app_id)
 
@@ -67,6 +70,10 @@ def start(descriptors: Tuple[Path], *, config: Path):
 @_cli.command()
 @_capture_api_exceptions
 def list():
+    """List known app IDs (both active and dead).
+
+    The results are sorted by the apps' creation time.
+    """
     app_ids = DappManager.list()
     if app_ids:
         print("\n".join(app_ids))
@@ -75,6 +82,10 @@ def list():
 @_cli.command()
 @_capture_api_exceptions
 def prune():
+    """Remove data for non-running apps.
+
+    This removes all data related to those apps, including logs, state etc.
+    """
     app_ids = DappManager.prune()
     if app_ids:
         print("\n".join(app_ids))
@@ -91,6 +102,11 @@ def prune():
 @_with_app_id
 @_capture_api_exceptions
 def stop(*, app_id: str, timeout: int):
+    """Stop the given app gracefully.
+
+    Requests a stop from the given app through a SIGINT.
+    Optionally, a timeout (in seconds) may be given with --timeout flag.
+    """
     dapp = DappManager(app_id)
     if dapp.stop(timeout):
         print(app_id)
@@ -100,25 +116,39 @@ def stop(*, app_id: str, timeout: int):
 @_with_app_id
 @_capture_api_exceptions
 def kill(*, app_id):
+    """Stop the given app forcibly.
+
+    Stops the app's process using SIGKILL.
+    """
     dapp = DappManager(app_id)
     dapp.kill()
     print(app_id)
 
 
-@_cli.command()
+@_cli.group()
+def read():
+    """Read data stream from the given app."""
+    # this function serves only to add a CLI command group
+    # and so it doesn't need any body code
+    pass
+
+
+@read.command()
 @_with_app_id
 @_capture_api_exceptions
 @_with_ensure_alive
-def raw_state(*, app_id, ensure_alive):
+def state(*, app_id, ensure_alive):
+    """Read the state stream of the given app."""
     dapp = DappManager(app_id)
     print(dapp.read_file("state", ensure_alive))
 
 
-@_cli.command()
+@read.command()
 @_with_app_id
 @_capture_api_exceptions
 @_with_ensure_alive
-def raw_data(*, app_id, ensure_alive):
+def data(*, app_id, ensure_alive):
+    """Read the data stream of the given app."""
     dapp = DappManager(app_id)
     print(dapp.read_file("data", ensure_alive))
 
@@ -128,6 +158,7 @@ def raw_data(*, app_id, ensure_alive):
 @_capture_api_exceptions
 @_with_ensure_alive
 def log(*, app_id, ensure_alive):
+    """Read the log stream of a given app."""
     dapp = DappManager(app_id)
     print(dapp.read_file("log", ensure_alive))
 
@@ -137,6 +168,7 @@ def log(*, app_id, ensure_alive):
 @_capture_api_exceptions
 @_with_ensure_alive
 def stdout(*, app_id, ensure_alive):
+    """Read the stdout of a given app."""
     dapp = DappManager(app_id)
     print(dapp.read_file("stdout", ensure_alive))
 
@@ -146,9 +178,40 @@ def stdout(*, app_id, ensure_alive):
 @_capture_api_exceptions
 @_with_ensure_alive
 def stderr(*, app_id, ensure_alive):
+    """Read the stderr of a given app."""
     dapp = DappManager(app_id)
     print(dapp.read_file("stderr", ensure_alive))
 
 
-if __name__ == "__main__":
+@_cli.command()
+@click.argument("shell", type=click.Choice(["bash", "fish", "zsh"]))
+@click.option(
+    "--path",
+    "-p",
+    type=Path,
+    default=None,
+    help="Path to the file to which the shell completion function should be added.",
+)
+def autocomplete(shell: str, path: Path):
+    """Enable CLI shell completion for the given shell.
+
+    This command works by appending a pre-defined piece of shell code to the user's
+    shell configuration file.
+
+    The default target file will depend on the selected shell type (bash, fish or zsh):
+        - bash: `~/.bashrc`
+        - fish: `~/.config/fish/completions/{script_name}.fish`
+        - zsh: `~/.zshrc`
+    Use the `--path` flag to override the default target file.
+
+    The command does nothing if the target file already contains the completion code.
+    """
+    install_autocomplete(shell, path)
+
+
+def main():
     _cli()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/dapp_manager/autocomplete/__init__.py b/dapp_manager/autocomplete/__init__.py
new file mode 100644
index 0000000..b331eb5
--- /dev/null
+++ b/dapp_manager/autocomplete/__init__.py
@@ -0,0 +1,39 @@
+from pathlib import Path
+
+import appdirs
+import click
+
+# Entrypoint aka binary name aka script name
+# Entrypoint must match the name defined in pyproject.toml under
+# [tool.poetry.scripts] section
+ENTRYPOINT = "dapp-manager"
+COMPLETION_FUNC_NAME = f"{ENTRYPOINT}_completion"
+SCRIPTS_DIR = Path(__file__).parent / "scripts"
+
+DEFAULT_SHELL_FILES = {
+    "bash": Path.home() / ".bashrc",
+    "fish": Path(appdirs.user_config_dir("fish"))
+    / "completions"
+    / f"{ENTRYPOINT}.fish",
+    "zsh": Path.home() / ".zshrc",
+}
+
+
+def install_autocomplete(shell: str, path: Path = None) -> None:
+    # File containing the shell-specific completion script
+    script_file = SCRIPTS_DIR / f".{ENTRYPOINT}.{shell}"
+    target_file = path or DEFAULT_SHELL_FILES[shell]
+
+    # Create the target file if it doesn't already exist
+    target_file.touch(exist_ok=True)
+
+    # Check if the target file already contains the completion script
+    with target_file.open() as f:
+        if COMPLETION_FUNC_NAME in target_file.read_text():
+            click.echo(f"Completion for {shell} already present in: {target_file}")
+            return
+
+    with target_file.open("a") as f:
+        f.write(script_file.read_text())
+
+    click.echo(f"Completion for {shell} installed to: {target_file}")
diff --git a/dapp_manager/autocomplete/scripts/.dapp-manager.bash b/dapp_manager/autocomplete/scripts/.dapp-manager.bash
new file mode 100644
index 0000000..051ecd4
--- /dev/null
+++ b/dapp_manager/autocomplete/scripts/.dapp-manager.bash
@@ -0,0 +1,26 @@
+
+_dapp_manager_completion() {
+    local IFS=$'\n'
+    local response
+
+    response=$(env COMP_WORDS="${COMP_WORDS[*]}" COMP_CWORD=$COMP_CWORD _DAPP_MANAGER_COMPLETE=bash_complete $1)
+
+    for completion in $response; do
+        IFS=',' read type value <<< "$completion"
+
+        if [[ $type == 'dir' ]]; then
+            COMREPLY=()
+            compopt -o dirnames
+        elif [[ $type == 'file' ]]; then
+            COMREPLY=()
+            compopt -o default
+        elif [[ $type == 'plain' ]]; then
+            COMPREPLY+=($value)
+        fi
+    done
+
+    return 0
+}
+
+complete -o nosort -F _dapp_manager_completion dapp-manager
+
diff --git a/dapp_manager/autocomplete/scripts/.dapp-manager.fish b/dapp_manager/autocomplete/scripts/.dapp-manager.fish
new file mode 100644
index 0000000..ca16565
--- /dev/null
+++ b/dapp_manager/autocomplete/scripts/.dapp-manager.fish
@@ -0,0 +1,23 @@
+
+function _dapp_manager_completion;
+    set -l response;
+
+    for value in (env _DAPP_MANAGER_COMPLETE=fish_complete COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t) dapp-manager);
+        set response $response $value;
+    end;
+
+    for completion in $response;
+        set -l metadata (string split "," $completion);
+
+        if test $metadata[1] = "dir";
+            __fish_complete_directories $metadata[2];
+        else if test $metadata[1] = "file";
+            __fish_complete_path $metadata[2];
+        else if test $metadata[1] = "plain";
+            echo $metadata[2];
+        end;
+    end;
+end;
+
+complete --no-files --command dapp-manager --arguments "(_dapp_manager_completion)";
+
diff --git a/dapp_manager/autocomplete/scripts/.dapp-manager.zsh b/dapp_manager/autocomplete/scripts/.dapp-manager.zsh
new file mode 100644
index 0000000..f9dbf27
--- /dev/null
+++ b/dapp_manager/autocomplete/scripts/.dapp-manager.zsh
@@ -0,0 +1,36 @@
+
+#compdef dapp-manager
+
+_dapp_manager_completion() {
+    local -a completions
+    local -a completions_with_descriptions
+    local -a response
+    (( ! $+commands[dapp-manager] )) && return 1
+
+    response=("${(@f)$(env COMP_WORDS="${words[*]}" COMP_CWORD=$((CURRENT-1)) _DAPP_MANAGER_COMPLETE=zsh_complete dapp-manager)}")
+
+    for type key descr in ${response}; do
+        if [[ "$type" == "plain" ]]; then
+            if [[ "$descr" == "_" ]]; then
+                completions+=("$key")
+            else
+                completions_with_descriptions+=("$key":"$descr")
+            fi
+        elif [[ "$type" == "dir" ]]; then
+            _path_files -/
+        elif [[ "$type" == "file" ]]; then
+            _path_files -f
+        fi
+    done
+
+    if [ -n "$completions_with_descriptions" ]; then
+        _describe -V unsorted completions_with_descriptions -U
+    fi
+
+    if [ -n "$completions" ]; then
+        compadd -U -V unsorted -a completions
+    fi
+}
+
+compdef _dapp_manager_completion dapp-manager;
+
diff --git a/yagna_dapp_manager/dapp_manager.py b/dapp_manager/dapp_manager.py
similarity index 100%
rename from yagna_dapp_manager/dapp_manager.py
rename to dapp_manager/dapp_manager.py
diff --git a/yagna_dapp_manager/dapp_starter.py b/dapp_manager/dapp_starter.py
similarity index 100%
rename from yagna_dapp_manager/dapp_starter.py
rename to dapp_manager/dapp_starter.py
diff --git a/yagna_dapp_manager/exceptions.py b/dapp_manager/exceptions.py
similarity index 93%
rename from yagna_dapp_manager/exceptions.py
rename to dapp_manager/exceptions.py
index 569c5eb..abcdd38 100644
--- a/yagna_dapp_manager/exceptions.py
+++ b/dapp_manager/exceptions.py
@@ -1,5 +1,5 @@
 class DappManagerException(Exception):
-    """Base exception for all yagna-dapp-manager exceptions"""
+    """Base exception for all dapp-manager exceptions"""
 
     SHELL_EXIT_CODE = 3
 
diff --git a/yagna_dapp_manager/py.typed b/dapp_manager/py.typed
similarity index 100%
rename from yagna_dapp_manager/py.typed
rename to dapp_manager/py.typed
diff --git a/yagna_dapp_manager/storage.py b/dapp_manager/storage.py
similarity index 100%
rename from yagna_dapp_manager/storage.py
rename to dapp_manager/storage.py
diff --git a/pyproject.toml b/pyproject.toml
index d9b1f44..b64f78f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,5 +1,5 @@
 [tool.poetry]
-name = "yagna-dapp-manager"
+name = "dapp-manager"
 version = "0.0.1"
 description = "Golem dapp-manager - run decetralized apps on the Golem Network using a 'docker-compose'-like interface"
 authors = ["Golem Factory <contact@golem.network>"]
@@ -31,6 +31,9 @@ poethepoet = "^0.13"
 pytest = "^7.1"
 pytest-cov = "^3.0.0"
 
+[tool.poetry.scripts]
+dapp-manager = "dapp_manager.__main__:main"
+
 [tool.poe.tasks]
 codestyle = "black --check --diff ."
 typecheck = "mypy --install-types --non-interactive --ignore-missing-imports --warn-unused-ignores --show-error-codes ."
diff --git a/sample_usage.py b/sample_usage.py
index 7019be0..581b1f6 100644
--- a/sample_usage.py
+++ b/sample_usage.py
@@ -1,4 +1,4 @@
-from yagna_dapp_manager import DappManager
+from dapp_manager import DappManager
 
 #   New app
 dapp = DappManager.start("golem_compose.yml", config="config_file.yml")
diff --git a/yagna_dapp_manager/__init__.py b/yagna_dapp_manager/__init__.py
deleted file mode 100644
index 5e3fbcc..0000000
--- a/yagna_dapp_manager/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-from yagna_dapp_manager.dapp_manager import DappManager
-from yagna_dapp_manager.exceptions import UnknownApp


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 48fde9c..1674bdb 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,7 +1,7 @@
 import pytest
 import tempfile
 
-from yagna_dapp_manager import DappManager
+from dapp_manager import DappManager
 
 
 @pytest.fixture(autouse=True)
diff --git a/tests/helpers.py b/tests/helpers.py
index a2f077c..81c85e2 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -2,7 +2,7 @@
 import psutil
 from typing import List, Tuple, Union
 
-from yagna_dapp_manager import DappManager
+from dapp_manager import DappManager
 
 
 def process_is_running(pid: int) -> bool:
@@ -35,7 +35,7 @@ def _get_command(self):
         return command
 
     with mock.patch(
-        "yagna_dapp_manager.dapp_starter.DappStarter._get_command", new=_get_command
+        "dapp_manager.dapp_starter.DappStarter._get_command", new=_get_command
     ):
         return DappManager.start(descriptor_file, config=config_file)
 
diff --git a/tests/test_manager_api.py b/tests/test_manager_api.py
index 9b62482..38df1ef 100644
--- a/tests/test_manager_api.py
+++ b/tests/test_manager_api.py
@@ -3,8 +3,8 @@
 import pytest
 from time import sleep
 
-from yagna_dapp_manager import DappManager
-from yagna_dapp_manager.exceptions import UnknownApp, AppNotRunning
+from dapp_manager import DappManager
+from dapp_manager.exceptions import UnknownApp, AppNotRunning
 
 from .helpers import (
     start_dapp,