Skip to content

Commit ed0149b

Browse files
Merge pull request #457 from kgaughan/app-flag
Add an --app= flag for specifying the WSGI application
2 parents fc592e8 + 228cdee commit ed0149b

File tree

5 files changed

+119
-118
lines changed

5 files changed

+119
-118
lines changed

docs/runner.rst

+11-1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ Is equivalent to::
2020

2121
waitress-serve --port=8041 --url-scheme=https myapp:wsgifunc
2222

23+
Or:
24+
25+
waitress-serve --port=8041 --url-scheme=https --app=myapp:wsgifunc
26+
2327
The full argument list is :ref:`given below <invocation>`.
2428

2529
Boolean arguments are represented by flags. If you wish to explicitly set a
@@ -64,13 +68,19 @@ Invocation
6468

6569
Usage::
6670

67-
waitress-serve [OPTS] MODULE:OBJECT
71+
waitress-serve [OPTS] [MODULE:OBJECT]
6872

6973
Common options:
7074

7175
``--help``
7276
Show this information.
7377

78+
``--app=MODULE:OBJECT``
79+
Run the given callable object the WSGI application.
80+
81+
You can specify the WSGI application using this flag or as a positional
82+
argument.
83+
7484
``--call``
7585
Call the given object to get the WSGI application.
7686

src/waitress/adjustments.py

+35-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"""Adjustments are tunable parameters.
1515
"""
1616
import getopt
17+
import pkgutil
1718
import socket
1819
import warnings
1920

@@ -95,9 +96,25 @@ class _int_marker(int):
9596
pass
9697

9798

99+
class AppResolutionError(Exception):
100+
"""The named WSGI application could not be resolved."""
101+
102+
103+
def resolve_wsgi_app(app_name, call=False):
104+
"""Resolve a WSGI app descriptor to a callable."""
105+
try:
106+
app = pkgutil.resolve_name(app_name)
107+
except (ValueError, ImportError, AttributeError) as exc:
108+
raise AppResolutionError(f"Cannot import WSGI application '{app_name}': {exc}")
109+
return app() if call else app
110+
111+
98112
class Adjustments:
99113
"""This class contains tunable parameters."""
100114

