Repository: CentOS/duffy (language: python)

Issue:
Command Line Interface
# Story

As an operator of Duffy,
I want to run Duffy from the command line,
to have a defined entry point for executing the service.

# Acceptance Criteria

- [x] A command line scripts exists which runs the web app service…
- [x] …which is installed as an executable with `poetry install`.


Discussion / hints:

Fix patch:
diff --git a/README.md b/README.md
index 4cb4dc8a..9963dfc2 100644
--- a/README.md
+++ b/README.md
@@ -13,29 +13,77 @@ Duffy is the middle layer running ci.centos.org that manages the provisioning, m
 
 ### Installation
 To install Duffy:
-* Clone the repository.
-* Install using Poetry: `poetry install`
+1. Clone the repository and navigate into the project directory.
+   ```
+   git clone https://github.com/CentOS/duffy.git
+   cd duffy
+   ```
+2. Set up and activate a virtual environment.
+   * Using native virtual environment
+     ```
+     python3 -m venv duffyenv
+     source duffyenv/bin/activate
+     ```
+   Or
+   * Using virtualenv wrapper
+     ```
+     virtualenv duffyenv
+     source duffyenv/bin/activate
+     ```
+   Or
+   * Using Poetry virtual environment shell
+     ```
+     poetry shell
+     ```
+3. Install using Poetry
+   ```
+   poetry install
+   ```
 
-### Running Duffy using poetry
+### Running Duffy server
 
-There are two ways to run the app using the virtual environment poetry manages for you:
+#### Viewing CLI usage
 
 ```
-poetry run uvicorn duffy.app.main:app --reload
+duffy --help
 ```
 
-Or:
-
 ```
-poetry shell
-uvicorn duffy.app.main:app --reload
+Usage: duffy [OPTIONS]
+
+  Duffy is the middle layer running ci.centos.org that manages the
+  provisioning, maintenance and teardown / rebuild of the Nodes (physical
+  hardware for now, VMs coming soon) that are used to run the tests in the CI
+  Cluster.
+
+Options:
+  -p, --portnumb INTEGER          Set the port value [0-65536]
+  -6, --ipv6                      Start the server on an IPv6 address
+  -4, --ipv4                      Start the server on an IPv4 address
+  -l, --loglevel [critical|error|warning|info|debug|trace]
+                                  Set the log level
+  --version                       Show the version and exit.
+  --help                          Show this message and exit.
 ```
 
-### Running Duffy in an explicitly defined virtual environment
+#### Starting the server at port 8080 using IP version 4 and setting the log level to `trace`
 
-If you create a virtual environment by yourself, poetry will install into this if it's activated. In
-this environment, simply run the app like this:
+```
+duffy -p 8000 -4 -l trace
+```
 
 ```
-uvicorn duffy.app.main:app --reload
+ * Starting Duffy...
+ * Port number : 8000
+ * IP version  : 4
+ * Log level   : trace
+INFO:     Started server process [104283]
+INFO:     Waiting for application startup.
+TRACE:    ASGI [1] Started scope={'type': 'lifespan', 'asgi': {'version': '3.0', 'spec_version': '2.0'}}
+TRACE:    ASGI [1] Receive {'type': 'lifespan.startup'}
+TRACE:    ASGI [1] Send {'type': 'lifespan.startup.complete'}
+INFO:     Application startup complete.
+INFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
 ```
