feat: add support for running via uvicorn; wutta webapp
command
This commit is contained in:
parent
3fabc0a141
commit
b6d5ffa8ce
6
docs/api/wuttaweb.cli.rst
Normal file
6
docs/api/wuttaweb.cli.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
``wuttaweb.cli``
|
||||
================
|
||||
|
||||
.. automodule:: wuttaweb.cli
|
||||
:members:
|
6
docs/api/wuttaweb.cli.webapp.rst
Normal file
6
docs/api/wuttaweb.cli.webapp.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
``wuttaweb.cli.webapp``
|
||||
=======================
|
||||
|
||||
.. automodule:: wuttaweb.cli.webapp
|
||||
:members:
|
|
@ -21,6 +21,7 @@ extensions = [
|
|||
'sphinx.ext.intersphinx',
|
||||
'sphinx.ext.viewcode',
|
||||
'sphinx.ext.todo',
|
||||
'sphinxcontrib.programoutput',
|
||||
]
|
||||
|
||||
templates_path = ['_templates']
|
||||
|
|
|
@ -19,6 +19,7 @@ the narrative docs are pretty scant. That will eventually change.
|
|||
:caption: Documentation:
|
||||
|
||||
glossary
|
||||
narr/cli/index
|
||||
narr/templates/index
|
||||
|
||||
.. toctree::
|
||||
|
@ -28,6 +29,8 @@ the narrative docs are pretty scant. That will eventually change.
|
|||
api/wuttaweb
|
||||
api/wuttaweb.app
|
||||
api/wuttaweb.auth
|
||||
api/wuttaweb.cli
|
||||
api/wuttaweb.cli.webapp
|
||||
api/wuttaweb.conf
|
||||
api/wuttaweb.db
|
||||
api/wuttaweb.db.continuum
|
||||
|
|
28
docs/narr/cli/builtin.rst
Normal file
28
docs/narr/cli/builtin.rst
Normal file
|
@ -0,0 +1,28 @@
|
|||
|
||||
===================
|
||||
Built-in Commands
|
||||
===================
|
||||
|
||||
Below are the :term:`subcommands <subcommand>` which come with
|
||||
WuttaWeb.
|
||||
|
||||
|
||||
.. _wutta-webapp:
|
||||
|
||||
``wutta webapp``
|
||||
----------------
|
||||
|
||||
Run the web app, according to config file(s).
|
||||
|
||||
This command is a convenience only; under the hood it can run `uvicorn
|
||||
<https://www.uvicorn.org/#uvicornrun>`_ but by default will run
|
||||
whatever :ref:`pserve <pyramid:pserve_script>` is setup to do (which
|
||||
usually is `waitress
|
||||
<https://docs.pylonsproject.org/projects/waitress/en/latest/index.html>`_).
|
||||
|
||||
Ultimately it's all up to config, so run different web apps with
|
||||
different config files.
|
||||
|
||||
Defined in: :mod:`wuttaweb.cli.webapp`
|
||||
|
||||
.. program-output:: wutta webapp --help
|
14
docs/narr/cli/index.rst
Normal file
14
docs/narr/cli/index.rst
Normal file
|
@ -0,0 +1,14 @@
|
|||
|
||||
==============
|
||||
Command Line
|
||||
==============
|
||||
|
||||
There isn't much to the command line for WuttaWeb, but here it is.
|
||||
|
||||
For more general info about CLI see
|
||||
:doc:`wuttjamaican:narr/cli/index`.
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
builtin
|
|
@ -50,25 +50,25 @@ dependencies = [
|
|||
|
||||
[project.optional-dependencies]
|
||||
continuum = ["Wutta-Continuum"]
|
||||
docs = ["Sphinx", "furo"]
|
||||
docs = ["Sphinx", "furo", "sphinxcontrib-programoutput"]
|
||||
tests = ["pytest-cov", "tox"]
|
||||
|
||||
|
||||
[project.entry-points."fanstatic.libraries"]
|
||||
wuttaweb_img = "wuttaweb.static:img"
|
||||
|
||||
|
||||
[project.entry-points."paste.app_factory"]
|
||||
main = "wuttaweb.app:main"
|
||||
|
||||
|
||||
[project.entry-points."wutta.app.providers"]
|
||||
wuttaweb = "wuttaweb.app:WebAppProvider"
|
||||
|
||||
|
||||
[project.entry-points."wutta.config.extensions"]
|
||||
wuttaweb = "wuttaweb.conf:WuttaWebConfigExtension"
|
||||
|
||||
[project.entry-points."wutta.typer_imports"]
|
||||
wuttaweb = "wuttaweb.cli"
|
||||
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://wuttaproject.org/"
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
Application
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from wuttjamaican.app import AppProvider
|
||||
|
@ -35,6 +36,9 @@ import wuttaweb.db
|
|||
from wuttaweb.auth import WuttaSecurityPolicy
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebAppProvider(AppProvider):
|
||||
"""
|
||||
The :term:`app provider` for WuttaWeb. This adds some methods to
|
||||
|
@ -87,17 +91,20 @@ def make_wutta_config(settings, config_maker=None, **kwargs):
|
|||
|
||||
If this config file path cannot be discovered, an error is raised.
|
||||
"""
|
||||
# validate config file path
|
||||
path = settings.get('wutta.config')
|
||||
if not path or not os.path.exists(path):
|
||||
raise ValueError("Please set 'wutta.config' in [app:main] "
|
||||
"section of config to the path of your "
|
||||
"config file. Lame, but necessary.")
|
||||
wutta_config = settings.get('wutta_config')
|
||||
if not wutta_config:
|
||||
|
||||
# make config, add to settings
|
||||
config_maker = config_maker or make_config
|
||||
wutta_config = config_maker(path, **kwargs)
|
||||
settings['wutta_config'] = wutta_config
|
||||
# validate config file path
|
||||
path = settings.get('wutta.config')
|
||||
if not path or not os.path.exists(path):
|
||||
raise ValueError("Please set 'wutta.config' in [app:main] "
|
||||
"section of config to the path of your "
|
||||
"config file. Lame, but necessary.")
|
||||
|
||||
# make config, add to settings
|
||||
config_maker = config_maker or make_config
|
||||
wutta_config = config_maker(path, **kwargs)
|
||||
settings['wutta_config'] = wutta_config
|
||||
|
||||
# configure database sessions
|
||||
if hasattr(wutta_config, 'appdb_engine'):
|
||||
|
@ -148,19 +155,18 @@ def make_pyramid_config(settings):
|
|||
|
||||
def main(global_config, **settings):
|
||||
"""
|
||||
This function returns a Pyramid WSGI application.
|
||||
Make and return the WSGI application, per given settings.
|
||||
|
||||
Typically there is no need to call this function directly, but it
|
||||
may be configured as the web app entry point like so:
|
||||
This function is designed to be called via Paste, hence it does
|
||||
require params and therefore can't be used directly as app factory
|
||||
for general WSGI servers. For the latter see
|
||||
:func:`make_wsgi_app()` instead.
|
||||
|
||||
.. code-block:: ini
|
||||
|
||||
[app:main]
|
||||
use = egg:wuttaweb
|
||||
|
||||
The app returned by this function is quite minimal, so most apps
|
||||
will need to define their own ``main()`` function, and use that
|
||||
instead.
|
||||
And this *particular* function is not even that useful, it only
|
||||
constructs an app with minimal views built-in to WuttaWeb. Most
|
||||
apps will define their own ``main()`` function (e.g. as
|
||||
``poser.web.app:main``), similar to this one but with additional
|
||||
views and other config.
|
||||
"""
|
||||
wutta_config = make_wutta_config(settings)
|
||||
pyramid_config = make_pyramid_config(settings)
|
||||
|
@ -170,3 +176,58 @@ def main(global_config, **settings):
|
|||
pyramid_config.include('wuttaweb.views')
|
||||
|
||||
return pyramid_config.make_wsgi_app()
|
||||
|
||||
|
||||
def make_wsgi_app(main_app=None, config=None):
|
||||
"""
|
||||
Make and return a WSGI app, using the given Paste app factory.
|
||||
|
||||
This function could be used directly for general WSGI servers
|
||||
(e.g. uvicorn), ***if*** you just want the built-in :func:`main()`
|
||||
app factory.
|
||||
|
||||
But most likely you do not, in which case you must define your own
|
||||
function and call this one with your preferred app factory::
|
||||
|
||||
from wuttaweb.app import make_wsgi_app
|
||||
|
||||
def my_main(global_config, **settings):
|
||||
# TODO: build your app
|
||||
pass
|
||||
|
||||
def make_my_wsgi_app():
|
||||
return make_wsgi_app(my_main)
|
||||
|
||||
So ``make_my_wsgi_app()`` could then be used as-is for general
|
||||
WSGI servers. However, note that this approach will require
|
||||
setting the ``WUTTA_CONFIG_FILES`` environment variable, unless
|
||||
running via :ref:`wutta-webapp`.
|
||||
|
||||
:param main_app: Either a Paste-compatible app factory, or
|
||||
:term:`spec` for one. If not specified, the built-in
|
||||
:func:`main()` is assumed.
|
||||
|
||||
:param config: Optional :term:`config object`. If not specified,
|
||||
one is created based on ``WUTTA_CONFIG_FILES`` environment
|
||||
variable.
|
||||
"""
|
||||
if not config:
|
||||
config = make_config()
|
||||
app = config.get_app()
|
||||
|
||||
# extract pyramid settings
|
||||
settings = config.get_dict('app:main')
|
||||
|
||||
# keep same config object
|
||||
settings['wutta_config'] = config
|
||||
|
||||
# determine the app factory
|
||||
if isinstance(main_app, str):
|
||||
make_wsgi_app = app.load_object(main_app)
|
||||
elif callable(main_app):
|
||||
make_wsgi_app = main_app
|
||||
else:
|
||||
raise ValueError("main_app must be spec or callable")
|
||||
|
||||
# construct a pyramid app "per usual"
|
||||
return make_wsgi_app({}, **settings)
|
||||
|
|
28
src/wuttaweb/cli/__init__.py
Normal file
28
src/wuttaweb/cli/__init__.py
Normal file
|
@ -0,0 +1,28 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# wuttaweb -- Web App for Wutta Framework
|
||||
# Copyright © 2024 Lance Edgar
|
||||
#
|
||||
# This file is part of Wutta Framework.
|
||||
#
|
||||
# Wutta Framework is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU General Public License as published by the Free
|
||||
# Software Foundation, either version 3 of the License, or (at your option) any
|
||||
# later version.
|
||||
#
|
||||
# Wutta Framework is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
# more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
WuttaWeb - ``wutta`` subcommands
|
||||
"""
|
||||
|
||||
# nb. must bring in all modules for discovery to work
|
||||
from . import webapp
|
88
src/wuttaweb/cli/webapp.py
Normal file
88
src/wuttaweb/cli/webapp.py
Normal file
|
@ -0,0 +1,88 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# wuttaweb -- Web App for Wutta Framework
|
||||
# Copyright © 2024 Lance Edgar
|
||||
#
|
||||
# This file is part of Wutta Framework.
|
||||
#
|
||||
# Wutta Framework is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU General Public License as published by the Free
|
||||
# Software Foundation, either version 3 of the License, or (at your option) any
|
||||
# later version.
|
||||
#
|
||||
# Wutta Framework is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
# more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
See also: :ref:`wutta-webapp`
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing_extensions import Annotated
|
||||
|
||||
import typer
|
||||
from pyramid.scripts import pserve
|
||||
|
||||
from wuttjamaican.cli import wutta_typer
|
||||
|
||||
|
||||
@wutta_typer.command()
|
||||
def webapp(
|
||||
ctx: typer.Context,
|
||||
auto_reload: Annotated[
|
||||
bool,
|
||||
typer.Option('--reload', '-r',
|
||||
help="Auto-reload web app when files change.")] = False,
|
||||
):
|
||||
"""
|
||||
Run the configured web app
|
||||
"""
|
||||
config = ctx.parent.wutta_config
|
||||
|
||||
# we'll need config file(s) to specify for web app
|
||||
if not config.files_read:
|
||||
sys.stderr.write("no config files found!\n")
|
||||
sys.exit(1)
|
||||
|
||||
runner = config.get(f'{config.appname}.web.app.runner', default='pserve')
|
||||
if runner == 'pserve':
|
||||
|
||||
# run pserve
|
||||
argv = ['pserve', f'file+ini:{config.files_read[0]}']
|
||||
if ctx.params['auto_reload']:
|
||||
argv.append('--reload')
|
||||
pserve.main(argv=argv)
|
||||
|
||||
elif runner == 'uvicorn':
|
||||
|
||||
import uvicorn
|
||||
|
||||
# need service details from config
|
||||
spec = config.require(f'{config.appname}.web.app.spec')
|
||||
kw = {
|
||||
'host': config.get(f'{config.appname}.web.app.host', default='127.0.0.1'),
|
||||
'port': config.get_int(f'{config.appname}.web.app.port', default=8000),
|
||||
'reload': ctx.params['auto_reload'],
|
||||
'reload_dirs': config.get_list(f'{config.appname}.web.app.reload_dirs'),
|
||||
'factory': config.get_bool(f'{config.appname}.web.app.factory', default=False),
|
||||
'interface': config.get(f'{config.appname}.web.app.interface', default='auto'),
|
||||
}
|
||||
|
||||
# also must inject our config files to env, since there is no
|
||||
# other way to specify when running via uvicorn
|
||||
os.environ['WUTTA_CONFIG_FILES'] = os.pathsep.join(config.files_read)
|
||||
|
||||
# run uvicorn
|
||||
uvicorn.run(spec, **kw)
|
||||
|
||||
else:
|
||||
sys.stderr.write(f"unknown web app runner: {runner}\n")
|
||||
sys.exit(2)
|
0
tests/cli/__init__.py
Normal file
0
tests/cli/__init__.py
Normal file
104
tests/cli/test_webapp.py
Normal file
104
tests/cli/test_webapp.py
Normal file
|
@ -0,0 +1,104 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from wuttjamaican.testing import ConfigTestCase
|
||||
|
||||
from wuttaweb.cli import webapp as mod
|
||||
|
||||
|
||||
class TestWebapp(ConfigTestCase):
|
||||
|
||||
def make_context(self, **kwargs):
|
||||
params = {'auto_reload': False}
|
||||
params.update(kwargs.get('params', {}))
|
||||
ctx = MagicMock(params=params)
|
||||
ctx.parent.wutta_config = self.config
|
||||
return ctx
|
||||
|
||||
def test_missing_config_file(self):
|
||||
# nb. our default config has no files, so can test w/ that
|
||||
ctx = self.make_context()
|
||||
with patch.object(mod, 'sys') as sys:
|
||||
sys.exit.side_effect = RuntimeError
|
||||
self.assertRaises(RuntimeError, mod.webapp, ctx)
|
||||
sys.stderr.write.assert_called_once_with("no config files found!\n")
|
||||
sys.exit.assert_called_once_with(1)
|
||||
|
||||
def test_invalid_runner(self):
|
||||
|
||||
# make new config from file, with bad setting
|
||||
path = self.write_file('my.conf', """
|
||||
[wutta.web]
|
||||
app.runner = bogus
|
||||
""")
|
||||
self.config = self.make_config(files=[path])
|
||||
|
||||
ctx = self.make_context()
|
||||
with patch.object(mod, 'sys') as sys:
|
||||
sys.exit.side_effect = RuntimeError
|
||||
self.assertRaises(RuntimeError, mod.webapp, ctx)
|
||||
sys.stderr.write.assert_called_once_with("unknown web app runner: bogus\n")
|
||||
sys.exit.assert_called_once_with(2)
|
||||
|
||||
def test_pserve(self):
|
||||
|
||||
path = self.write_file('my.conf', """
|
||||
[wutta.web]
|
||||
app.runner = pserve
|
||||
""")
|
||||
self.config = self.make_config(files=[path])
|
||||
|
||||
# normal
|
||||
with patch.object(mod, 'pserve') as pserve:
|
||||
ctx = self.make_context()
|
||||
mod.webapp(ctx)
|
||||
pserve.main.assert_called_once_with(argv=['pserve', f'file+ini:{path}'])
|
||||
|
||||
# with reload
|
||||
with patch.object(mod, 'pserve') as pserve:
|
||||
ctx = self.make_context(params={'auto_reload': True})
|
||||
mod.webapp(ctx)
|
||||
pserve.main.assert_called_once_with(argv=['pserve', f'file+ini:{path}', '--reload'])
|
||||
|
||||
def test_uvicorn(self):
|
||||
|
||||
path = self.write_file('my.conf', """
|
||||
[wutta.web]
|
||||
app.runner = uvicorn
|
||||
app.spec = wuttaweb.app:make_wsgi_app
|
||||
""")
|
||||
self.config = self.make_config(files=[path])
|
||||
|
||||
orig_import = __import__
|
||||
uvicorn = MagicMock()
|
||||
|
||||
def mock_import(name, *args, **kwargs):
|
||||
if name == 'uvicorn':
|
||||
return uvicorn
|
||||
return orig_import(name, *args, **kwargs)
|
||||
|
||||
# normal
|
||||
with patch('builtins.__import__', side_effect=mock_import):
|
||||
ctx = self.make_context()
|
||||
mod.webapp(ctx)
|
||||
uvicorn.run.assert_called_once_with('wuttaweb.app:make_wsgi_app',
|
||||
host='127.0.0.1',
|
||||
port=8000,
|
||||
reload=False,
|
||||
reload_dirs=None,
|
||||
factory=False,
|
||||
interface='auto')
|
||||
|
||||
# with reload
|
||||
uvicorn.run.reset_mock()
|
||||
with patch('builtins.__import__', side_effect=mock_import):
|
||||
ctx = self.make_context(params={'auto_reload': True})
|
||||
mod.webapp(ctx)
|
||||
uvicorn.run.assert_called_once_with('wuttaweb.app:make_wsgi_app',
|
||||
host='127.0.0.1',
|
||||
port=8000,
|
||||
reload=True,
|
||||
reload_dirs=None,
|
||||
factory=False,
|
||||
interface='auto')
|
|
@ -1,8 +1,9 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
|
||||
from unittest import TestCase
|
||||
from unittest.mock import patch
|
||||
|
||||
from wuttjamaican.testing import FileConfigTestCase
|
||||
from wuttjamaican.testing import FileTestCase, ConfigTestCase
|
||||
|
||||
from pyramid.config import Configurator
|
||||
from pyramid.router import Router
|
||||
|
@ -21,7 +22,7 @@ class TestWebAppProvider(TestCase):
|
|||
handler = app.get_web_handler()
|
||||
|
||||
|
||||
class TestMakeWuttaConfig(FileConfigTestCase):
|
||||
class TestMakeWuttaConfig(FileTestCase):
|
||||
|
||||
def test_config_path_required(self):
|
||||
|
||||
|
@ -51,7 +52,7 @@ class TestMakePyramidConfig(TestCase):
|
|||
self.assertIsInstance(config, Configurator)
|
||||
|
||||
|
||||
class TestMain(FileConfigTestCase):
|
||||
class TestMain(FileTestCase):
|
||||
|
||||
def test_basic(self):
|
||||
global_config = None
|
||||
|
@ -59,3 +60,43 @@ class TestMain(FileConfigTestCase):
|
|||
settings = {'wutta.config': myconf}
|
||||
app = mod.main(global_config, **settings)
|
||||
self.assertIsInstance(app, Router)
|
||||
|
||||
|
||||
def mock_main(global_config, **settings):
|
||||
|
||||
wutta_config = mod.make_wutta_config(settings)
|
||||
pyramid_config = mod.make_pyramid_config(settings)
|
||||
|
||||
pyramid_config.include('wuttaweb.static')
|
||||
pyramid_config.include('wuttaweb.subscribers')
|
||||
pyramid_config.include('wuttaweb.views')
|
||||
|
||||
return pyramid_config.make_wsgi_app()
|
||||
|
||||
|
||||
class TestMakeWsgiApp(ConfigTestCase):
|
||||
|
||||
def test_with_callable(self):
|
||||
|
||||
# specify config
|
||||
wsgi = mod.make_wsgi_app(mock_main, config=self.config)
|
||||
self.assertIsInstance(wsgi, Router)
|
||||
|
||||
# auto config
|
||||
with patch.object(mod, 'make_config', return_value=self.config):
|
||||
wsgi = mod.make_wsgi_app(mock_main)
|
||||
self.assertIsInstance(wsgi, Router)
|
||||
|
||||
def test_with_spec(self):
|
||||
|
||||
# specify config
|
||||
wsgi = mod.make_wsgi_app('tests.test_app:mock_main', config=self.config)
|
||||
self.assertIsInstance(wsgi, Router)
|
||||
|
||||
# auto config
|
||||
with patch.object(mod, 'make_config', return_value=self.config):
|
||||
wsgi = mod.make_wsgi_app('tests.test_app:mock_main')
|
||||
self.assertIsInstance(wsgi, Router)
|
||||
|
||||
def test_invalid(self):
|
||||
self.assertRaises(ValueError, mod.make_wsgi_app, 42, config=self.config)
|
||||
|
|
Loading…
Reference in a new issue