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() == '