From 16ed1251138ca2404371ea3ffe9f58b7f1bf3778 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Dec 2024 16:54:02 -0600 Subject: [PATCH] fix: use fanstatic to serve built-in images by default Refs: #1 --- docs/conf.py | 1 + docs/glossary.rst | 15 ++++++ pyproject.toml | 4 ++ src/wuttaweb/handler.py | 77 +++++++++++++++++++++++++-- src/wuttaweb/menus.py | 2 +- src/wuttaweb/static/__init__.py | 43 +++++++++++++-- src/wuttaweb/subscribers.py | 11 ++-- src/wuttaweb/templates/base_meta.mako | 6 +-- tests/test_handler.py | 66 +++++++++++++++++++---- tests/util.py | 5 ++ tests/views/test_master.py | 4 +- 11 files changed, 206 insertions(+), 28 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 7212f98..cf15be8 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -29,6 +29,7 @@ exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] intersphinx_mapping = { 'colander': ('https://docs.pylonsproject.org/projects/colander/en/latest/', None), 'deform': ('https://docs.pylonsproject.org/projects/deform/en/latest/', None), + 'fanstatic': ('https://www.fanstatic.org/en/latest/', None), 'pyramid': ('https://docs.pylonsproject.org/projects/pyramid/en/latest/', None), 'python': ('https://docs.python.org/3/', None), 'rattail-manual': ('https://rattailproject.org/docs/rattail-manual/', None), diff --git a/docs/glossary.rst b/docs/glossary.rst index 0626c00..6eb7c56 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -13,3 +13,18 @@ Glossary tools. See also the :class:`~wuttaweb.grids.base.Grid` base class. + + menu handler + This is the :term:`handler` responsible for constructing the main + app menu at top of page. + + The menu handler is accessed by way of the :term:`web handler`. + + See also the :class:`~wuttaweb.menus.MenuHandler` base class. + + web handler + This is the :term:`handler` responsible for overall web layer + customizations, e.g. logo image and menu overrides. Although + the latter it delegates to the :term:`menu handler`. + + See also the :class:`~wuttaweb.handler.WebHandler` base class. diff --git a/pyproject.toml b/pyproject.toml index 40ee298..0a0435c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,10 @@ docs = ["Sphinx", "furo"] tests = ["pytest-cov", "tox"] +[project.entry-points."fanstatic.libraries"] +wuttaweb_img = "wuttaweb.static:img" + + [project.entry-points."paste.app_factory"] main = "wuttaweb.app:main" diff --git a/src/wuttaweb/handler.py b/src/wuttaweb/handler.py index d0fa704..f5b4d71 100644 --- a/src/wuttaweb/handler.py +++ b/src/wuttaweb/handler.py @@ -22,21 +22,88 @@ ################################################################################ """ Web Handler - -This defines the :term:`handler` for the web layer. """ from wuttjamaican.app import GenericHandler +from wuttaweb import static + class WebHandler(GenericHandler): """ - Base class and default implementation for the "web" :term:`handler`. + Base class and default implementation for the :term:`web handler`. - This is responsible for determining the "menu handler" and - (eventually) possibly other things. + This is responsible for determining the :term:`menu handler` and + various other customizations. """ + def get_fanstatic_url(self, request, resource): + """ + Returns the full URL to the given Fanstatic resource. + + :param request: Current :term:`request` object. + + :param resource: :class:`fanstatic:fanstatic.Resource` + instance representing an image file or other resource. + """ + needed = request.environ['fanstatic.needed'] + url = needed.library_url(resource.library) + '/' + if request.script_name: + url = request.script_name + url + return url + resource.relpath + + def get_favicon_url(self, request): + """ + Returns the canonical app favicon image URL. + + This will return the fallback favicon from WuttaWeb unless + config specifies an override: + + .. code-block:: ini + + [wuttaweb] + favicon_url = http://example.com/favicon.ico + """ + url = self.config.get('wuttaweb.favicon_url') + if url: + return url + return self.get_fanstatic_url(request, static.favicon) + + def get_header_logo_url(self, request): + """ + Returns the canonical app header image URL. + + This will return the value from config if specified (as shown + below); otherwise it will just call :meth:`get_favicon_url()` + and return that. + + .. code-block:: ini + + [wuttaweb] + header_logo_url = http://example.com/logo.png + """ + url = self.config.get('wuttaweb.header_logo_url') + if url: + return url + return self.get_favicon_url(request) + + def get_main_logo_url(self, request): + """ + Returns the canonical app logo image URL. + + This will return the fallback logo from WuttaWeb unless config + specifies an override: + + .. code-block:: ini + + [wuttaweb] + logo_url = http://example.com/logo.png + """ + url = self.config.get('wuttaweb.logo_url') + if url: + return url + return self.get_fanstatic_url(request, static.logo) + def get_menu_handler(self, **kwargs): """ Get the configured "menu" handler for the web app. diff --git a/src/wuttaweb/menus.py b/src/wuttaweb/menus.py index 8e824be..80b6c53 100644 --- a/src/wuttaweb/menus.py +++ b/src/wuttaweb/menus.py @@ -35,7 +35,7 @@ log = logging.getLogger(__name__) class MenuHandler(GenericHandler): """ - Base class and default implementation for menu handler. + Base class and default implementation for :term:`menu handler`. It is assumed that most apps will override the menu handler with their own subclass. In particular the subclass will override diff --git a/src/wuttaweb/static/__init__.py b/src/wuttaweb/static/__init__.py index a2e7012..dd1ff45 100644 --- a/src/wuttaweb/static/__init__.py +++ b/src/wuttaweb/static/__init__.py @@ -23,15 +23,52 @@ """ Static Assets -It is assumed that all (i.e. even custom) apps will include this -module somewhere during startup. For instance this happens within -:func:`~wuttaweb.app.main()`:: +Note that (for now?) It is assumed that *all* (i.e. even custom) apps +will include this module somewhere during startup. For instance this +happens within :func:`wuttaweb.app.main()`:: pyramid_config.include('wuttaweb.static') This allows for certain common assets to be available for all apps. + +However, an attempt is being made to incorporate Fanstatic for use +with the built-in static assets. It is possible the above mechanism +could be abandoned in the future. + +So on the Fanstatic front, we currently have defined: + +.. data:: img + + A :class:`fanstatic:fanstatic.Library` representing the ``img`` + static folder. + +.. data:: favicon + + A :class:`fanstatic:fanstatic.Resource` representing the + ``img/favicon.ico`` image file. + +.. data:: logo + + A :class:`fanstatic:fanstatic.Resource` representing the + ``img/logo.png`` image file. + +.. data:: testing + + A :class:`fanstatic:fanstatic.Resource` representing the + ``img/testing.png`` image file. """ +from fanstatic import Library, Resource + +# fanstatic img library +img = Library('wuttaweb_img', 'img') +favicon = Resource(img, 'favicon.ico') +# nb. mock out the renderers here, to appease fanstatic +logo = Resource(img, 'logo.png', renderer=True) +testing = Resource(img, 'testing.png', renderer=True) + + +# TODO: should consider deprecating this? def includeme(config): config.add_static_view('wuttaweb', 'wuttaweb:static') diff --git a/src/wuttaweb/subscribers.py b/src/wuttaweb/subscribers.py index 8bbaaf0..79fefd2 100644 --- a/src/wuttaweb/subscribers.py +++ b/src/wuttaweb/subscribers.py @@ -260,13 +260,17 @@ def before_render(event): Here are the keys added to context dict by this hook: + .. data:: 'config' + + Reference to the app :term:`config object`. + .. data:: 'app' Reference to the :term:`app handler`. - .. data:: 'config' + .. data:: 'web' - Reference to the app :term:`config object`. + Reference to the :term:`web handler`. .. data:: 'h' @@ -293,8 +297,9 @@ def before_render(event): web = app.get_web_handler() context = event - context['app'] = app context['config'] = config + context['app'] = app + context['web'] = web context['h'] = helpers context['url'] = request.route_url context['json'] = json diff --git a/src/wuttaweb/templates/base_meta.mako b/src/wuttaweb/templates/base_meta.mako index 67739fa..d98445c 100644 --- a/src/wuttaweb/templates/base_meta.mako +++ b/src/wuttaweb/templates/base_meta.mako @@ -5,15 +5,15 @@ <%def name="extra_styles()"> <%def name="favicon()"> - + <%def name="header_logo()"> - ${h.image(config.get('wuttaweb.header_logo_url', default=request.static_url('wuttaweb:static/img/favicon.ico')), "Header Logo", style="height: 49px;")} + ${h.image(web.get_header_logo_url(request), "Header Logo", style="height: 49px;")} <%def name="full_logo(image_url=None)"> - ${h.image(image_url or config.get('wuttaweb.logo_url', default=request.static_url('wuttaweb:static/img/logo.png')), f"{app.get_title()} logo")} + ${h.image(image_url or web.get_main_logo_url(request), f"App Logo for {app.get_title()}")} <%def name="footer()"> diff --git a/tests/test_handler.py b/tests/test_handler.py index 79a0a64..9c4037f 100644 --- a/tests/test_handler.py +++ b/tests/test_handler.py @@ -1,20 +1,64 @@ # -*- coding: utf-8; -*- -from unittest import TestCase - -from wuttjamaican.conf import WuttaConfig - -from wuttaweb import handler as mod +from wuttaweb import handler as mod, static from wuttaweb.menus import MenuHandler +from tests.util import WebTestCase -class TestWebHandler(TestCase): +class TestWebHandler(WebTestCase): - def setUp(self): - self.config = WuttaConfig() - self.app = self.config.get_app() - self.handler = mod.WebHandler(self.config) + def make_handler(self): + return mod.WebHandler(self.config) + + def test_get_fanstatic_url(self): + handler = self.make_handler() + + # default with / root path + url = handler.get_fanstatic_url(self.request, static.logo) + self.assertEqual(url, '/fanstatic/wuttaweb_img/logo.png') + + # what about a subpath + self.request.script_name = '/testing' + url = handler.get_fanstatic_url(self.request, static.logo) + self.assertEqual(url, '/testing/fanstatic/wuttaweb_img/logo.png') + + def test_get_favicon_url(self): + handler = self.make_handler() + + # default + url = handler.get_favicon_url(self.request) + self.assertEqual(url, '/fanstatic/wuttaweb_img/favicon.ico') + + # config override + self.config.setdefault('wuttaweb.favicon_url', '/testing/other.ico') + url = handler.get_favicon_url(self.request) + self.assertEqual(url, '/testing/other.ico') + + def test_get_header_logo_url(self): + handler = self.make_handler() + + # default + url = handler.get_header_logo_url(self.request) + self.assertEqual(url, '/fanstatic/wuttaweb_img/favicon.ico') + + # config override + self.config.setdefault('wuttaweb.header_logo_url', '/testing/header.png') + url = handler.get_header_logo_url(self.request) + self.assertEqual(url, '/testing/header.png') + + def test_get_main_logo_url(self): + handler = self.make_handler() + + # default + url = handler.get_main_logo_url(self.request) + self.assertEqual(url, '/fanstatic/wuttaweb_img/logo.png') + + # config override + self.config.setdefault('wuttaweb.logo_url', '/testing/other.png') + url = handler.get_main_logo_url(self.request) + self.assertEqual(url, '/testing/other.png') def test_menu_handler_default(self): - menus = self.handler.get_menu_handler() + handler = self.make_handler() + menus = handler.get_menu_handler() self.assertIsInstance(menus, MenuHandler) diff --git a/tests/util.py b/tests/util.py index 51a5768..e292253 100644 --- a/tests/util.py +++ b/tests/util.py @@ -3,6 +3,7 @@ from unittest import TestCase from unittest.mock import MagicMock +import fanstatic from pyramid import testing from wuttjamaican.conf import WuttaConfig @@ -66,6 +67,10 @@ class WebTestCase(DataTestCase): 'pyramid.events.BeforeRender') self.pyramid_config.include('wuttaweb.static') + # nb. mock out fanstatic env..good enough for now to avoid errors.. + needed = fanstatic.init_needed() + self.request.environ[fanstatic.NEEDED] = needed + # setup new request w/ anonymous user event = MagicMock(request=self.request) subscribers.new_request(event) diff --git a/tests/views/test_master.py b/tests/views/test_master.py index 7e427e9..8e451ee 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -1028,7 +1028,7 @@ class TestMasterView(WebTestCase): self.session.commit() def get_instance(): - setting = self.session.query(model.Setting).get('foo.bar') + setting = self.session.get(model.Setting, 'foo.bar') return { 'name': setting.name, 'value': setting.value, @@ -1092,7 +1092,7 @@ class TestMasterView(WebTestCase): self.assertEqual(self.session.query(model.Setting).count(), 1) def get_instance(): - setting = self.session.query(model.Setting).get('foo.bar') + setting = self.session.get(model.Setting, 'foo.bar') return { 'name': setting.name, 'value': setting.value,