Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions coverage-notes.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
@nox.session
def pytest(session: nox.Session):
uv_sync(session)
session.run('playwright', 'install', 'chromium')
Comment thread
rsyring marked this conversation as resolved.
pytest_run(session)


Expand Down
2 changes: 1 addition & 1 deletion prek.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
4 changes: 2 additions & 2 deletions src/flask_hypergen/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 '
Expand Down Expand Up @@ -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


Expand Down
10 changes: 8 additions & 2 deletions src/flask_hypergen/hypergen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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


Expand Down
21 changes: 10 additions & 11 deletions src/flask_hypergen/imports.py
Original file line number Diff line number Diff line change
@@ -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] = []
Expand Down
8 changes: 5 additions & 3 deletions src/flask_hypergen/liveview.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ class LiveviewCallable(BaseViewCallable, Protocol):
is_hypergen_liveview: bool


class ActionCallable(RoutableCallable, Protocol):
class ActionCallable(BaseViewCallable, Protocol):
pass


Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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)]},
Expand Down
1 change: 0 additions & 1 deletion src/flask_hypergen/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
16 changes: 15 additions & 1 deletion tasks/mise-uv-init.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Loading
Loading