diff --git a/coverage-notes.md b/coverage-notes.md new file mode 100644 index 0000000..1f8755d --- /dev/null +++ b/coverage-notes.md @@ -0,0 +1,115 @@ +# Test Coverage Notes + +This document records the deliberate gaps in `flask_hypergen`'s test coverage: one +`xfail` placeholder carried over from the original `django-hypergen` test suite, and a +small number of source lines/branches that are intentionally left uncovered. + +These are documented (rather than tested) because exercising them would require +behavior that flask_hypergen does not implement, or contrived environments (optional +dependencies uninstalled, internal invariants forced into impossible states) that would +add maintenance cost without testing anything meaningful. + +## Intentional `xfail`: Django legacy middleware placeholder + +`tests/flask_hypergen_tests/test_core_ported.py` contains: + +```python +@pytest.mark.xfail( + reason='Django legacy middleware compatibility is intentionally not part of flask_hypergen', +) +def test_context_middleware_old(): + raise AssertionError() +``` + +### Background + +The upstream `django-hypergen` project supported Django's *old-style* middleware via +`django.utils.deprecation.MiddlewareMixin`. Its `context.py` did: + +```python +try: + from django.utils.deprecation import MiddlewareMixin +except ImportError: + MiddlewareMixin = object # Backwards compatibility. + +class ContextMiddleware(MiddlewareMixin): + def process_request(self, request): + context.replace(**_init_context(request)) +``` + +The corresponding upstream test (`test_context_middleware_old`) only ran on Django 3 or +earlier and asserted that the legacy `MiddlewareMixin`-based path populated the context: + +```python +def test_context_middleware_old(): + if int(django.get_version()[0]) > 3: + return + middleware = ContextMiddleware() + middleware.process_request(Request()) + assert context.request.user.pk == 1 +``` + +### Why it is an `xfail` placeholder here + +flask_hypergen is a Flask port and has **no dependency on Django**, so the +`MiddlewareMixin` deprecation-compatibility shim does not apply. The framework-agnostic +behavior it guarded *is* ported and tested: + +- `ContextMiddleware.process_request` is covered by `test_context_middleware_class`. +- The functional `context_middleware` wrapper is covered by `test_context_middleware`. + +The `test_context_middleware_old` placeholder is kept as a named `xfail` purely to +preserve a one-to-one mapping with the upstream test suite, making it obvious to future +readers that this Django-specific legacy path was considered and intentionally dropped +rather than overlooked. It is expected to remain `xfailed` indefinitely. + +## Remaining uncovered source lines/branches + +As of the latest run, total coverage is **99%**. The handful of uncovered +lines/branches below are intentional. + +### `src/flask_hypergen/template.py` + +Optional-dependency guards. These execute only when an optional package is absent (or +its lazy loader is invoked), which is not the case in the standard test environment. + +- **Lines 62, 66** — `docutils_core_load()` / `docutils_utils_load()` bodies. These lazy + `importlib.import_module('docutils.*')` loaders run only when `rst()` is actually used + with `docutils` installed. +- **Lines 73-74** — the `except ImportError: yattag_ok = False` branch, which only runs + when `yattag` is not installed. (The positive `yattag` path and the + "yattag required" error path are both tested via mocking.) +- **Lines 268-271** — the `rst()` body that loads `docutils` and renders reStructuredText. + The guard that raises when `docutils` is missing is tested; the success path requires + `docutils` to be installed. + +### `src/flask_hypergen/imports.py` + +- **Line 22** — the `continue` in the flat-namespace dedup loop: + + ```python + for name in getattr(module, '__all__', []): + if name in __all__: + continue # only hit if two submodules export the same name + ``` + + This defensive branch fires only if two submodules export the *same* public name. + Given the current module set and their `__all__` definitions, no such collision + exists, so the line is unreachable today. It is retained to keep the re-export logic + robust against future additions. + +### `src/flask_hypergen/liveview.py` + +Two negative branch-partials in `LiveviewPluginBase.template_after`: + +- **`330->349`** — the path taken when an action `base_view` produces a resolver match + whose `func` is `None` (so the isolated re-render is skipped). The positive case + (a resolvable base view) is tested; this negative branch is a guard against an + unresolvable referer. +- **`356->362`** — the path taken when `self.morph` is false or there is no `'into'` in + the hypergen context, so the morph commands are not appended. The morph-enabled path + is tested; this branch covers the non-morphing configuration. + +Both are defensive negatives around the action/base_view re-render flow; forcing them +would require constructing an unresolvable or morph-disabled liveview state that does not +correspond to a real usage scenario. diff --git a/noxfile.py b/noxfile.py index b869d6a..38bb708 100644 --- a/noxfile.py +++ b/noxfile.py @@ -13,6 +13,7 @@ @nox.session def pytest(session: nox.Session): uv_sync(session) + session.run('playwright', 'install', 'chromium') pytest_run(session) diff --git a/prek.toml b/prek.toml index 10c7cee..744cff0 100644 --- a/prek.toml +++ b/prek.toml @@ -17,7 +17,7 @@ hooks = [ [[repos]] repo = 'https://github.com/astral-sh/ruff-pre-commit' -rev = "v0.15.12" +rev = "v0.15.13" hooks = [ { id = 'ruff', exclude = 'tasks/bump' }, # Due to the Ruff config we use (see comment in pyproject.toml), it's possible that the diff --git a/ruff.toml b/ruff.toml index e985dbd..9d23d68 100644 --- a/ruff.toml +++ b/ruff.toml @@ -74,6 +74,9 @@ ignore = [ [lint.per-file-ignores] 'tasks/*.py' = ['EXE003'] +# Polyglot sh/python bootstrap: the sh preamble must be the first statement, so imports +# can't be at the top of the file. (EXE003 already covered by the tasks/*.py glob above.) +'tasks/mise-uv-init.py' = ['E402'] [lint.flake8-builtins] diff --git a/src/flask_hypergen/context.py b/src/flask_hypergen/context.py index 36c54fb..c49c0c4 100644 --- a/src/flask_hypergen/context.py +++ b/src/flask_hypergen/context.py @@ -74,7 +74,7 @@ def __call__( self.ctx = self.ctx.set(at, pmap(items)) else: new_value_at = self.ctx[at].update(pmap(items)) - if not new_value_at: + if new_value_at is None: raise TypeError( 'Not immutable context variable attempted updated. If you want to ' 'nest with context() statements you must use a pmap() or another ' @@ -102,7 +102,7 @@ def user_resolve(request: Request) -> Any: return None try: return current_user._get_current_object() - except RuntimeError: + except (RuntimeError, AttributeError): return None diff --git a/src/flask_hypergen/hypergen.py b/src/flask_hypergen/hypergen.py index 43edc3e..8e2309f 100644 --- a/src/flask_hypergen/hypergen.py +++ b/src/flask_hypergen/hypergen.py @@ -143,7 +143,13 @@ def auth_failure_response() -> Response: raise Forbidden() login_target = login_url if login_target is None: - login_manager = current_app.extensions.get('login_manager') + login_manager = getattr( + current_app, + 'login_manager', + None, + ) or current_app.extensions.get( + 'login_manager', + ) login_target = getattr(login_manager, 'login_view', None) if not login_target: return Response(status=403) @@ -285,7 +291,7 @@ def plugins_exit_stack(method_name: str) -> Iterator[None]: with ExitStack() as stack: for plugin in context.hypergen.plugins: if hasattr(plugin, method_name): - stack.enter_context(plugin.context()) + stack.enter_context(getattr(plugin, method_name)()) yield diff --git a/src/flask_hypergen/imports.py b/src/flask_hypergen/imports.py index e130f79..bd60472 100644 --- a/src/flask_hypergen/imports.py +++ b/src/flask_hypergen/imports.py @@ -1,18 +1,17 @@ from __future__ import annotations -from flask_hypergen import context as context_module -from flask_hypergen import hypergen as hypergen_module -from flask_hypergen import liveview as liveview_module -from flask_hypergen import template as template_module -from flask_hypergen import websocket as websocket_module +from importlib import import_module -MODULES = ( - context_module, - hypergen_module, - liveview_module, - template_module, - websocket_module, +MODULES = tuple( + import_module(name) + for name in ( + 'flask_hypergen.context', + 'flask_hypergen.hypergen', + 'flask_hypergen.liveview', + 'flask_hypergen.template', + 'flask_hypergen.websocket', + ) ) __all__: list[str] = [] diff --git a/src/flask_hypergen/liveview.py b/src/flask_hypergen/liveview.py index ecb59d9..a7413b3 100644 --- a/src/flask_hypergen/liveview.py +++ b/src/flask_hypergen/liveview.py @@ -177,7 +177,7 @@ class LiveviewCallable(BaseViewCallable, Protocol): is_hypergen_liveview: bool -class ActionCallable(RoutableCallable, Protocol): +class ActionCallable(BaseViewCallable, Protocol): pass @@ -422,7 +422,7 @@ def fix_this(x: Any) -> Any: 'debug': current_app.debug if has_app_context() else False, 'meta': meta, 'headers': headers, - 'eachUrlBlocks': each_url_blocks, + 'blocksEachUrl': each_url_blocks, 'timeout': timeout, }, return_=True, @@ -442,7 +442,7 @@ def fix_this(x: Any) -> Any: 'clear': clear, 'meta': meta, 'when': when, - 'eachUrlBlocks': each_url_blocks, + 'blocksEachUrl': each_url_blocks, 'timeout': timeout, }.items() if value @@ -657,6 +657,7 @@ def _(*args, **kwargs): return json_commands_response(full.context.hypergen.commands) wrapped = cast(ActionCallable, _) + wrapped.original_func = func wrapped.supports_hypergen_callback = True route_register( router, @@ -672,6 +673,7 @@ def _(*args, **kwargs): ENCODINGS = { date: lambda o: {'_': ['date', str(o)]}, datetime: lambda o: {'_': ['datetime', str(o)]}, + dt_time: lambda o: {'_': ['time', str(o)]}, tuple: lambda o: {'_': ['tuple', list(o)]}, deque: lambda o: {'_': ['deque', list(o)]}, set: lambda o: {'_': ['set', list(o)]}, diff --git a/src/flask_hypergen/template.py b/src/flask_hypergen/template.py index fa735de..38b7a92 100644 --- a/src/flask_hypergen/template.py +++ b/src/flask_hypergen/template.py @@ -454,7 +454,6 @@ def attribute(self, key: str, value: Any) -> list[Any]: if key == 'class' and type(value) in (list, tuple, set): return [' ', key, '="', t(' '.join(value)), '"'] value = '' if value is None else t(value) - assert '"' not in value, 'How dare you put a " in my attributes! :)' return [' ', key, '="', value, '"'] def start(self) -> list[Any]: diff --git a/tasks/mise-uv-init.py b/tasks/mise-uv-init.py index 592d9f6..c052000 100755 --- a/tasks/mise-uv-init.py +++ b/tasks/mise-uv-init.py @@ -1,4 +1,18 @@ -#!/usr/bin/env python3 +#!/bin/sh +# Polyglot bootstrap (valid as both /bin/sh and python). This runs during mise's env +# evaluation, before any venv/tool exists, so it must use a real *system* python3 and never +# a mise/uv shim. `#!/usr/bin/env python3` can't be used: env resolves `python3` to mise's +# shim, which re-enters mise and exhausts process limits (os error 11). The sh preamble +# below re-execs this file under the first real python3 it finds (covers Linux + macOS); +# to python the whole preamble is just an ignored string literal. +""":" +for _py in /usr/bin/python3 /opt/homebrew/bin/python3 /usr/local/bin/python3; do + [ -x "$_py" ] && exec "$_py" "$0" "$@" +done +echo 'mise-uv-init: no system python3 found (checked /usr/bin, Homebrew, /usr/local)' >&2 +exit 1 +""" + """ #MISE hide=true diff --git a/tests/flask_hypergen_tests/test_core_ported.py b/tests/flask_hypergen_tests/test_core_ported.py index 2342bdc..7a9762e 100644 --- a/tests/flask_hypergen_tests/test_core_ported.py +++ b/tests/flask_hypergen_tests/test_core_ported.py @@ -1,6 +1,8 @@ from contextlib import contextmanager -from datetime import date, datetime +from datetime import date, datetime, time import re +from types import SimpleNamespace +from unittest import mock from pyrsistent import pmap import pytest @@ -11,7 +13,9 @@ from flask_hypergen import ( FULL, LOGIN_REQUIRED, + NO_PERM_REQUIRED, THIS, + action, call_js, check_perms, command, @@ -19,11 +23,41 @@ doctype, dumps, hypergen, + liveview, loads, ) -from flask_hypergen.context import context, context_middleware, contextlist -from flask_hypergen.hypergen import compare_funcs -from flask_hypergen.liveview import LiveviewPlugin +from flask_hypergen.context import ( + ContextMiddleware, + context, + context_init_app, + context_middleware, + context_values_build, + contextlist, + user_resolve, +) +from flask_hypergen.hypergen import ( + autourl_register, + autourls, + compare_funcs, + is_collection, + make_string, + metastr, + plugins_exit_stack, + plugins_method_call, + plugins_pipeline, + resolve_url, + route_register, + wrap2, +) +from flask_hypergen.liveview import ( + LiveviewPlugin, + LiveviewPluginBase, + _request_header, + _request_path, + decoder, + encoder, + url_is_active, +) from flask_hypergen.liveview import callback as cb from flask_hypergen.tags import ( a, @@ -36,6 +70,7 @@ input_, li, p, + select, span, td, textarea, @@ -43,7 +78,17 @@ tr, ul, ) -from flask_hypergen.template import TemplatePlugin, join_html +from flask_hypergen.template import ( + OMIT, + TemplatePlugin, + add_class, + hprint, + join_html, + on_url, + raw, + write, +) +from flask_hypergen.template import html_indent as html_indent_ from .conftest import ( HttpResponse, @@ -109,6 +154,13 @@ def test_context_at_creation(): assert context['my_appname']['items'] == [1, 2, 3] +def test_context_at_empty_nesting(): + with context(at='my_appname'): + with context(at='my_appname'): + assert context['my_appname'] == pmap() + assert context['my_appname'] == pmap() + + def test_context_middleware(): def view(request): assert context.user.pk == 1 @@ -362,7 +414,7 @@ def template(): '{"0":["hypergen.callback","/path/to/cb/",[["_","element_value",' '["hypergen.read.value",null,"tec"]]],{"debounce":0,"confirm_":false,' '"blocks":false,"uploadFiles":false,"clear":false,"elementId":"tec",' - '"debug":false,"meta":{},"headers":{},"eachUrlBlocks":true,"timeout":20000}]}' + '"debug":false,"meta":{},"headers":{},"blocksEachUrl":true,"timeout":20000}]}' ) hypergen(template, settings={'liveview': True, 'target_id': 'foo'}) @@ -404,10 +456,49 @@ def test_repr(): el2 = input_(onclick=cb('alert', el1), id_='el2') assert ( repr(el2) == 'input_(onclick=callback("alert", input_(id_="el1"), ' - 'eachUrlBlocks=True, timeout=20000), id_="el2")' + 'blocksEachUrl=True, timeout=20000), id_="el2")' ) +def test_attribute_escapes_double_quote(): + with context(at='hypergen', **hypergen_context()): + div('hi', title='a "b" c') + assert normalized_html() == '
hi
' + + +def test_plugins_exit_stack_uses_method_name(): + entered = [] + + class FakePlugin: + @contextmanager + def custom(self): + entered.append('enter') + yield + entered.append('exit') + + ctx = hypergen_context() + ctx['plugins'] = [FakePlugin()] + with context(at='hypergen', **ctx), plugins_exit_stack('custom'): + assert entered == ['enter'] + assert entered == ['enter', 'exit'] + + +def test_action_sets_original_func(): + def my_action(request, foo, bar): + return None + + decorated = action(perm=NO_PERM_REQUIRED)(my_action) + assert decorated.original_func is my_action + + +def test_liveview_sets_original_func(): + def my_view(request, foo, bar): + return None + + decorated = liveview(perm=NO_PERM_REQUIRED)(my_view) + assert decorated.original_func is my_view + + def test_serialization(): payload = { 'string': 'hi', @@ -420,6 +511,7 @@ def test_serialization(): 'frozenset': frozenset({1, 2, 3}), 'date': date(2022, 1, 1), 'datetime': datetime(2022, 1, 1, 10, 11, 23), + 'time': time(10, 11, 23), } assert loads(dumps(payload)) == payload @@ -577,3 +669,717 @@ def test_function_equality(): assert compare_funcs(a, b) assert not compare_funcs(a, foo) assert not compare_funcs(a, c) + + +def test_make_string(): + assert make_string(None) == '' + assert make_string(42) == '42' + assert make_string('x') == 'x' + + +def test_is_collection(): + assert is_collection('abc') is False + assert is_collection(metastr('abc')) is False + assert is_collection([1, 2]) is True + assert is_collection((1,)) is True + assert is_collection({1}) is True + assert is_collection(5) is False + + +def test_metastr_make(): + value = metastr.make('hello', {'base_template': None}) + assert value == 'hello' + assert value.meta == {'base_template': None} + + +def test_wrap2_both_forms(): + @wrap2 + def deco(func, suffix='!'): + def inner(*a, **k): + return func(*a, **k) + suffix + + return inner + + @deco + def plain(): + return 'a' + + @deco(suffix='?') + def configured(): + return 'b' + + assert plain() == 'a!' + assert plain.__name__ == 'plain' + assert configured() == 'b?' + assert configured.__name__ == 'configured' + + +def test_check_perms_no_perm_required(): + assert check_perms(Request(), NO_PERM_REQUIRED).ok is True + + +def test_check_perms_all_perms_required(): + request = Request() + request.user.permissions = frozenset({'a', 'b'}) + + result = check_perms(request, ('a', 'b')) + + assert result.ok is True + assert result.matched_perms == {'a', 'b'} + + +def test_check_perms_missing_perm_returns_403(): + request = Request() + request.user.permissions = frozenset({'a'}) + + result = check_perms(request, ('a', 'b')) + + assert result.ok is False + assert result.response.status_code == 403 + + +def test_check_perms_raise_exception_on_missing_perm(): + request = Request() + request.user.permissions = frozenset() + + with pytest.raises(Forbidden): + check_perms(request, 'a', raise_exception=True) + + +def test_check_perms_falls_back_to_has_perm(): + class SingleCheckUser: + is_authenticated = True + + def has_perm(self, name): + return name == 'a' + + request = Request() + request.user = SingleCheckUser() + + assert check_perms(request, 'a').ok is True + assert check_perms(request, 'b').ok is False + + +def test_check_perms_login_redirect_includes_next(): + request = Request() + request.user.is_authenticated = False + + result = check_perms(request, LOGIN_REQUIRED, login_url='/login') + + assert result.ok is False + assert result.response.status_code == 302 + assert 'next=' in result.response.location + + +def test_check_perms_empty_login_target_returns_403(): + request = Request() + request.user.is_authenticated = False + + result = check_perms(request, LOGIN_REQUIRED, login_url='') + + assert result.response.status_code == 403 + + +def test_autourl_register_and_autourls(): + def registered_view(request): + return None + + autourl_register(registered_view, path='/x/') + module = SimpleNamespace(__name__=registered_view.__module__) + + items = autourls(module, 'ns') + assert any(item[0] is registered_view for item in items) + assert autourls(module, '') == [] + + +def test_route_register_resolve_and_reverse(): + from flask import Flask + + from flask_hypergen import init_app + + app = Flask(__name__) + init_app(app) + + def my_view(request): + return None + + func_obj = route_register(app, my_view, rule='/myview/', methods=['GET', 'POST']) + + assert func_obj.supports_hypergen_callback is True + assert func_obj.hypergen_endpoint == 'my_view' + + with app.app_context(): + match = resolve_url('/myview/') + assert match.func is func_obj + assert match.kwargs == {} + + with app.test_request_context(): + url = func_obj.reverse() + assert url == '/myview/' + assert url.meta == {'base_template': None} + + with pytest.raises(TypeError, match='Too many positional'): + func_obj.reverse('extra') + + +def test_plugins_method_call_and_pipeline(): + calls = [] + + class Recorder: + def note(self, msg): + calls.append(msg) + + def process(self, data, suffix=''): + return data + suffix + + class Empty: + pass + + ctx = hypergen_context() + ctx['plugins'] = [Recorder(), Empty()] + with context(at='hypergen', **ctx): + plugins_method_call('note', 'hi') + result = plugins_pipeline('process', 'x', {'suffix': '!'}) + + assert calls == ['hi'] + assert result == 'x!' + + +def test_context_at_with_transformer(): + def shout_title(m): + return m.set('title', m['title'].upper()) + + with context(at='app', title='foo'): + with context(shout_title, at='app', items=[1]): + assert context['app']['title'] == 'FOO' + assert context['app']['items'] == [1] + assert context['app']['title'] == 'foo' + + +def test_user_resolve_from_request(): + request = Request() + assert user_resolve(request) is request.user + + +def test_context_values_build_omits_none_user(): + values = context_values_build(SimpleNamespace()) + assert 'user' not in values + assert 'request' in values + + +def test_context_middleware_class(): + request = Request() + ContextMiddleware().process_request(request) + assert context.request is request + assert context.user is request.user + + +def test_context_init_app_idempotent(app): + assert app.extensions.get('flask_hypergen_context_init') is True + context_init_app(app) + assert app.extensions.get('flask_hypergen_context_init') is True + + +def test_contextlist_default_without_hypergen(): + context.replace() + cl = contextlist('target_id') + cl.append('x') + assert cl.contexts['__default_context__'] == ['x'] + + +def test_add_class(): + assert add_class('', 'a') == 'a' + assert add_class(None, 'a') == 'a' + assert add_class(OMIT, 'a') == 'a' + assert add_class('a', 'b') == 'a b' + assert add_class(' a ', 'b') == 'a b' + assert add_class(['a'], 'b') == ['a', 'b'] + assert add_class({'a'}, 'b') == {'a', 'b'} + + +def test_add_class_invalid_type(): + with pytest.raises(Exception, match="don't know how to add"): + add_class(42, 'b') + + +def test_write_escapes_and_raw_does_not(): + with context(at='hypergen', **hypergen_context()): + write('') + assert join_html(context.hypergen.into) == '<b>' + with context(at='hypergen', **hypergen_context()): + raw('') + assert join_html(context.hypergen.into) == '' + + +def test_input_datetime_value_formatting(): + with context(at='hypergen', **hypergen_context()): + input_(type_='datetime-local', value=datetime(2022, 3, 4, 5, 6, 7), id_='dt') + assert 'value="2022-03-04T05:06:07"' in normalized_html() + with context(at='hypergen', **hypergen_context()): + input_(type_='month', value={'year': 2022, 'month': 3}, id_='mo') + assert 'value="2022-03"' in normalized_html() + with context(at='hypergen', **hypergen_context()): + input_(type_='week', value={'year': 2022, 'week': 9}, id_='wk') + assert 'value="2022-W09"' in normalized_html() + + +def test_hprint_single_and_multi(): + with context(at='hypergen', **hypergen_context()): + hprint({'a': 1}) + out = join_html(context.hypergen.into) + assert '') + + +def test_request_header_sources(): + assert _request_header(SimpleNamespace(headers={'X-Test': 'h'}), 'X-Test') == 'h' + assert _request_header(SimpleNamespace(META={'HTTP_X_TEST': 'm'}), 'X-Test') == 'm' + assert _request_header(SimpleNamespace(environ={'HTTP_X_TEST': 'e'}), 'X-Test') == 'e' + assert _request_header(SimpleNamespace(headers={}, environ={}), 'X-Test') is None + + +def test_request_path_sources(): + assert _request_path(SimpleNamespace(full_path='/a/b?')) == '/a/b' + assert _request_path(SimpleNamespace(get_full_path=lambda: '/c')) == '/c' + assert _request_path(SimpleNamespace(path='/d')) == '/d' + + +def test_command_return_form(): + item = command('hypergen.foo', 1, 2, return_=True) + assert item == ['hypergen.foo', 1, 2] + + +def test_encoder_rejects_unknown_type(): + with pytest.raises(TypeError): + encoder(object()) + + +def test_decoder_passthrough_and_unknown(): + assert decoder({'x': 1}) == {'x': 1} + assert decoder({'_': ['notalist']}) == {'_': ['notalist']} + with pytest.raises(Exception, match='Unknown datatype'): + decoder({'_': ['bogus', 1]}) + + +def test_wrap_element_init_coerce_error(): + with context(at='hypergen', **hypergen_context()): + element = input_(id_='ci') + with ( + pytest.raises(Exception, match='coerce must be one of'), + LiveviewPluginBase().wrap_element_init(element, [], {'coerce_to': object()}), + ): + pass + + +def test_wrap_element_init_select_multiple(): + with context(at='hypergen', **hypergen_context()): + element = select(id_='s1', multiple=True) + with LiveviewPluginBase().wrap_element_init( + element, + [], + {'multiple': True, 'coerce_to': int}, + ): + pass + assert element.js_value_func == 'hypergen.read.selectMultiple' + assert element.js_coerce_func == 'hypergen.coerce.intlist' + + +def test_wrap_element_init_select_multiple_unsupported_coerce(): + with context(at='hypergen', **hypergen_context()): + element = select(id_='s2', multiple=True) + with ( + pytest.raises(Exception, match='not yet implemented'), + LiveviewPluginBase().wrap_element_init( + element, + [], + {'multiple': True, 'coerce_to': float}, + ), + ): + pass + + +def test_wrap_element_init_a_partial_load(): + base_template = make_base_template('one') + href = metastr.make('/x/', {'base_template': base_template}) + ctx = hypergen_context() + ctx['partial_base_template'] = base_template + with context(at='hypergen', **ctx): + element = a('link', href=href, id_='ap') + attrs = {'href': href} + with LiveviewPluginBase().wrap_element_init(element, [], attrs): + pass + assert attrs['onclick'] == f"hypergen.partialLoad(event, '{href}', true)" + + +def test_url_is_active_on_url_and_a_class_active(): + from flask import Flask + + from flask_hypergen import init_app + + app = Flask(__name__) + init_app(app) + + def active_view(request): + return None + + def inactive_view(request): + return None + + route_register(app, active_view, rule='/active/', methods=['GET']) + route_register(app, inactive_view, rule='/inactive/', methods=['GET']) + + with app.test_request_context('/active/'): + ctx = hypergen_context() + ctx['liveview_resolver_match'] = resolve_url('/active/') + with context(at='hypergen', **ctx): + assert url_is_active('/active/') is True + assert url_is_active('/inactive/') is False + assert on_url('/active/') is True + assert on_url('/inactive/', 'Y', 'N') == 'N' + a('x', href='/active/', class_active='on', id_='lnk') + a('y', href='/inactive/', class_active='on', id_='lnk2') + html = normalized_html() + assert 'class="on"' in html + assert 'y' in html + + +def test_context_setattr(): + context.replace() + context.custom = 7 + assert context.custom == 7 + + +def test_user_resolve_handles_missing_flask_login(): + import sys + + with mock.patch.dict(sys.modules, {'flask_login': None}): + assert user_resolve(SimpleNamespace()) is None + + +def test_user_resolve_handles_runtime_error(): + fake = mock.Mock() + fake._get_current_object.side_effect = RuntimeError + with mock.patch('flask_login.current_user', fake): + assert user_resolve(SimpleNamespace()) is None + + +def test_liveview_resolver_match_edges(): + from flask_hypergen.liveview import liveview_resolver_match + + context.replace(request=SimpleNamespace(endpoint=None, view_args={})) + assert liveview_resolver_match().func is None + + context.replace(request=SimpleNamespace(headers={}, environ={})) + assert liveview_resolver_match(for_action=True) is None + + +def test_liveview_partial_requires_target_id(): + def base(view): + return view + + with pytest.raises(Exception, match='requires a target_id'): + liveview(base_template=base, perm=NO_PERM_REQUIRED)(lambda request: None) + + +def test_action_partial_requires_target_id(): + def base(view): + return view + + with pytest.raises(Exception, match='requires a target_id'): + action(base_template=base, perm=NO_PERM_REQUIRED)(lambda request: None) + + +def test_element_id_from_tuple(): + with context(at='hypergen', **hypergen_context()): + div('x', id_=('a', 'b')) + assert 'id="a-b"' in normalized_html() + + +def test_element_class_list_attribute(): + with context(at='hypergen', **hypergen_context()): + div('x', class_=['a', 'b']) + assert 'class="a b"' in normalized_html() + + +def test_element_callable_child_and_end_char(): + with context(at='hypergen', **hypergen_context()): + div(lambda: 'cb', end='!') + assert normalized_html() == '
cb!
' + + +def test_join_html_callable_and_generator(): + with context(at='hypergen', **hypergen_context()): + assert join_html([lambda: 'x', (str(i) for i in range(2))]) == 'x01' + + +def test_input_radio_requires_name(): + with ( + context(at='hypergen', **hypergen_context()), + pytest.raises(AssertionError, match='Name must be set'), + ): + input_(type_='radio') + + +def test_repr_includes_this(): + with context(is_test=True, at='hypergen', **hypergen_context()): + + @mock_hypergen_callback + def fn(): + pass + + element = input_(onclick=cb(fn, THIS), id_='rt') + assert 'THIS' in repr(element) + + +def test_callback_confirm_alias(): + with context(is_test=True, at='hypergen', **hypergen_context()): + + @mock_hypergen_callback + def fn(): + pass + + input_(onclick=cb(fn, confirm=True), id_='cc') + handler = next(iter(context.hypergen.event_handler_callbacks.values())) + assert handler[3]['confirm_'] is True + + +def test_wrap_element_init_select_multiple_str(): + with context(at='hypergen', **hypergen_context()): + element = select(id_='s4', multiple=True) + with LiveviewPluginBase().wrap_element_init( + element, + [], + {'multiple': True, 'coerce_to': str}, + ): + pass + assert element.js_value_func == 'hypergen.read.selectMultiple' + assert element.js_coerce_func == 'hypergen.coerce.str' + + +def test_wrap_element_init_select_single(): + with context(at='hypergen', **hypergen_context()): + element = select(id_='s5') + with LiveviewPluginBase().wrap_element_init(element, [], {}): + pass + assert element.js_value_func == 'hypergen.read.value' + + +def test_wrap_element_init_a_partial_no_base_template(): + href = metastr.make('/x/', {'base_template': None}) + with context(at='hypergen', **hypergen_context()): + element = a('l', href=href, id_='ap2') + attrs = {'href': href} + with LiveviewPluginBase().wrap_element_init(element, [], attrs): + pass + assert 'onclick' not in attrs + + +def test_wrap_element_init_a_partial_mismatched_base_template(): + href = metastr.make('/x/', {'base_template': make_base_template('one')}) + with context(at='hypergen', **hypergen_context()): + element = a('l', href=href, id_='ap3') + attrs = {'href': href} + with LiveviewPluginBase().wrap_element_init(element, [], attrs): + pass + assert 'onclick' not in attrs + + +def test_check_perms_any_perm_no_match_returns_403(): + request = Request() + request.user.permissions = frozenset() + + result = check_perms(request, ('a', 'b'), any_perm=True) + + assert result.ok is False + assert result.response.status_code == 403 + + +def test_check_perms_redirects_to_login_manager_view(app): + request = Request() + request.user.is_authenticated = False + with app.test_request_context('/protected'): + result = check_perms(request, LOGIN_REQUIRED) + assert result.response.status_code == 302 + assert 'login' in result.response.location.lower() + + +def test_plugins_exit_stack_skips_missing_method(): + class WithMethod: + @contextmanager + def custom(self): + yield + + class WithoutMethod: + pass + + ctx = hypergen_context() + ctx['plugins'] = [WithoutMethod(), WithMethod()] + with context(at='hypergen', **ctx), plugins_exit_stack('custom'): + pass + + +def test_init_app_idempotent_blueprint(app): + from flask_hypergen import init_app + + init_app(app) + assert app.extensions['flask_hypergen'] is True + + +def test_void_element_as_context_manager(): + with context(at='hypergen', **hypergen_context()): + with input_(id_='vd'): + pass + assert normalized_html() == '' + + +def test_rst_requires_docutils(): + from flask_hypergen.template import rst + + with pytest.raises(Exception, match='docutils'): + rst('hello') + + +def test_imports_flat_namespace(): + from flask_hypergen import imports as flat + + assert 'hypergen' in flat.__all__ + assert 'context' in flat.__all__ + assert flat.hypergen is hypergen + for name in flat.__all__: + assert hasattr(flat, name) + + +def test_appstate_plugin_persists_in_session(app): + from flask_hypergen.plugins.appstate import AppstatePlugin + + plugin = AppstatePlugin('myns', lambda: {'count': 0}) + with app.test_request_context('/'): + with plugin.context(): + assert context.hypergen.appstate == {'count': 0} + context.hypergen.appstate['count'] = 5 + with plugin.context(): + assert context.hypergen.appstate['count'] == 5 + + +def test_plugins_build_includes_appstate(): + from flask_hypergen.plugins.appstate import AppstatePlugin + from flask_hypergen.template import plugins_build, settings_load + + settings = settings_load({'appstate': lambda: {'count': 0}, 'namespace': 'ns'}) + plugins = plugins_build(settings) + + assert any(isinstance(plugin, AppstatePlugin) for plugin in plugins) + + +def test_liveview_returns_response_via_client(): + from flask import Flask, Response + + from flask_hypergen import init_app + + app = Flask(__name__) + app.config['SECRET_KEY'] = 'x' + init_app(app) + + def view(request): + return Response('hi', status=201) + + liveview(app, rule='/lvresp/', perm=NO_PERM_REQUIRED, partial=False)(view) + resp = app.test_client().get('/lvresp/') + assert resp.status_code == 201 + assert resp.data == b'hi' + + +def test_liveview_partial_redirect_via_client(): + from flask import Flask, redirect + + from flask_hypergen import init_app + + app = Flask(__name__) + app.config['SECRET_KEY'] = 'x' + init_app(app) + + def view(request): + return redirect('/elsewhere/') + + liveview(app, rule='/lvredir/', perm=NO_PERM_REQUIRED, target_id='t')(view) + resp = app.test_client().get('/lvredir/', headers={'X-Hypergen-Partial': '1'}) + assert resp.status_code == 302 + assert b'hypergen.redirect' in resp.data + + +def test_action_returns_response_via_client(): + from flask import Flask, Response + + from flask_hypergen import init_app + + app = Flask(__name__) + app.config['SECRET_KEY'] = 'x' + init_app(app) + + def act(request): + return Response('done', status=202) + + action(app, rule='/actresp/', perm=NO_PERM_REQUIRED, target_id='t')(act) + resp = app.test_client().post('/actresp/', data={'hypergen_data': dumps({'args': []})}) + assert resp.status_code == 202 + assert resp.data == b'done' + + +def test_action_returns_redirect_via_client(): + from flask import Flask, redirect + + from flask_hypergen import init_app + + app = Flask(__name__) + app.config['SECRET_KEY'] = 'x' + init_app(app) + + def act(request): + return redirect('/elsewhere/') + + action(app, rule='/actredir/', perm=NO_PERM_REQUIRED, target_id='t')(act) + resp = app.test_client().post('/actredir/', data={'hypergen_data': dumps({'args': []})}) + assert resp.status_code == 302 + assert b'hypergen.redirect' in resp.data + + +def test_action_with_base_view_via_client(): + from flask import Flask + + from flask_hypergen import init_app + from flask_hypergen.tags import div + + app = Flask(__name__) + app.config['SECRET_KEY'] = 'x' + init_app(app) + + def page(request): + div('sidebar', id_='side') + + base = liveview(app, rule='/page/', perm=NO_PERM_REQUIRED, partial=False, target_id='side')( + page, + ) + + def act(request): + div('main', id_='main') + + action(app, rule='/act/', perm=NO_PERM_REQUIRED, target_id='main', base_view=base)(act) + + resp = app.test_client().post( + '/act/', + data={'hypergen_data': dumps({'args': []})}, + headers={'Referer': '/page/'}, + ) + assert resp.status_code == 200 + assert b'hypergen.morph' in resp.data