Skip to content
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

CLI: Re-add command line interface (2024) #549

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -60,16 +60,19 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: yezz123/setup-uv@v4

- name: Install package and run software tests (Python 3.6)
if: matrix.python-version == '3.6'
run: |
pip install '.[graphql,develop,test]'
pip install '.[cli,graphql,develop,test]'
poe test

- name: Install and completely validate package (Python >=3.6)
if: matrix.python-version != '3.6'
run: |
uv pip install '.[graphql,develop,test]' --system
uv pip install '.[cli,graphql,develop,test]' --system
poe check
Comment on lines 68 to 78
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should probably be using the full extra now.

1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
.pytest_cache
.DS_Store
coverage.xml
.coverage*

__pycache__
tests/__pycache__
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ Install the most recent stable release:

uv pip install --upgrade 'responder'

Or, install directly from the repository:
Install package with CLI and GraphQL support:

uv pip install --upgrade 'responder[cli,graphql]'

Alternatively, install directly from the repository:

uv pip install --upgrade 'responder @ git+https://github.com/kennethreitz/responder.git'

Expand Down
90 changes: 90 additions & 0 deletions docs/source/cli.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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. By default, it uses the current working directory,
where it expects a regular NPM ``package.json`` file.

.. code-block:: shell

responder build

When specifying a target directory, responder will change to that
directory beforehand.

.. code-block:: shell

responder build /path/to/project


.. _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 @@ -100,6 +100,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
17 changes: 16 additions & 1 deletion 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 All @@ -77,7 +92,7 @@ check = [
]

