Skip to content
Open
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
281 changes: 281 additions & 0 deletions docs/app/tests/test_docs_site.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
)
from reflex_site_shared.docs import render_markdown
from reflex_site_shared.docs.content import discover_docs
from reflex_site_shared.docs.markdown import _file_modules
from reflex_site_shared.docs.models import DocsPage
from rxconfig import config
from xy_docs.api_reference import (
API_REFERENCE_HEADING,
Expand Down Expand Up @@ -1155,6 +1157,285 @@ def render() -> str:
assert second_payloads == first_payloads


def test_repeated_identical_fence_keeps_the_page_namespace_accumulating(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""A duplicated fence must not roll the page back to its first occurrence.

Snapshots are keyed by fence source *and* occurrence: two identical
fences are two positions in the page's fence sequence, and the second one
restoring the first one's namespace would discard everything the fences
between them defined — the shared renderer skips re-execution and lets
the namespace keep accumulating, and this seam must not change that.
"""
duplicated = "shared = 1\n"
content = "\n".join(
(
"~~~python exec",
duplicated.rstrip("\n"),
"~~~",
"",
"~~~python exec",
"between = 2",
"~~~",
"",
"~~~python exec",
duplicated.rstrip("\n"),
"~~~",
"",
"~~~python exec",
"def result():",
" return (shared, between)",
"~~~",
)
)
virtual_filepath = "tests/docdemo/repeated-fence/page.md"

def render() -> dict:
transformer = XyDocsMarkdownTransformer(
virtual_filepath=virtual_filepath,
filename=virtual_filepath,
)
transformer.transform(parse_document(content))
return transformer.env

monkeypatch.chdir(tmp_path)
first, second = render(), render()
# `between` survives the duplicate fence on the first render and on every
# later one; a KeyError here is the regression.
assert first["result"]() == (1, 2)
assert second["result"]() == (1, 2)


def test_editing_a_page_drops_the_fence_snapshots_keyed_to_its_old_layout(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""A page edit must not let a fence restore a snapshot from another position.

Snapshot keys carry the fence's occurrence index, so removing or reordering
a duplicated fence renumbers every surviving copy. Without invalidation the
renumbered fence adopts the snapshot the *other* copy left behind — a
namespace captured before the bindings that now precede it — and the page
silently renders from stale data. The dev server re-renders in-process on
reload, so this is the live path, not a hypothetical one.
"""
source_path = tmp_path / "editable.md"

def page_of(*fences: str) -> DocsPage:
content = "\n".join(f"~~~python exec\n{body}\n~~~\n" for body in fences)
source_path.write_text(content)
return DocsPage(
source_path=source_path,
relative_path=Path("editable.md"),
route="/docs/xy/editable",
title="Editable",
description=None,
metadata={},
content=content,
)

duplicated = "shared = 1"
monkeypatch.chdir(tmp_path)

# First layout: the duplicate sits at occurrence 1, after `between`, so
# occurrence 0's snapshot is the one captured before `between` existed.
render_xy_markdown_page(
page_of(duplicated, "between = 2", duplicated, "first = (shared, between)")
)
# The edit removes the leading copy, renumbering the survivor to occurrence
# 0 — and revises the trailing fence, as a real edit does, so it is a cache
# miss and runs against whatever namespace the restore left behind.
render_xy_markdown_page(page_of("between = 2", duplicated, "second = (shared, between)"))

module = _file_modules[str(source_path.resolve())]
assert module.__dict__["second"] == (1, 2)


def _assigned_names(target: ast.expr) -> set[str]:
"""Every top-level name an assignment target binds, unpacking included."""
if isinstance(target, ast.Name):
return {target.id}
if isinstance(target, (ast.Tuple, ast.List)):
return {name for element in target.elts for name in _assigned_names(element)}
if isinstance(target, ast.Starred):
return _assigned_names(target.value)
return set() # attribute/subscript targets rebind no module-level name


#: Statements that run at module scope and can hold further bindings in their
#: bodies. `FunctionDef`/`ClassDef` are deliberately absent: they bind their own
#: name (handled below) but their bodies are a separate scope, so a name
#: assigned inside one is not a module-level binding and cannot shadow a
#: sibling fence's.
_NESTED_SCOPE_FREE_BODIES = (
ast.For,
ast.AsyncFor,
ast.While,
ast.If,
ast.With,
ast.AsyncWith,
ast.Try,
ast.Match,
)


def _module_bindings(node: ast.AST) -> set[str]:
"""Module-level names one statement binds, recursing into nested bodies."""
bound: set[str] = set()
if isinstance(node, ast.Assign):
for target in node.targets:
bound |= _assigned_names(target)
elif isinstance(node, (ast.AnnAssign, ast.AugAssign)):
bound |= _assigned_names(node.target)
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
bound.add(node.name) # the body is another scope; the name is not
return bound
elif isinstance(node, (ast.Import, ast.ImportFrom)):
bound |= {(alias.asname or alias.name).split(".")[0] for alias in node.names}
elif isinstance(node, (ast.For, ast.AsyncFor)):
bound |= _assigned_names(node.target)
elif isinstance(node, (ast.With, ast.AsyncWith)):
for item in node.items:
if item.optional_vars is not None:
bound |= _assigned_names(item.optional_vars)
if isinstance(node, ast.Try):
bound |= {handler.name for handler in node.handlers if handler.name}
if isinstance(node, ast.Match):
for case in node.cases:
bound |= {
capture.name
for capture in ast.walk(case.pattern)
if isinstance(capture, (ast.MatchAs, ast.MatchStar)) and capture.name
}
if isinstance(node, _NESTED_SCOPE_FREE_BODIES):
Comment thread
masenf marked this conversation as resolved.
for child in ast.iter_child_nodes(node):
if isinstance(child, ast.stmt):
bound |= _module_bindings(child)
# Walrus binds at the enclosing scope wherever the expression appears.
for inner in ast.walk(node):
if isinstance(inner, ast.NamedExpr):
bound |= _assigned_names(inner.target)
Comment thread
masenf marked this conversation as resolved.
return bound


def _bound_names(body: str) -> set[str]:
"""Every module-level name one fence binds.

Plain assignment is not the only way to shadow a name a sibling fence also
owns: annotated, augmented and unpacked assignment, `def`/`class`, imports,
`for`/`with`/`except`/`match` targets, and walrus all do it — and the
compound statements among them nest, so a binding can sit inside a
top-level `for` or `if` body. Missing any of those forms under-reports
rebound names and silently drops pages from the guarded test, which then
still passes while covering less.
"""
return {name for node in ast.parse(body).body for name in _module_bindings(node)}


def _names_rebound_across_exec_fences(content: str) -> set[str]:
"""Top-level names that more than one exec fence of a page binds."""
seen: set[str] = set()
rebound: set[str] = set()
for fence, body in re.findall(r"~~~python([^\n]*)\n(.*?)\n~~~", content, re.DOTALL):
if "exec" not in fence:
continue
bound = _bound_names(body)
rebound |= bound & seen
seen |= bound
return rebound


def test_rebound_name_detection_covers_every_binding_form() -> None:
"""Pin the detector itself: the page filter above is only as complete as
the binding forms it recognizes."""
fence = "~~~python exec\n{body}\n~~~"
declared = (
"plain",
"annotated",
"augmented",
"unpacked",
"spread",
"fn",
"Cls",
"np",
"pi",
"looped",
"handle",
"caught",
"matched",
"walrus",
"nested",
)
page = "\n".join(
fence.format(body=body)
for body in (
"\n".join(f"{name} = 0" for name in declared),
"plain = 2",
"annotated: int = 2",
"augmented += 2",
"unpacked, other = (2, 3)",
"*spread, tail = [2, 3]",
"def fn():\n return 2",
"class Cls:\n pass",
"import numpy as np",
"from math import pi",
"for looped in range(2):\n pass",
"with open(__file__) as handle:\n pass",
"try:\n pass\nexcept ValueError as caught:\n pass",
"match [1]:\n case [matched]:\n pass",
"if (walrus := 2):\n pass",
# A binding inside a top-level compound body is still module scope.
"for _ in range(1):\n if True:\n nested = 2",
# Neither rebinds a module-level name: attribute/subscript targets,
# and names assigned inside a function body (a separate scope).
"obj.attr = 2\nmapping['key'] = 2",
"def _unrelated():\n plain = 3\n return plain",
)
)
assert _names_rebound_across_exec_fences(page) == set(declared)


def test_repeated_page_renders_rebuild_every_demo_from_its_own_data(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""Keep cached fences bound to their own globals, not the page's last ones.

Pages are evaluated more than once per process — the frontend compile, then
the `reflex_xy` worker-startup pass over unevaluated pages — and demos that
reuse names such as `months` must not pick up a later demo's arrays on the
second pass. Content-addressed payload names make that visible: identical
demo data renders identical `.xyf` sources.
"""
monkeypatch.chdir(tmp_path)
payload_pattern = re.compile(r'src:"(?:/docs/xy)?/xy/([^"]+\.xyf)"')
pages = [
page
for page in discover_docs(DOCS_CONFIG)
if _names_rebound_across_exec_fences(page.content)
]

assert pages
for page in pages:
route = page.relative_path.as_posix()
virtual_filepath = f"tests/docdemo/repeated-render/{route}"
renders = [
str(
XyDocsMarkdownTransformer(
virtual_filepath=virtual_filepath,
filename=page.source_path.as_posix(),
).transform(parse_document(page.content))
)
for _ in range(2)
]

first, second = (payload_pattern.findall(rendered) for rendered in renders)
assert first, route
assert second == first, route


def test_exact_readout_demo_is_backed_by_live_reflex_state() -> None:
"""Keep the readout driven by chart events instead of compiled sample values."""
source_path = DOCS_ROOT / "core-concepts" / "interactions.md"
Expand Down
Loading
Loading