+
+Exit out of the server using `Ctrl` + `C`
diff --git a/duffy/app/cli.py b/duffy/app/cli.py
new file mode 100644
index 00000000..495c3627
--- /dev/null
+++ b/duffy/app/cli.py
@@ -0,0 +1,48 @@
+import click
+import uvicorn
+
+from duffy.version import __version__
+
+
+@click.command()
+@click.option("-p", "--portnumb", "portnumb", help="Set the port value [0-65536]", default=8080)
+@click.option(
+    "-6",
+    "--ipv6",
+    "netprotc",
+    flag_value="ipv6",
+    help="Start the server on an IPv6 address",
+)
+@click.option(
+    "-4",
+    "--ipv4",
+    "netprotc",
+    flag_value="ipv4",
+    help="Start the server on an IPv4 address",
+)
+@click.option(
+    "-l",
+    "--loglevel",
+    "loglevel",
+    type=click.Choice(list(uvicorn.config.LOG_LEVELS.keys()), case_sensitive=False),
+    help="Set the log level",
+    default="info",
+)
+@click.version_option(version=__version__, prog_name="Duffy")
+def main(portnumb, netprotc, loglevel):
+    """
+    Duffy is the middle layer running ci.centos.org that manages the
+    provisioning, maintenance and teardown / rebuild of the Nodes
+    (physical hardware for now, VMs coming soon) that are used to run
+    the tests in the CI Cluster.
+    """
+    print(" * Starting Duffy...")
+    print(" * Port number : %s" % portnumb)
+    if netprotc == "ipv6":
+        print(" * IP version  : 6")
+        netpdata = "::"
+    else:
+        print(" * IP version  : 4")
+        netpdata = "0.0.0.0"
+    print(" * Log level   : %s" % loglevel)
+    uvicorn.run("duffy.app.main:app", host=netpdata, port=portnumb, log_level=loglevel)
diff --git a/pyproject.toml b/pyproject.toml
index 016c7db7..66b75c4b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -34,3 +34,6 @@ line-length = 100
 [build-system]
 requires = ["poetry-core>=1.0.0"]
 build-backend = "poetry.core.masonry.api"
+
+[tool.poetry.scripts]
+duffy = "duffy.app.cli:main"


Test patch:
diff --git a/tests/duffy/app/test_cli.py b/tests/duffy/app/test_cli.py
new file mode 100644
index 00000000..188f3b55
--- /dev/null
+++ b/tests/duffy/app/test_cli.py
@@ -0,0 +1,60 @@
+from unittest import mock
+
+import pytest
+from click.testing import CliRunner
+
+from duffy.app.cli import main
+from duffy.version import __version__
+
+
+def test_cli_version():
+    runner = CliRunner()
+    result = runner.invoke(main, ["--version"])
+    assert result.exit_code == 0
+    assert result.output == "Duffy, version %s\n" % __version__
+
+
+def test_cli_help():
+    runner = CliRunner()
+    result = runner.invoke(main, ["--help"], terminal_width=80)
+    assert result.exit_code == 0
+    assert (
+        result.output
+        == """Usage: main [OPTIONS]
+
+  Duffy is the middle layer running ci.centos.org that manages the provisioning,
+  maintenance and teardown / rebuild of the Nodes (physical hardware for now,
+  VMs coming soon) that are used to run the tests in the CI Cluster.
+
+Options:
+  -p, --portnumb INTEGER          Set the port value [0-65536]
+  -6, --ipv6                      Start the server on an IPv6 address
+  -4, --ipv4                      Start the server on an IPv4 address
+  -l, --loglevel [critical|error|warning|info|debug|trace]
+                                  Set the log level
+  --version                       Show the version and exit.
+  --help                          Show this message and exit.
+"""
+    )
+
+
+def test_cli_suggestion():
+    runner = CliRunner()
+    result = runner.invoke(main, ["--helo"])
+    assert result.exit_code == 2
+    assert (
+        result.output
+        == """Usage: main [OPTIONS]
+Try 'main --help' for help.
+
+Error: No such option: --helo Did you mean --help?
+"""
+    )
+
+
+@pytest.mark.parametrize("parameters", ((), ("--ipv6",)))
+@mock.patch("duffy.app.cli.uvicorn")
+def test_cli_main(mock_uvicorn, parameters):
+    runner = CliRunner()
+    runner.invoke(main, parameters)
+    mock_uvicorn.run.assert_called_once()