Fix CLI application lookup from the current directory

Commit 36bf1ea · patx · Nov 9 at 9:23PM

Changeset
36bf1ead52935eeec0cc626ccbfe167d123a3720
Parents
312c11719513af4f0d52a50d8c1773a0fa808b83

View source at this commit

Fix CLI application lookup from the current directory

Load app:app from the working directory by default, support an optional app directory override, and report malformed application references as usage errors. Add subprocess regressions and an installed-console release check. Release as 0.1.1.

Comments

No comments yet.

Log in to comment

Diff

diff --git a/Cargo.lock b/Cargo.lock
index d8f725a..25f8ad8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -109,7 +109,7 @@ dependencies = [
 
 [[package]]
 name = "skirr"
-version = "0.1.0"
+version = "0.1.1"
 dependencies = [
  "httparse",
  "libc",
diff --git a/Cargo.toml b/Cargo.toml
index a6b48b1..be8606d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "skirr"
-version = "0.1.0"
+version = "0.1.1"
 edition = "2024"
 rust-version = "1.88"
 license = "MIT"
diff --git a/README.md b/README.md
index 073771b..1582596 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,10 @@ python -m pip install .
 skirr app:app --host 127.0.0.1 --port 8000
 ```
 
+Run this from the directory containing `app.py`; `app:app` selects its ASGI
+callable named `app`. The current directory is searched by default. To load an
+application from another directory, use `--app-dir /path/to/project`.
+
 `pip install '.[speed]'` installs optional uvloop. Standard asyncio also works.
 Applications use the ordinary ASGI 3 `(scope, receive, send)` interface.
 `--lifespan auto|on|off` defaults to `auto`. Only one worker is implemented;
@@ -38,6 +42,10 @@ PYTHONPATH=python python -m pytest tests -q
 cargo test --lib
 ```
 
+Before publishing, install the release wheel in a virtual environment and run
+`/path/to/venv/bin/python tools/check_release.py`. This checks the installed
+`skirr app:app` console command from a separate application directory.
+
 ## Implemented scope
 
 - HTTP/1.0 and HTTP/1.1, sequential pipelining, fixed-length and chunked bodies.
@@ -161,9 +169,9 @@ uses wrk2 corrected histograms; do not pool those percentiles with wrk results.
 Extended soak tests, fuzzing, broader sanitizer/leak qualification,
 and broader platform testing remain required before a production release.
 
-Verification: 64 real-socket/ASGI tests pass under both standard asyncio
+Verification: 69 CLI/socket/ASGI cases pass under both standard asyncio
 and uvloop; three native framing-invariant tests pass; strict Clippy passes.
-An installable CPython 3.14 Linux wheel was built and the same 64 socket tests
+An installable CPython 3.14 Linux wheel was built and the same 69 cases
 passed against that installed wheel. These checks cover the listed contracts,
 not every requirement in the HTTP specifications. A separate AddressSanitizer
 build passes the 64 cases under both loops; its embedding-host limitations and
diff --git a/pyproject.toml b/pyproject.toml
index 53f7658..971462b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "maturin"
 
 [project]
 name = "skirr"
-version = "0.1.0"
+version = "0.1.1"
 description = "Experimental Linux HTTP/1 ASGI-only server"
 readme = "README.md"
 requires-python = ">=3.12"
diff --git a/python/skirr/__init__.py b/python/skirr/__init__.py
index a28662e..b6f6ffd 100644
--- a/python/skirr/__init__.py
+++ b/python/skirr/__init__.py
@@ -4,7 +4,7 @@ import asyncio
 import importlib
 import signal
 
-__version__ = '0.1.0'
+__version__ = '0.1.1'
 
 
 def load(app):
@@ -150,10 +150,19 @@ def run(app, host='127.0.0.1', port=8000, workers=1, lifespan='auto'):
 
 
 def main():
+    import os
+    import sys
+
     parser = argparse.ArgumentParser(description=__doc__)
-    parser.add_argument('app', help='module:application')
+    parser.add_argument('app', help='ASGI application as module:attribute (for example app:app)')
+    parser.add_argument('--app-dir', default='.', help='Application directory (default: current directory)')
     parser.add_argument('--host', default='127.0.0.1')
     parser.add_argument('--port', type=int, default=8000)
     parser.add_argument('--workers', type=int, default=1)
     parser.add_argument('--lifespan', choices=['auto', 'on', 'off'], default='auto')
-    run(**vars(parser.parse_args()))
+    options = vars(parser.parse_args())
+    module, separator, attribute = options['app'].partition(':')
+    if not separator or not module or not attribute:
+        parser.error('app must use module:attribute format (for example app:app)')
+    sys.path.insert(0, os.path.abspath(options.pop('app_dir')))
+    run(**options)
diff --git a/tests/test_cli.py b/tests/test_cli.py
new file mode 100644
index 0000000..d654431
--- /dev/null
+++ b/tests/test_cli.py
@@ -0,0 +1,75 @@
+"""Exercise the CLI with the working directory absent from Python's import path."""
+import http.client
+import os
+from pathlib import Path
+import subprocess
+import sys
+import time
+
+import pytest
+import skirr
+
+
+def cli_env():
+    # Console scripts start with their bin directory on sys.path. -P gives the
+    # subprocess the same constraint without relying on an installed launcher.
+    return dict(os.environ, PYTHONPATH=str(Path(skirr.__file__).resolve().parent.parent))
+
+
[email protected]('directory', ['cwd', 'explicit'])
+def test_cli_serves_application_from_selected_directory(tmp_path, unused_tcp_port, directory):
+    project = tmp_path / 'project with spaces'
+    project.mkdir()
+    (project / 'app.py').write_text(
+        "async def app(scope, receive, send):\n"
+        "    await send({'type': 'http.response.start', 'status': 200})\n"
+        "    await send({'type': 'http.response.body', 'body': b'local-app'})\n"
+    )
+    cwd = project if directory == 'cwd' else tmp_path
+    arguments = ['app:app', '--port', str(unused_tcp_port), '--lifespan', 'off']
+    if directory == 'explicit':
+        (cwd / 'app.py').write_text("raise RuntimeError('Imported the wrong app directory')\n")
+        arguments += ['--app-dir', str(project.relative_to(cwd))]
+    process = subprocess.Popen(
+        [sys.executable, '-P', '-c', 'from skirr import main; main()', *arguments],
+        cwd=cwd, env=cli_env(), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
+        text=True,
+    )
+    try:
+        deadline = time.monotonic() + 5
+        while True:
+            assert process.poll() is None, process.communicate()[0]
+            client = http.client.HTTPConnection('127.0.0.1', unused_tcp_port, timeout=.5)
+            try:
+                client.request('GET', '/')
+                response = client.getresponse()
+                assert response.status == 200
+                assert response.read() == b'local-app'
+                break
+            except (OSError, http.client.HTTPException):
+                if time.monotonic() >= deadline:
+                    raise
+                time.sleep(.02)
+            finally:
+                client.close()
+    finally:
+        if process.poll() is None:
+            process.terminate()
+        try:
+            output, _ = process.communicate(timeout=5)
+        except subprocess.TimeoutExpired:
+            process.kill()
+            process.communicate()
+            raise
+    assert process.returncode == 0, output
+
+
[email protected]('reference', ['app', 'app:', ':app'])
+def test_cli_reports_malformed_application_reference(tmp_path, reference):
+    result = subprocess.run(
+        [sys.executable, '-P', '-c', 'from skirr import main; main()', reference],
+        cwd=tmp_path, env=cli_env(), capture_output=True, text=True, timeout=5,
+    )
+    assert result.returncode == 2
+    assert 'module:attribute' in result.stderr
+    assert 'Traceback' not in result.stderr
diff --git a/tools/check_release.py b/tools/check_release.py
new file mode 100644
index 0000000..5859aa0
--- /dev/null
+++ b/tools/check_release.py
@@ -0,0 +1,63 @@
+"""Run with an installed wheel's Python to verify its real console entry point."""
+import http.client
+import os
+from pathlib import Path
+import socket
+import subprocess
+import sys
+import tempfile
+import time
+
+launcher = Path(sys.executable).with_name('skirr')
+if not launcher.is_file():
+    raise RuntimeError('Install the wheel in a virtual environment and run with its Python')
+env = dict(os.environ)
+env.pop('PYTHONPATH', None)
+with socket.socket() as probe:
+    probe.bind(('127.0.0.1', 0))
+    port = probe.getsockname()[1]
+with tempfile.TemporaryDirectory(prefix='skirr-release-check-') as directory:
+    root = Path(directory)
+    (root / 'app.py').write_text(
+        "async def app(scope, receive, send):\n"
+        "    if scope['type'] != 'http':\n"
+        "        raise NotImplementedError\n"
+        "    await send({'type': 'http.response.start', 'status': 200})\n"
+        "    await send({'type': 'http.response.body', 'body': b'installed-cli-ok'})\n"
+    )
+    log_path = root / 'server.log'
+    with log_path.open('w') as log:
+        process = subprocess.Popen(
+            [str(launcher), 'app:app', '--port', str(port)],
+            cwd=root, env=env, stdout=log, stderr=subprocess.STDOUT,
+        )
+        try:
+            deadline = time.monotonic() + 10
+            while True:
+                if process.poll() is not None:
+                    raise RuntimeError(log_path.read_text())
+                client = http.client.HTTPConnection('127.0.0.1', port, timeout=1)
+                try:
+                    client.request('GET', '/')
+                    response = client.getresponse()
+                    if response.status != 200 or response.read() != b'installed-cli-ok':
+                        raise RuntimeError('Installed CLI returned the wrong response')
+                    break
+                except (OSError, http.client.HTTPException):
+                    if time.monotonic() >= deadline:
+                        raise
+                    time.sleep(.05)
+                finally:
+                    client.close()
+        finally:
+            if process.poll() is None:
+                process.terminate()
+            try:
+                process.wait(timeout=5)
+            except subprocess.TimeoutExpired:
+                process.kill()
+                process.wait()
+                raise
+        if process.returncode:
+            raise RuntimeError(log_path.read_text())
+print('Installed skirr app:app served HTTP from a separate application directory.')