diff --git a/autohands/add_notebook_quotes.py b/autohands/add_notebook_quotes.py index 2a5c484..d9151e1 100644 --- a/autohands/add_notebook_quotes.py +++ b/autohands/add_notebook_quotes.py @@ -5,12 +5,21 @@ """ import ast +import re from typing import Iterable, List, Tuple from sys import argv +# A column-0 triple-quote docstring opener, with the optional raw-string prefix. +# The prefix is load-bearing for LaTeX-carrying tutorial prose: without ``r``, +# ``\theta`` in a docstring is a TAB followed by ``heta``. A raw opener is the +# same cell boundary as a plain one — the converter replaces this line with +# ``'''`` when it emits the cell, so the prefix never reaches the notebook. +_TRIPLE_DELIM_OPENER_RE = re.compile(r"^[rR]?(?:\"\"\"|''')") + + def _narrative_docstring_ranges(lines: List[str]) -> List[Tuple[int, int]]: """Locate the narrative docstring blocks of a script, by parsing it. @@ -64,9 +73,13 @@ def _narrative_docstring_ranges(lines: List[str]) -> List[Tuple[int, int]]: start = node.lineno - 1 end = node.end_lineno - 1 - if not (lines[start].startswith('"""') or lines[start].startswith("'''")): + if not _TRIPLE_DELIM_OPENER_RE.match(lines[start]): # A column-0 string statement written with a single-quote delimiter # was never a cell boundary; leave it as code, as it always was. + # An ``r``/``R`` prefix does not change that: a raw narrative + # docstring is the same cell boundary as a plain one, and the + # converter replaces this opener line when it emits the cell, + # so the prefix never reaches the generated notebook. continue if start == end: raise ValueError( diff --git a/autohands/env_config.py b/autohands/env_config.py index f3dfb0f..5b87d79 100644 --- a/autohands/env_config.py +++ b/autohands/env_config.py @@ -106,8 +106,13 @@ # raise on it (``add_notebook_quotes.strip_env_declarations`` also strips such # lines defensively when generating notebooks/markdown). _ENV_DECLARATION_RE = re.compile(r"^# ENV:(?P.*)$") -# A bare triple-quote delimiter alone on its line (opens/closes a docstring). -_DOCSTRING_DELIM_RE = re.compile(r"^(?:\"\"\"|''')\s*$") +# A bare triple-quote delimiter alone on its line (opens/closes a docstring), +# with the optional raw-string prefix an opener may carry. Missing the ``r`` +# form would be silent and worse than a crash: the scan walks past the opener, +# matches the block's CLOSER as an opener instead, and every docstring parity +# after it inverts — so an ``__Env__`` section lower down is read as if it were +# outside a docstring and its ``ENV:`` declaration is lost without a word. +_DOCSTRING_DELIM_RE = re.compile(r"^[rR]?(?:\"\"\"|''')\s*$") # The ``__Env__`` docstring header — the section marker, optional trailing text # (e.g. a ``(Developer Only)`` note). _ENV_SECTION_HEADER_RE = re.compile(r"^__Env__\b") diff --git a/tests/test_add_notebook_quotes.py b/tests/test_add_notebook_quotes.py index 3d28e54..297b1ee 100644 --- a/tests/test_add_notebook_quotes.py +++ b/tests/test_add_notebook_quotes.py @@ -318,3 +318,43 @@ def test_indented_closing_delimiter_still_closes_the_block(tmp_path, monkeypatch assert [cell["cell_type"] for cell in notebook["cells"]] == ["markdown", "code"] assert "x = 1" in "".join(notebook["cells"][1]["source"]) + + +def test_raw_string_docstring_is_a_cell_boundary(): + r"""A raw narrative docstring converts identically to a plain one. + + LaTeX-carrying tutorial prose has to be raw — in a plain docstring + ``\theta`` is a TAB followed by ``heta`` — but the opener test read + ``lines[start].startswith('\"\"\"')``, which an ``r\"\"\"`` line fails. The + block was then dropped as a boundary and the prose shipped as a *code* cell + containing a bare string literal. + + Asserted on the converted source rather than the notebook: the converter + replaces the opener line outright when it emits the cell, so a + byte-identical conversion is the tighter statement of "the prefix does not + leak into the generated artefact". + """ + plain = '"""\n' "__Intro__\n" "prose $\\theta_E$\n" '"""\n' "\n" "x = 1\n" + raw = "r" + plain + + converted = "".join(add_notebook_quotes(_lines(raw))) + + assert converted == "".join(add_notebook_quotes(_lines(plain))) + assert "# %%" in converted + assert 'r"""' not in converted and "r'''" not in converted + + +def test_every_raw_prefix_and_delimiter_form_is_a_cell_boundary(): + """``r``/``R`` against both triple-quote delimiters, all six forms.""" + baseline = None + + for prefix in ("", "r", "R"): + for delim in ('"""', "'''"): + script = f"{prefix}{delim}\n__Intro__\nprose\n{delim}\n\nx = 1\n" + converted = "".join(add_notebook_quotes(_lines(script))) + + assert "# %%" in converted, (prefix, delim) + if baseline is None: + baseline = converted + else: + assert converted == baseline, (prefix, delim) diff --git a/tests/test_env_config.py b/tests/test_env_config.py index 491c037..7acc78d 100644 --- a/tests/test_env_config.py +++ b/tests/test_env_config.py @@ -504,3 +504,75 @@ def test_unknown_token_raises_in_resolver(tmp_path): p = _write_script(tmp_path, "imaging/x.py", '"""\n__Env__\n\nENV: bogus\n"""\n') with pytest.raises(ValueError, match="unknown env declaration token"): apply_profile({}, p, {"defaults": {}}) + + +# --- Raw-string (`r"""`) docstring openers ------------------------------------- +# The workspace tutorial scripts carry LaTeX in their narrative docstrings, so +# those docstrings have to be raw (in a plain docstring `\theta` is a TAB +# followed by `heta`). `_DOCSTRING_DELIM_RE` matched a bare delimiter only, so +# an `r"""` opener was walked past and the block's CLOSER matched as an opener +# instead — parity inverted for the rest of the file and a later `__Env__` +# section was read as if it sat outside a docstring. Silent: no raise, just a +# lost `ENV:` declaration and a silently rerouted smoke profile. + + +def test_read_declaration_survives_an_earlier_raw_docstring(tmp_path): + # The parity case a single-block test would miss: the RAW docstring is not + # the one carrying the declaration. Before the fix this returned None. + body = ( + 'r"""\n' + "Tutorial prose with LaTeX $\\theta_E$ and $\\frac{1}{2}$.\n" + '"""\n' + "\n" + "import autolens as al\n" + "code()\n" + "\n" + '"""\n' + "Wrap Up\n" + "-------\n" + "\n" + "__Env__\n" + "\n" + "ENV: full_datasets\n" + '"""\n' + ) + p = _write_script(tmp_path, "imaging/x.py", body) + assert read_env_declaration(p) == ["full_datasets"] + + +def test_read_declaration_in_a_raw_docstring_section(tmp_path): + # The declaration inside the raw block itself. + body = ( + "import autolens as al\n" + "\n" + 'r"""\n' + "Closing prose with $\\theta_E$.\n" + "\n" + "__Env__\n" + "\n" + "ENV: jax full_datasets\n" + '"""\n' + ) + p = _write_script(tmp_path, "imaging/x.py", body) + assert read_env_declaration(p) == ["jax", "full_datasets"] + + +def test_read_declaration_raw_prefix_forms_all_parse(tmp_path): + # `r`/`R` against both delimiters, each identical to the unprefixed form. + for index, prefix in enumerate(("", "r", "R")): + for delim in ('"""', "'''"): + body = ( + f"{prefix}{delim}\n" + "Prose with $\\theta_E$.\n" + f"{delim}\n" + "\n" + "code()\n" + "\n" + f"{delim}\n" + "__Env__\n" + "\n" + "ENV: jax\n" + f"{delim}\n" + ) + p = _write_script(tmp_path, f"imaging/x{index}{len(delim)}{delim[0]}.py", body) + assert read_env_declaration(p) == ["jax"], (prefix, delim) diff --git a/tests/test_strip_env_declarations.py b/tests/test_strip_env_declarations.py index 1d38575..7a32f3c 100644 --- a/tests/test_strip_env_declarations.py +++ b/tests/test_strip_env_declarations.py @@ -300,3 +300,49 @@ def test_strip_leaves_a_code_string_literal_untouched(): assert "print(s)" in stripped assert "__Later__" in stripped assert "y = 2" in stripped + + +RAW_MERGED_SCRIPT = ( + 'r"""\n' + "Imaging Example\n" + "===============\n" + "\n" + "The Einstein radius $\\theta_E$ and the term $\\frac{1}{2}$ need a raw\n" + "docstring: unprefixed, $\\theta$ is a TAB followed by `heta`.\n" + '"""\n' + "import autolens as al\n" + "\n" + "al.do_something()\n" + "\n" + 'r"""\n' + "Wrap Up\n" + "-------\n" + "\n" + "Closing prose with $\\theta_E$.\n" + "\n" + "__Env__ (Developer Only)\n" + "\n" + "Not user documentation: this section configures the test harness.\n" + "\n" + "ENV: full_datasets\n" + '"""\n' +) + + +def test_strip_removes_env_section_from_a_raw_docstring(): + r"""A raw (`r\"\"\"`) block strips exactly as a plain one does. + + The workspace tutorial scripts carry LaTeX in their narrative docstrings, so + those docstrings must be raw. `_narrative_docstring_ranges` did not see an + `r\"\"\"` opener as a block boundary, so the section inside it was invisible + to the strip and the developer-only `ENV:` line leaked into the generated + notebook and markdown. + """ + out = "".join(strip_env_declarations(_lines(RAW_MERGED_SCRIPT))) + + _assert_no_env_leak(out) + # The user-facing prose and the LaTeX survive untouched. + assert "Imaging Example" in out + assert "Wrap Up" in out + assert "$\\theta_E$" in out + assert "al.do_something()" in out