115+
# If you add new parameters, be sure to update the following files:
116+
# * src/arguments.rst (waitress.serve)
117+
# * src/waitress/runner.py and src/runner.rst (CLI documentation)
101118
_params = (
102119
("host", str),
103120
("port", int),
@@ -459,11 +476,15 @@ def parse_args(cls, argv):
459476
else:
460477
long_opts.append(opt + "=")
461478

479+
long_opts.append("app=")
480+
462481
kw = {
463482
"help": False,
464483
"call": False,
484+
"app": None,
465485
}
466486

487+
app = None
467488
opts, args = getopt.getopt(argv, "", long_opts)
468489
for opt, value in opts:
469490
param = opt.lstrip("-").replace("-", "_")
@@ -477,12 +498,25 @@ def parse_args(cls, argv):
477498
kw[param] = "false"
478499
elif param in ("help", "call"):
479500
kw[param] = True
501+
elif param == "app":
502+
app = value
480503
elif cls._param_map[param] is asbool:
481504
kw[param] = "true"
482505
else:
483506
kw[param] = value
484507

485-
return kw, args
508+
if not kw["help"]:
509+
if app is None and len(args) > 0:
510+
app = args.pop(0)
511+
if app is None:
512+
raise AppResolutionError("Specify an application")
513+
if len(args) > 0:
514+
raise AppResolutionError("Provide only one WSGI app")
515+
kw["app"] = resolve_wsgi_app(app, kw["call"])
516+
517+
del kw["call"]
518+
519+
return kw
486520

487521
@classmethod
488522
def check_sockets(cls, sockets):

src/waitress/runner.py

+20-41
Original file line numberDiff line numberDiff line change
@@ -11,31 +11,35 @@
1111
# FOR A PARTICULAR PURPOSE.
1212
#
1313
##############################################################################
14-
"""Command line runner.
15-
"""
16-
14+
"""Command line runner."""
1715

1816
import getopt
1917
import logging
2018
import os
2119
import os.path
22-
import pkgutil
2320
import sys
21+
import traceback
2422

2523
from waitress import serve
26-
from waitress.adjustments import Adjustments
24+
from waitress.adjustments import Adjustments, AppResolutionError
2725
from waitress.utilities import logger
2826

2927
HELP = """\
3028
Usage:
3129
32-
{0} [OPTS] MODULE:OBJECT
30+
{0} [OPTS] [MODULE:OBJECT]
3331
3432
Standard options:
3533
3634
--help
3735
Show this information.
3836
37+
--app=MODULE:OBJECT
38+
Run the given callable object the WSGI application.
39+
40+
You can specify the WSGI application using this flag or as a positional
41+
argument.
42+
3943
--call
4044
Call the given object to get the WSGI application.
4145
@@ -277,62 +281,37 @@ def show_help(stream, name, error=None): # pragma: no cover
277281
print(HELP.format(name), file=stream)
278282

279283

280-
def show_exception(stream):
281-
exc_type, exc_value = sys.exc_info()[:2]
282-
args = getattr(exc_value, "args", None)
283-
print(
284-
("There was an exception ({}) importing your module.\n").format(
285-
exc_type.__name__,
286-
),
287-
file=stream,
288-
)
289-
if args:
290-
print("It had these arguments: ", file=stream)
291-
for idx, arg in enumerate(args, start=1):
292-
print(f"{idx}. {arg}\n", file=stream)
293-
else:
294-
print("It had no arguments.", file=stream)
295-
296-
297284
def run(argv=sys.argv, _serve=serve):
298285
"""Command line runner."""
286+
# Add the current directory onto sys.path
287+
sys.path.append(os.getcwd())
288+
299289
name = os.path.basename(argv[0])
300290

301291
try:
302-
kw, args = Adjustments.parse_args(argv[1:])
292+
kw = Adjustments.parse_args(argv[1:])
303293
except getopt.GetoptError as exc:
304294
show_help(sys.stderr, name, str(exc))
305295
return 1
296+
except AppResolutionError as exc:
297+
show_help(sys.stderr, name, str(exc))
298+
traceback.print_exc(file=sys.stderr)
299+
return 1
306300

307301
if kw["help"]:
308302
show_help(sys.stdout, name)
309303
return 0
310304

311-
if len(args) != 1:
312-
show_help(sys.stderr, name, "Specify one application only")
313-
return 1
314-
315305
# set a default level for the logger only if it hasn't been set explicitly
316306
# note that this level does not override any parent logger levels,
317307
# handlers, etc but without it no log messages are emitted by default
318308
if logger.level == logging.NOTSET:
319309
logger.setLevel(logging.INFO)
320310

321-
# Add the current directory onto sys.path
322-
sys.path.append(os.getcwd())
323-
324-
# Get the WSGI function.
325-
try:
326-
app = pkgutil.resolve_name(args[0])
327-
except (ValueError, ImportError, AttributeError) as exc:
328-
show_help(sys.stderr, name, str(exc))
329-
show_exception(sys.stderr)
330-
return 1
331-
if kw["call"]:
332-
app = app()
311+
app = kw["app"]
333312

334313
# These arguments are specific to the runner, not waitress itself.
335-
del kw["call"], kw["help"]
314+
del kw["help"], kw["app"]
336315

337316
_serve(app, **kw)
338317
return 0

tests/test_adjustments.py

+42-26
Original file line numberDiff line numberDiff line change
@@ -395,38 +395,57 @@ def assertDictContainsSubset(self, subset, dictionary):
395395
self.assertTrue(set(subset.items()) <= set(dictionary.items()))
396396

397397
def test_noargs(self):
398-
opts, args = self.parse([])
399-
self.assertDictEqual(opts, {"call": False, "help": False})
400-
self.assertSequenceEqual(args, [])
398+
from waitress.adjustments import AppResolutionError
399+
400+
self.assertRaises(AppResolutionError, self.parse, [])
401401

402402
def test_help(self):
403-
opts, args = self.parse(["--help"])
404-
self.assertDictEqual(opts, {"call": False, "help": True})
405-
self.assertSequenceEqual(args, [])
403+
opts = self.parse(["--help"])
404+
self.assertDictEqual(opts, {"help": True, "app": None})
405+
406+
def test_app_flag(self):
407+
from tests.fixtureapps import runner as _apps
408+
409+
opts = self.parse(["--app=tests.fixtureapps.runner:app"])
410+
self.assertEqual(opts["app"], _apps.app)
406411

407412
def test_call(self):
408-
opts, args = self.parse(["--call"])
409-
self.assertDictEqual(opts, {"call": True, "help": False})
410-
self.assertSequenceEqual(args, [])
413+
from tests.fixtureapps import runner as _apps
414+
415+
opts = self.parse(["--app=tests.fixtureapps.runner:returns_app", "--call"])
416+
self.assertEqual(opts["app"], _apps.app)
411417

412-
def test_both(self):
413-
opts, args = self.parse(["--call", "--help"])
414-
self.assertDictEqual(opts, {"call": True, "help": True})
415-
self.assertSequenceEqual(args, [])
418+
def test_app_arg(self):
419+
from tests.fixtureapps import runner as _apps
420+
421+
opts = self.parse(["tests.fixtureapps.runner:app"])
422+
self.assertEqual(opts["app"], _apps.app)
423+
424+
def test_excess(self):
425+
from waitress.adjustments import AppResolutionError
426+
427+
self.assertRaises(
428+
AppResolutionError,
429+
self.parse,
430+
["tests.fixtureapps.runner:app", "tests.fixtureapps.runner:app"],
431+
)
416432

417433
def test_positive_boolean(self):
418-
opts, args = self.parse(["--expose-tracebacks"])
434+
opts = self.parse(["--expose-tracebacks", "tests.fixtureapps.runner:app"])
419435
self.assertDictContainsSubset({"expose_tracebacks": "true"}, opts)
420-
self.assertSequenceEqual(args, [])
421436

422437
def test_negative_boolean(self):
423-
opts, args = self.parse(["--no-expose-tracebacks"])
438+
opts = self.parse(["--no-expose-tracebacks", "tests.fixtureapps.runner:app"])
424439
self.assertDictContainsSubset({"expose_tracebacks": "false"}, opts)
425-
self.assertSequenceEqual(args, [])
426440

427441
def test_cast_params(self):
428-
opts, args = self.parse(
429-
["--host=localhost", "--port=80", "--unix-socket-perms=777"]
442+
opts = self.parse(
443+
[
444+
"--host=localhost",
445+
"--port=80",
446+
"--unix-socket-perms=777",
447+
"tests.fixtureapps.runner:app",
448+
]
430449
)
431450
self.assertDictContainsSubset(
432451
{
@@ -436,28 +455,25 @@ def test_cast_params(self):
436455
},
437456
opts,
438457
)
439-
self.assertSequenceEqual(args, [])
440458

441459
def test_listen_params(self):
442-
opts, args = self.parse(
460+
opts = self.parse(
443461
[
444462
"--listen=test:80",
463+
"tests.fixtureapps.runner:app",
445464
]
446465
)
447-
448466
self.assertDictContainsSubset({"listen": " test:80"}, opts)
449-
self.assertSequenceEqual(args, [])
450467

451468
def test_multiple_listen_params(self):
452-
opts, args = self.parse(
469+
opts = self.parse(
453470
[
454471
"--listen=test:80",
455472
"--listen=test:8080",
473+
"tests.fixtureapps.runner:app",
456474
]
457475
)
458-
459476
self.assertDictContainsSubset({"listen": " test:80 test:8080"}, opts)
460-
self.assertSequenceEqual(args, [])
461477

462478
def test_bad_param(self):
463479
import getopt

0 commit comments

Comments
 (0)