diff --git a/docs/api/wuttaweb.cli.rst b/docs/api/wuttaweb.cli.rst new file mode 100644 index 0000000..222cd33 --- /dev/null +++ b/docs/api/wuttaweb.cli.rst @@ -0,0 +1,6 @@ + +``wuttaweb.cli`` +================ + +.. automodule:: wuttaweb.cli + :members: diff --git a/docs/api/wuttaweb.cli.webapp.rst b/docs/api/wuttaweb.cli.webapp.rst new file mode 100644 index 0000000..5f1663d --- /dev/null +++ b/docs/api/wuttaweb.cli.webapp.rst @@ -0,0 +1,6 @@ + +``wuttaweb.cli.webapp`` +======================= + +.. automodule:: wuttaweb.cli.webapp + :members: diff --git a/docs/conf.py b/docs/conf.py index 8bf7c8e..25df2b8 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -21,6 +21,7 @@ extensions = [ 'sphinx.ext.intersphinx', 'sphinx.ext.viewcode', 'sphinx.ext.todo', + 'sphinxcontrib.programoutput', ] templates_path = ['_templates'] diff --git a/docs/index.rst b/docs/index.rst index ce74ae6..e7f07f0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -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 diff --git a/docs/narr/cli/builtin.rst b/docs/narr/cli/builtin.rst new file mode 100644 index 0000000..a0f6ab1 --- /dev/null +++ b/docs/narr/cli/builtin.rst @@ -0,0 +1,28 @@ + +=================== + Built-in Commands +=================== + +Below are the :term:`subcommands ` 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 +`_ but by default will run +whatever :ref:`pserve ` is setup to do (which +usually is `waitress +`_). + +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 diff --git a/docs/narr/cli/index.rst b/docs/narr/cli/index.rst new file mode 100644 index 0000000..305f5e9 --- /dev/null +++ b/docs/narr/cli/index.rst @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 1168ddc..6a2d965 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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/" diff --git a/src/wuttaweb/app.py b/src/wuttaweb/app.py index c263b60..912a5d1 100644 --- a/src/wuttaweb/app.py +++ b/src/wuttaweb/app.py @@ -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) diff --git a/src/wuttaweb/cli/__init__.py b/src/wuttaweb/cli/__init__.py new file mode 100644 index 0000000..240ce5f --- /dev/null +++ b/src/wuttaweb/cli/__init__.py @@ -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 . +# +################################################################################ +""" +WuttaWeb - ``wutta`` subcommands +""" + +# nb. must bring in all modules for discovery to work +from . import webapp diff --git a/src/wuttaweb/cli/webapp.py b/src/wuttaweb/cli/webapp.py new file mode 100644 index 0000000..5f17052 --- /dev/null +++ b/src/wuttaweb/cli/webapp.py @@ -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 . +# +################################################################################ +""" +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) diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/test_webapp.py b/tests/cli/test_webapp.py new file mode 100644 index 0000000..1e23752 --- /dev/null +++ b/tests/cli/test_webapp.py @@ -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') diff --git a/tests/test_app.py b/tests/test_app.py index 5ce8e94..a38d5e6 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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)