docs-autobuild = [
{ cmd = "sphinx-autobuild --open-browser --watch docs/source docs/build" },
{ cmd = "sphinx-autobuild --open-browser --watch docs/source docs/source docs/build" },
]
docs-html = [
{ cmd = "sphinx-build -W --keep-going docs/source docs/build" },
Expand Down
7 changes: 7 additions & 0 deletions responder/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
"""
Responder - a familiar HTTP Service Framework.

This module exports the core functionality of the Responder framework,
including the API, Request, Response classes and CLI interface.
Comment on lines +4 to +5
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No longer true?

Suggested change
This module exports the core functionality of the Responder framework,
including the API, Request, Response classes and CLI interface.
This module exports the core functionality of the Responder framework,
including the API, Request, and Response classes.

"""

from . import ext
from .core import API, Request, Response

Expand Down
4 changes: 3 additions & 1 deletion responder/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,9 +363,11 @@ def serve(self, *, address=None, port=None, debug=False, **options):
address = "127.0.0.1"
if port is None:
port = 5042
if debug:
options["log_level"] = "debug"
amotl marked this conversation as resolved.
Show resolved Hide resolved

def spawn():
uvicorn.run(self, host=address, port=port, debug=debug, **options)
uvicorn.run(self, host=address, port=port, **options)

spawn()

Expand Down
127 changes: 127 additions & 0 deletions responder/ext/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""
Responder CLI.

A web framework for Python.

Commands:
run Start the application server
build Build frontend assets using npm
Comment on lines +6 to +8
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kennethreitz: Now after learning it, I think the second subcommand responder build is debatable. Did you keep and/or evolve it within Dyne, @tabotkevin?


Usage:
responder
responder run [--debug] [--limit-max-requests=] <target>
responder build [<target>]
responder --version

Options:
-h --help Show this screen.
-v --version Show version.
--debug Enable debug mode with verbose logging.
--limit-max-requests=<n> Maximum number of requests to handle before shutting down.

Arguments:
<target> For run: Python module specifier (e.g., "app:api" loads api from app.py)
Format: "module.submodule:variable_name" where variable_name is your API instance
For build: Directory containing package.json (default: current directory)

Examples:
responder run app:api # Run the 'api' instance from app.py
responder run myapp/core.py:application # Run the 'application' instance from myapp/core.py
responder build # Build frontend assets
""" # noqa: E501

import logging
import platform
import subprocess
import sys
import typing as t
from pathlib import Path

import docopt

from responder.__version__ import __version__
from responder.util.python import InvalidTarget, load_target

logger = logging.getLogger(__name__)


def cli() -> None:
"""
Main entry point for the Responder CLI.

Parses command line arguments and executes the appropriate command.
Supports running the application, building assets, and displaying version info.
"""
args = docopt.docopt(__doc__, argv=None, version=__version__, options_first=False)
setup_logging(args["--debug"])

target: t.Optional[str] = args["<target>"]
build: bool = args["build"]
debug: bool = args["--debug"]
run: bool = args["run"]

if build:
target_path = Path(target).resolve() if target else Path.cwd()
if not target_path.is_dir() or not (target_path / "package.json").exists():
logger.error(
f"Invalid target directory or missing package.json: {target_path}"
)
sys.exit(1)
npm_cmd = "npm.cmd" if platform.system() == "Windows" else "npm"
try:
# # S603, S607 are addressed by validating the target directory.
subprocess.check_call( # noqa: S603, S607
[npm_cmd, "run", "build"],
cwd=target_path,
timeout=300,
)
except FileNotFoundError:
logger.error("npm not found. Please install Node.js and npm.")
sys.exit(1)
except subprocess.CalledProcessError as e:
logger.error(f"Build failed with exit code {e.returncode}")
sys.exit(1)

Comment on lines +63 to +84
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Consider enhancing build command feedback.

The build command implementation could benefit from:

  1. Version check for npm to ensure compatibility
  2. Progress indicator for long-running builds

Consider adding these improvements:

 if build:
     target_path = Path(target).resolve() if target else Path.cwd()
     if not target_path.is_dir() or not (target_path / "package.json").exists():
         logger.error(
             f"Invalid target directory or missing package.json: {target_path}"
         )
         sys.exit(1)
     npm_cmd = "npm.cmd" if platform.system() == "Windows" else "npm"
     try:
+        # Check npm version
+        version_result = subprocess.run(
+            [npm_cmd, "--version"],
+            capture_output=True,
+            text=True,
+            check=True
+        )
+        logger.info(f"Using npm version {version_result.stdout.strip()}")
+
+        # Show build progress
+        logger.info("Building frontend assets...")
         # # S603, S607 are addressed by validating the target directory.
         subprocess.check_call(  # noqa: S603, S607
             [npm_cmd, "run", "build"],
             cwd=target_path,
             timeout=300,
         )
+        logger.info("Build completed successfully")
     except FileNotFoundError:
         logger.error("npm not found. Please install Node.js and npm.")
         sys.exit(1)
     except subprocess.CalledProcessError as e:
         logger.error(f"Build failed with exit code {e.returncode}")
         sys.exit(1)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if build:
target_path = Path(target).resolve() if target else Path.cwd()
if not target_path.is_dir() or not (target_path / "package.json").exists():
logger.error(
f"Invalid target directory or missing package.json: {target_path}"
)
sys.exit(1)
npm_cmd = "npm.cmd" if platform.system() == "Windows" else "npm"
try:
# # S603, S607 are addressed by validating the target directory.
subprocess.check_call( # noqa: S603, S607
[npm_cmd, "run", "build"],
cwd=target_path,
timeout=300,
)
except FileNotFoundError:
logger.error("npm not found. Please install Node.js and npm.")
sys.exit(1)
except subprocess.CalledProcessError as e:
logger.error(f"Build failed with exit code {e.returncode}")
sys.exit(1)
if build:
target_path = Path(target).resolve() if target else Path.cwd()
if not target_path.is_dir() or not (target_path / "package.json").exists():
logger.error(
f"Invalid target directory or missing package.json: {target_path}"
)
sys.exit(1)
npm_cmd = "npm.cmd" if platform.system() == "Windows" else "npm"
try:
# Check npm version
version_result = subprocess.run(
[npm_cmd, "--version"],
capture_output=True,
text=True,
check=True
)
logger.info(f"Using npm version {version_result.stdout.strip()}")
# Show build progress
logger.info("Building frontend assets...")
# # S603, S607 are addressed by validating the target directory.
subprocess.check_call( # noqa: S603, S607
[npm_cmd, "run", "build"],
cwd=target_path,
timeout=300,
)
logger.info("Build completed successfully")
except FileNotFoundError:
logger.error("npm not found. Please install Node.js and npm.")
sys.exit(1)
except subprocess.CalledProcessError as e:
logger.error(f"Build failed with exit code {e.returncode}")
sys.exit(1)

if run:
if not target:
logger.error("Target argument is required for run command")
sys.exit(1)

# Maximum request limit. Terminating afterward. Suitable for software testing.
limit_max_requests = args["--limit-max-requests"]
if limit_max_requests is not None:
try:
limit_max_requests = int(limit_max_requests)
if limit_max_requests <= 0:
logger.error("limit-max-requests must be a positive integer")
sys.exit(1)
except ValueError:
logger.error("limit-max-requests must be a valid integer")
sys.exit(1)

# Load application from target.
try:
api = load_target(target=target)
except InvalidTarget as ex:
raise ValueError(
f"{ex}. "
"Use either a Python module entrypoint specification, "
"a filesystem path, or a remote URL. "
"See also https://responder.kennethreitz.org/cli.html."
) from ex

# Launch Responder API server (uvicorn).
api.run(debug=debug, limit_max_requests=limit_max_requests)


def setup_logging(debug: bool) -> None:
"""
Configure logging based on debug mode.

Args:
debug: When True, sets logging level to DEBUG; otherwise, sets to INFO
"""
log_level = logging.DEBUG if debug else logging.INFO
logging.basicConfig(
level=log_level, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
Empty file added responder/util/__init__.py
Empty file.
Loading
Loading