3
0
Fork 0

feat: add support for running via uvicorn; wutta webapp command

This commit is contained in:
Lance Edgar 2024-12-18 11:28:08 -06:00
parent 3fabc0a141
commit b6d5ffa8ce
13 changed files with 408 additions and 28 deletions

View file

@ -0,0 +1,6 @@
``wuttaweb.cli``
================
.. automodule:: wuttaweb.cli
:members:

View file

@ -0,0 +1,6 @@
``wuttaweb.cli.webapp``
=======================
.. automodule:: wuttaweb.cli.webapp
:members:

View file

@ -21,6 +21,7 @@ extensions = [
'sphinx.ext.intersphinx',
'sphinx.ext.viewcode',
'sphinx.ext.todo',
'sphinxcontrib.programoutput',
]
templates_path = ['_templates']

View file

@ -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
View 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
View 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

View file

@ -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/"

View file

@ -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,6 +91,9 @@ def make_wutta_config(settings, config_maker=None, **kwargs):
If this config file path cannot be discovered, an error is raised.
"""
wutta_config = settings.get('wutta_config')
if not wutta_config:
# validate config file path
path = settings.get('wutta.config')
if not path or not os.path.exists(path):
@ -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)

View 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

View 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
View file

104
tests/cli/test_webapp.py Normal file
View 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')

View file

@ -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)