Skip to content

Commit

Permalink
CLI: Load from file or module. Add software tests and documentation.
Browse files Browse the repository at this point in the history
  • Loading branch information
amotl committed Oct 26, 2024
1 parent c6f3f50 commit 703720b
Show file tree
Hide file tree
Showing 9 changed files with 272 additions and 19 deletions.
82 changes: 82 additions & 0 deletions docs/source/cli.rst
Original file line number Diff line number Diff line change
@@ -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 [<target>]
.. _helloworld.py: https://github.com/kennethreitz/responder/blob/main/examples/helloworld.py
.. _HTTPie: https://httpie.io/docs/cli
1 change: 1 addition & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ User Guides
deployment
testing
api
cli


Installing Responder
Expand Down
2 changes: 2 additions & 0 deletions examples/helloworld.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# Example HTTP service definition, using Responder.
# https://pypi.org/project/responder/
import responder

api = responder.API()
Expand Down
15 changes: 15 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
35 changes: 18 additions & 17 deletions responder/cli.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"""Responder.
"""
Responder CLI.
Usage:
responder
responder run [--build] <module>
responder build
responder run [--debug] [--limit-max-requests=] <target>
responder build [<target>]
responder --version
Options:
Expand All @@ -17,6 +18,7 @@
import docopt

from .__version__ import __version__
from .util.python import load_target


def cli():
Expand All @@ -25,22 +27,21 @@ def cli():
"""
args = docopt.docopt(__doc__, argv=None, version=__version__, options_first=False)

module = args["<module>"]
build = args["build"] or args["--build"]
target = args["<target>"]
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)
Empty file added responder/util/__init__.py
Empty file.
31 changes: 31 additions & 0 deletions responder/util/cmd.py
Original file line number Diff line number Diff line change
@@ -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)
54 changes: 54 additions & 0 deletions responder/util/python.py
Original file line number Diff line number Diff line change
@@ -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
71 changes: 69 additions & 2 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 703720b

Please sign in to comment.