From 9db5a9c3bdea90c58e2c7073f8ae836bfd14941f Mon Sep 17 00:00:00 2001 From: Robbie1977 Date: Thu, 16 Jul 2026 20:32:30 +0000 Subject: [PATCH] Strip stray backslash-escaping in query preview-row markdown cells PR #80 cleaned a term's own labels (term_info_queries.clean_label), but query preview rows are assembled in vfb_queries.py from raw Neo4j labels via apoc.text.format and never pass through that path. So an embedded neuron label containing an apostrophe (e.g. the NBLAST 'similar neurons' preview showing MBON01(y5B\'2a)_R) still carried the stray backslash into the name/thumbnail markdown cells. Add the same stray-quote stripping into encode_markdown_links -- the shared 'prepare a markdown cell for display' pass already responsible for securing URLs and bracket-encoding labels for these columns -- so every preview name/thumbnail/label cell is cleaned in one vectorised pass. Guarded on a cheap literal-backslash check so it stays essentially free on large frames. Bump version to 1.22.33. Adds network-free unit tests over encode_markdown_links. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RQZ8eMr398cK1bnzN2XTqK --- src/test/test_preview_stray_backslash.py | 66 ++++++++++++++++++++++++ src/vfbquery/_version.py | 2 +- src/vfbquery/vfb_queries.py | 20 +++++++ 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 src/test/test_preview_stray_backslash.py diff --git a/src/test/test_preview_stray_backslash.py b/src/test/test_preview_stray_backslash.py new file mode 100644 index 0000000..14f5631 --- /dev/null +++ b/src/test/test_preview_stray_backslash.py @@ -0,0 +1,66 @@ +import unittest + +import pandas as pd + +from vfbquery.vfb_queries import encode_markdown_links + + +class PreviewStrayBackslashTest(unittest.TestCase): + """Query preview rows are assembled in vfb_queries.py from raw Neo4j labels + (via apoc.text.format), NOT through term_info_queries.clean_label. So the + stray backslash-before-quote artifact that PR #80 stripped from a term's own + labels still reached the ``name`` / ``thumbnail`` markdown cells of query + previews when an embedded neuron label contained an apostrophe (e.g. the + NBLAST 'similar neurons' preview showing ``MBON01(y5B\\'2a)_R``). + + encode_markdown_links is the shared 'prepare a markdown cell for display' + pass for those columns, so the escape stripping lives there. These tests are + network-free: they exercise the encoder directly on production-shaped + (object-dtype) frames. + """ + + def _obj_df(self, data): + # Match the dtype the live pipeline produces (pd.DataFrame.from_records + # of dict rows yields object columns). A str/StringDtype column would be + # skipped by the encoder's ``dtype != object`` guard. + return pd.DataFrame({k: pd.Series(v, dtype=object) for k, v in data.items()}) + + def test_link_cell_stray_backslash_stripped(self): + raw = "[MBON01(y5B\\'2a)_R (FlyEM-HB:612371421)](VFB_jrchk0e2)" + df = self._obj_df({"name": [raw]}) + out = encode_markdown_links(df, ["name"]) + val = out["name"].iloc[0] + self.assertNotIn("\\", val) + self.assertEqual( + "[MBON01(y5B'2a)_R (FlyEM-HB:612371421)](VFB_jrchk0e2)", val + ) + + def test_image_cell_stray_backslash_stripped(self): + # https URL (as produced by the Cypher REPLACE) so the only thing that + # should change is the removal of the stray backslashes in alt + title. + raw = ( + "[![MBON01(y5B\\'2a)_R aligned to JRCFIB2018Fum]" + "(https://www.virtualflybrain.org/data/x/thumbnail.png " + "'MBON01(y5B\\'2a)_R aligned to JRCFIB2018Fum')](t,VFB_jrchk0e2)" + ) + df = self._obj_df({"thumbnail": [raw]}) + out = encode_markdown_links(df, ["thumbnail"]) + val = out["thumbnail"].iloc[0] + self.assertNotIn("\\", val) + self.assertIn("MBON01(y5B'2a)_R", val) + + def test_clean_cells_untouched(self): + raw = "[MBON01_L](VFB_00000001)" + df = self._obj_df({"name": [raw]}) + out = encode_markdown_links(df, ["name"]) + self.assertEqual(raw, out["name"].iloc[0]) + + def test_null_cells_preserved(self): + df = self._obj_df({"name": [None, "[a\\'b](x)"]}) + out = encode_markdown_links(df, ["name"]) + self.assertTrue(pd.isna(out["name"].iloc[0])) + self.assertNotIn("\\", out["name"].iloc[1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/vfbquery/_version.py b/src/vfbquery/_version.py index a797213..1348b3a 100644 --- a/src/vfbquery/_version.py +++ b/src/vfbquery/_version.py @@ -6,4 +6,4 @@ cache's version stamp can never drift apart. """ -__version__ = "1.22.32" +__version__ = "1.22.33" diff --git a/src/vfbquery/vfb_queries.py b/src/vfbquery/vfb_queries.py index 38ede4b..a70bc44 100644 --- a/src/vfbquery/vfb_queries.py +++ b/src/vfbquery/vfb_queries.py @@ -420,6 +420,15 @@ def encode_brackets(text): # markdown parser. _RE_MD_LINK = re.compile(r'\[([^\]]+)\]\(([^\)]+)\)') +# Stray backslash-before-quote artifact (e.g. y5B\'2a -> y5B'2a). The source +# label data carries a spurious backslash before apostrophes/quotes; it must be +# stripped before it reaches a display cell (and, for link cells, before it can +# leak into an HTTP request target where a backslash is illegal). Mirrors +# term_info_queries.clean_label so a term's own labels and the labels embedded +# in query preview-row markdown (name / thumbnail / label cells, built here from +# raw Neo4j labels via apoc.text.format) are cleaned identically. +_STRAY_QUOTE_ESCAPE = re.compile(r"\\(['\"])") + def _encode_image_url(match: 're.Match') -> str: """Repl callback for image-markdown cells — secure URL, preserve rest.""" @@ -479,6 +488,17 @@ def encode_markdown_links(df, columns): continue non_null = s.where(notnull_mask, '').astype(str) + # Strip stray backslash-before-quote artifacts from every cell first, so + # embedded labels (link text, image alt text, titles) are cleaned in + # both the image-markdown and label-shape branches below. PR #80 cleaned + # the term's own labels via term_info_queries.clean_label, but query + # preview rows are assembled here from raw Neo4j labels and never passed + # through that path, so the escape survived into name/thumbnail cells. + # Vectorised + guarded on a cheap literal-backslash check so it stays + # essentially free on the large frames this function was tuned for. + if non_null.str.contains('\\', regex=False, na=False).any(): + non_null = non_null.str.replace(_STRAY_QUOTE_ESCAPE, r"\1", regex=True) + # Branch on shape using a vectorised prefix check — this is a single # C-loop over the column, far cheaper than re-detecting per row. is_image = non_null.str.startswith('[![', na=False)