From 703720b86cc36c1e92774de1556aad788de94a6b Mon Sep 17 00:00:00 2001 From: Andreas Motl Date: Sat, 26 Oct 2024 15:12:49 +0200 Subject: [PATCH] CLI: Load from file or module. Add software tests and documentation. --- docs/source/cli.rst | 82 ++++++++++++++++++++++++++++++++++++++ docs/source/index.rst | 1 + examples/helloworld.py | 2 + pyproject.toml | 15 +++++++ responder/cli.py | 35 ++++++++-------- responder/util/__init__.py | 0 responder/util/cmd.py | 31 ++++++++++++++ responder/util/python.py | 54 +++++++++++++++++++++++++ tests/test_cli.py | 71 ++++++++++++++++++++++++++++++++- 9 files changed, 272 insertions(+), 19 deletions(-) create mode 100644 docs/source/cli.rst create mode 100644 responder/util/__init__.py create mode 100644 responder/util/cmd.py create mode 100644 responder/util/python.py diff --git a/docs/source/cli.rst b/docs/source/cli.rst new file mode 100644 index 00000000..ebd2a91a --- /dev/null +++ b/docs/source/cli.rst @@ -0,0 +1,82 @@ +Responder CLI +============= + +Responder installs a command line program ``responder``. Use it to launch +a Responder application from a file or module. + +Launch application from file +---------------------------- + +Acquire minimal example application, `helloworld.py`_, +implementing a basic echo handler, and launch the HTTP service. + +.. code-block:: shell + + wget https://github.com/kennethreitz/responder/raw/refs/heads/main/examples/helloworld.py + responder run helloworld.py + +In another terminal, invoke a HTTP request, for example using `HTTPie`_. + +.. code-block:: shell + + http http://127.0.0.1:5042/hello + +The response is no surprise. + +:: + + HTTP/1.1 200 OK + content-length: 13 + content-type: text/plain + date: Sat, 26 Oct 2024 13:16:55 GMT + encoding: utf-8 + server: uvicorn + + hello, world! + + +Launch application from module +------------------------------ + +If your Responder application has been implemented as a Python module, +launch it like this: + +.. code-block:: shell + + responder run acme.app + +That assumes a Python package ``acme`` including an ``app`` module +``acme/app.py`` that includes an attribute ``api`` that refers +to a ``responder.API`` instance, reflecting the typical layout of +a standard Responder application. + +.. rubric:: Non-standard instance name + +When your attribute that references the ``responder.API`` instance +is called differently than ``api``, append it to the launch target +address like this: + +.. code-block:: shell + + responder run acme.app:service + +Within your ``app.py``, the instance would have been defined like this: + +.. code-block:: python + + service = responder.API() + + +Build JavaScript application +---------------------------- + +The ``build`` subcommand invokes ``npm run build``, optionally accepting +a target directory. + +.. code-block:: shell + + responder build [] + + +.. _helloworld.py: https://github.com/kennethreitz/responder/blob/main/examples/helloworld.py +.. _HTTPie: https://httpie.io/docs/cli diff --git a/docs/source/index.rst b/docs/source/index.rst index 88683e46..dfe95941 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -96,6 +96,7 @@ User Guides deployment testing api + cli Installing Responder diff --git a/examples/helloworld.py b/examples/helloworld.py index 4327e9da..96047d2b 100644 --- a/examples/helloworld.py +++ b/examples/helloworld.py @@ -1,3 +1,5 @@ +# Example HTTP service definition, using Responder. +# https://pypi.org/project/responder/ import responder api = responder.API() diff --git a/pyproject.toml b/pyproject.toml index d36e57c2..bef3f519 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,21 @@ markers = [ ] xfail_strict = true +[tool.coverage.run] +branch = false +omit = [ + "*.html", + "tests/*", +] + +[tool.coverage.report] +fail_under = 0 +show_missing = true +exclude_lines = [ + "# pragma: no cover", + "raise NotImplemented", +] + [tool.poe.tasks] check = [ diff --git a/responder/cli.py b/responder/cli.py index d9e5e5e1..fb2a0fc5 100644 --- a/responder/cli.py +++ b/responder/cli.py @@ -1,9 +1,10 @@ -"""Responder. +""" +Responder CLI. Usage: responder - responder run [--build] - responder build + responder run [--debug] [--limit-max-requests=] + responder build [] responder --version Options: @@ -17,6 +18,7 @@ import docopt from .__version__ import __version__ +from .util.python import load_target def cli(): @@ -25,22 +27,21 @@ def cli(): """ args = docopt.docopt(__doc__, argv=None, version=__version__, options_first=False) - module = args[""] - build = args["build"] or args["--build"] + target = args[""] + build = args["build"] + debug = args["--debug"] run = args["run"] if build: - # S603, S607 are suppressed as we're using fixed arguments, not user input - subprocess.check_call(["npm", "run", "build"]) # noqa: S603, S607 + subprocess.check_call(["npm", "run", "build"], cwd=target) # noqa: S603, S607 if run: - split_module = module.split(":") - - if len(split_module) > 1: - module = split_module[0] - prop = split_module[1] - else: - prop = "api" - - app = __import__(module) - getattr(app, prop).run() + # Maximum request limit. Terminating afterward. Suitable for software testing. + limit_max_requests = args["--limit-max-requests"] + limit_max_requests = ( + limit_max_requests and int(limit_max_requests) or limit_max_requests + ) + + # Launch Responder API server (uvicorn). + api = load_target(target=target) + api.run(debug=debug, limit_max_requests=limit_max_requests) diff --git a/responder/util/__init__.py b/responder/util/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/responder/util/cmd.py b/responder/util/cmd.py new file mode 100644 index 00000000..a11a94f0 --- /dev/null +++ b/responder/util/cmd.py @@ -0,0 +1,31 @@ +# ruff: noqa: S603, S607 +import shutil +import subprocess +import threading + +RESPONDER_BIN = shutil.which("responder") + + +class ResponderServer(threading.Thread): + """ + A little wrapper around `responder run`. Mostly used for testing. + """ + + def __init__(self, target: str, port: int = 5042, limit_max_requests: int = None): + super().__init__() + self.target = target + self.port = port + self.limit_max_requests = limit_max_requests + + def run(self): + command = [ + RESPONDER_BIN, + "run", + self.target, + ] + if self.limit_max_requests is not None: + command += [f"--limit-max-requests={self.limit_max_requests}"] + env = {} + if self.port is not None: + env = {"PORT": str(self.port)} + subprocess.check_call(command, env=env) diff --git a/responder/util/python.py b/responder/util/python.py new file mode 100644 index 00000000..be1591a4 --- /dev/null +++ b/responder/util/python.py @@ -0,0 +1,54 @@ +import importlib +import importlib.util +import sys +from pathlib import Path + + +def load_target(target: str, default_property: str = "api", method: str = "run"): + """ + Load Python code from file or module. + """ + + # Decode launch target location address. + # Module: acme.app:foo + # Path: /path/to/acme/app.py:foo + target_fragments = target.split(":") + if len(target_fragments) > 1: + target = target_fragments[0] + prop = target_fragments[1] + else: + prop = default_property + + # Import launch target. Treat input location either as a filesystem path + # (/path/to/acme/app.py), or as a module address specification (acme.app). + if Path(target).exists(): + app = load_file_module(target) + else: + app = importlib.import_module(target) + + # Invoke launch target. + msg_prefix = f"Failed to import target '{target}'" + try: + api = getattr(app, prop, None) + if api is None: + raise AttributeError( + f"{msg_prefix}: Module has no API instance attribute '{prop}'" + ) + if not hasattr(api, method): + raise AttributeError( + f"{msg_prefix}: API instance '{prop}' has no method 'run'" + ) + return api + except ImportError as ex: + raise ImportError(f"{msg_prefix}: {ex}") from ex + + +def load_file_module(module: str): + """ + Load Python file as Python module. + """ + spec = importlib.util.spec_from_file_location("__app__", module) + app = importlib.util.module_from_spec(spec) + sys.modules["__app__"] = app + spec.loader.exec_module(app) + return app diff --git a/tests/test_cli.py b/tests/test_cli.py index 5975105a..f1fd7f72 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,18 +1,85 @@ +# ruff: noqa: S603, S607 +import json import subprocess +import time import pytest +import requests from responder.__version__ import __version__ +from responder.util.cmd import RESPONDER_BIN, ResponderServer pytest.importorskip("docopt", reason="docopt-ng package not installed") def test_cli_version(capfd): - # S603, S607 are suppressed as we're using fixed arguments, not user input + """ + Verify that `responder --version` works as expected. + """ try: - subprocess.check_call(["responder", "--version"]) # noqa: S603, S607 + subprocess.check_call(["responder", "--version"]) except subprocess.CalledProcessError as ex: pytest.fail(f"CLI command failed with exit code {ex.returncode}") stdout = capfd.readouterr().out.strip() assert stdout == __version__ + + +def test_cli_build(capfd, tmp_path): + """ + Verify that `responder build` works as expected. + """ + + # Temporary surrogate `package.json` file. + package_json = {"scripts": {"build": "echo foobar"}} + package_json_file = tmp_path / "package.json" + package_json_file.write_text(json.dumps(package_json)) + + # Invoke `responder build`. + command = [ + RESPONDER_BIN, + "build", + str(tmp_path), + ] + subprocess.check_call(command) + + output = capfd.readouterr() + + stdout = output.out.strip() + assert "foobar" in stdout + + +def test_cli_run(capfd): + """ + Verify that `responder run` works as expected. + """ + + # Invoke `responder run`. + # Start a Responder service instance in the background, using its CLI. + # Make it terminate itself after serving one HTTP request. + server = ResponderServer( + target="examples/helloworld.py", port=9876, limit_max_requests=1 + ) + server.start() + time.sleep(0.5) + + response = requests.get(f"http://127.0.0.1:{server.port}/hello", timeout=1) + assert "hello, world!" == response.text + server.join() + + output = capfd.readouterr() + + stdout = output.out.strip() + assert '"GET /hello HTTP/1.1" 200 OK' in stdout + + stderr = output.err.strip() + + assert "Started server process" in stderr + assert "Waiting for application startup" in stderr + assert "Application startup complete" in stderr + assert "Uvicorn running" in stderr + + assert "Shutting down" in stderr + assert "Waiting for application shutdown" in stderr + assert "Application shutdown complete" in stderr + assert "Finished server process" in stderr