patx/skirr
Fix CLI application lookup from the current directory
Commit 36bf1ea · patx · Nov 9 at 9:23PM
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.
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.')