From 2db44550e17eb511338c7818152e8dc3b05dab17 Mon Sep 17 00:00:00 2001 From: Bret Date: Sun, 8 Nov 2026 21:46:47 -0800 Subject: [PATCH 1/5] Fix unescaped underscore symbols in test docstrings --- cirq-core/cirq/_compat_test.py | 12 ++++++------ cirq-core/cirq/circuits/circuit_test.py | 4 ++-- cirq-core/cirq/contrib/ghz/ghz_2d_test.py | 2 +- cirq-core/cirq/ops/common_gates_test.py | 4 ++-- .../protocols/approximate_equality_protocol_test.py | 4 ++-- cirq-core/cirq/protocols/hash_from_pickle_test.py | 2 +- cirq-core/cirq/protocols/kraus_protocol_test.py | 4 +++- .../cirq/transformers/diagonal_optimization_test.py | 2 +- .../cirq/transformers/dynamical_decoupling_test.py | 2 +- cirq-core/cirq/value/abc_alt_test.py | 2 +- .../cirq_google/study/finite_random_variable_test.py | 2 +- 11 files changed, 21 insertions(+), 19 deletions(-) diff --git a/cirq-core/cirq/_compat_test.py b/cirq-core/cirq/_compat_test.py index f7b77cb836..c4b2720f3a 100644 --- a/cirq-core/cirq/_compat_test.py +++ b/cirq-core/cirq/_compat_test.py @@ -423,8 +423,8 @@ def _from_deprecated_import_sub(): def _import_deprecated_first_new_second(): """To ensure that module_a gets initialized only once. - Note that the single execution of _compat_test_data and module_a is asserted - in _test_deprecated_module_inner by counting the INFO messages emitted by + Note that the single execution of `_compat_test_data` and `module_a` is asserted + in `_test_deprecated_module_inner` by counting the INFO messages emitted by the modules ('init:compat_test_data' and 'init:module_a'). """ @@ -449,7 +449,7 @@ def _import_deprecated_first_new_second(): def _import_new_first_deprecated_second(): """To ensure that module_a gets initialized only once. - It is the same as _import_deprecated_first_new_second just with different import order. + It is the same as `_import_deprecated_first_new_second` just with different import order. See that for more details. """ from cirq.testing._compat_test_data.module_a import module_b @@ -727,10 +727,10 @@ def test_same_name_submodule_earlier_in_subtree(): """Tests whether module resolution works in the right order. We have two packages with a bool `DUPE_CONSTANT` attribute each: - 1. cirq.testing._compat_test_data.module_a.sub.dupe.DUPE_CONSTANT=True # the right one - 2. cirq.testing._compat_test_data.module_a.dupe.DUPE_CONSTANT=False # the wrong one + 1. `cirq.testing._compat_test_data.module_a.sub.dupe.DUPE_CONSTANT=True` # the right one + 2. `cirq.testing._compat_test_data.module_a.dupe.DUPE_CONSTANT=False` # the wrong one - If the new module's (in this case cirq.testing._compat_test_data.module_a) path has precedence + If the new module's (in this case `cirq.testing._compat_test_data.module_a`) path has precedence during module spec resolution, dupe number 2 is going to get resolved. You might wonder where this comes up in cirq. There was a bug where the lookup path was not in diff --git a/cirq-core/cirq/circuits/circuit_test.py b/cirq-core/cirq/circuits/circuit_test.py index 378851e71a..b83611bac7 100644 --- a/cirq-core/cirq/circuits/circuit_test.py +++ b/cirq-core/cirq/circuits/circuit_test.py @@ -2620,7 +2620,7 @@ def test_circuit_superoperator_too_many_qubits() -> None: ), ) def test_circuit_superoperator_fixed_values(circuit, expected_superoperator) -> None: - """Tests Circuit._superoperator_() on a few simple circuits.""" + """Tests `Circuit._superoperator_()` on a few simple circuits.""" assert circuit._has_superoperator_() assert np.allclose(circuit._superoperator_(), expected_superoperator) @@ -2638,7 +2638,7 @@ def test_circuit_superoperator_fixed_values(circuit, expected_superoperator) -> ), ) def test_circuit_superoperator_depolarizing_channel_compositions(rs, n_qubits) -> None: - """Tests Circuit._superoperator_() on compositions of depolarizing channels.""" + """Tests `Circuit._superoperator_()` on compositions of depolarizing channels.""" def pauli_error_probability(r: float, n_qubits: int) -> float: """Computes Pauli error probability for given depolarization parameter. diff --git a/cirq-core/cirq/contrib/ghz/ghz_2d_test.py b/cirq-core/cirq/contrib/ghz/ghz_2d_test.py index 55570d8d7c..aa3f775a27 100644 --- a/cirq-core/cirq/contrib/ghz/ghz_2d_test.py +++ b/cirq-core/cirq/contrib/ghz/ghz_2d_test.py @@ -82,7 +82,7 @@ def test_ghz_circuits_state( def test_transform_circuit_properties() -> None: - """Tests that _transform_circuit preserves circuit properties.""" + """Tests that `_transform_circuit` preserves circuit properties.""" circuit = ghz_2d.generate_2d_ghz_circuit( CENTER_QUBIT, GRAPH, num_qubits=9, randomized=False, add_dd_and_align_right=False ) diff --git a/cirq-core/cirq/ops/common_gates_test.py b/cirq-core/cirq/ops/common_gates_test.py index f73496a58c..09514ddd01 100644 --- a/cirq-core/cirq/ops/common_gates_test.py +++ b/cirq-core/cirq/ops/common_gates_test.py @@ -432,14 +432,14 @@ def test_x_act_on_tableau() -> None: class iZGate(cirq.testing.SingleQubitGate): - """Equivalent to an iZ gate without _act_on_ defined on it.""" + """Equivalent to an iZ gate without `_act_on_` defined on it.""" def _unitary_(self): return np.array([[1j, 0], [0, -1j]]) class MinusOnePhaseGate(cirq.testing.SingleQubitGate): - """Equivalent to a -1 global phase without _act_on_ defined on it.""" + """Equivalent to a -1 global phase without `_act_on_` defined on it.""" def _unitary_(self): return np.array([[-1, 0], [0, -1]]) diff --git a/cirq-core/cirq/protocols/approximate_equality_protocol_test.py b/cirq-core/cirq/protocols/approximate_equality_protocol_test.py index f43f50ba13..b113c720cd 100644 --- a/cirq-core/cirq/protocols/approximate_equality_protocol_test.py +++ b/cirq-core/cirq/protocols/approximate_equality_protocol_test.py @@ -113,7 +113,7 @@ def test_approx_eq_special_numerics() -> None: class X(Number): - """Subtype of Number that can fallback to __eq__""" + """Subtype of Number that can fallback to `__eq__`""" def __init__(self, val): self.val = val @@ -128,7 +128,7 @@ def __hash__(self): class Y(Number): - """Subtype of Number that cannot fallback to __eq__""" + """Subtype of Number that cannot fallback to `__eq__`""" def __init__(self): pass diff --git a/cirq-core/cirq/protocols/hash_from_pickle_test.py b/cirq-core/cirq/protocols/hash_from_pickle_test.py index b1b69428b2..64ac6af700 100644 --- a/cirq-core/cirq/protocols/hash_from_pickle_test.py +++ b/cirq-core/cirq/protocols/hash_from_pickle_test.py @@ -80,7 +80,7 @@ def _read_json(json_filename: str) -> Any: def test_exclude_json_files_has_valid_entries() -> None: - """Verify _EXCLUDE_JSON_FILES has valid entries.""" + """Verify `_EXCLUDE_JSON_FILES` has valid entries.""" json_file_validates = lambda f: any( m.test_data_path.joinpath(os.path.basename(f)).is_file() for m in MODULE_TEST_SPECS ) diff --git a/cirq-core/cirq/protocols/kraus_protocol_test.py b/cirq-core/cirq/protocols/kraus_protocol_test.py index 0229510489..4904aa8585 100644 --- a/cirq-core/cirq/protocols/kraus_protocol_test.py +++ b/cirq-core/cirq/protocols/kraus_protocol_test.py @@ -202,7 +202,9 @@ def _apply_channel_(self, args): ], ) def test_kraus_fallback_to_apply_channel(channel_cls, params) -> None: - """Kraus protocol falls back to _apply_channel_ when no _kraus_, _mixture_, or _unitary_.""" + """Kraus protocol falls back to `_apply_channel_` when no `_kraus_`, `_mixture_`, + or `_unitary_`. + """ # Create the expected channel and get its Kraus operators expected_channel = channel_cls(*params) expected_kraus = cirq.kraus(expected_channel) diff --git a/cirq-core/cirq/transformers/diagonal_optimization_test.py b/cirq-core/cirq/transformers/diagonal_optimization_test.py index 0deeeda632..ca59457cc4 100644 --- a/cirq-core/cirq/transformers/diagonal_optimization_test.py +++ b/cirq-core/cirq/transformers/diagonal_optimization_test.py @@ -211,7 +211,7 @@ def test_unrecognized_diagonal_breaks_chain(): def test_is_z_or_cz_pow_gate_helper_edge_cases(): - """Test edge cases in _is_z_or_cz_pow_gate helper function for full coverage.""" + """Test edge cases in `_is_z_or_cz_pow_gate` helper function for full coverage.""" q = cirq.NamedQubit('q') diff --git a/cirq-core/cirq/transformers/dynamical_decoupling_test.py b/cirq-core/cirq/transformers/dynamical_decoupling_test.py index 61ae747836..3792e1da67 100644 --- a/cirq-core/cirq/transformers/dynamical_decoupling_test.py +++ b/cirq-core/cirq/transformers/dynamical_decoupling_test.py @@ -860,7 +860,7 @@ def test_labeled_circuit_str(): def test_labeled_circuit_str_empty(): - """Tests the __str__ method of _Grid for empty and no-qubit circuits.""" + """Tests the `__str__` method of `_Grid` for empty and no-qubit circuits.""" # Test case for an empty circuit (no moments, no qubits) empty_circuit = cirq.Circuit() labeled_empty = _Grid.from_circuit(empty_circuit, single_qubit_gate_moments_only=True) diff --git a/cirq-core/cirq/value/abc_alt_test.py b/cirq-core/cirq/value/abc_alt_test.py index f9e288c0be..a988bb7398 100644 --- a/cirq-core/cirq/value/abc_alt_test.py +++ b/cirq-core/cirq/value/abc_alt_test.py @@ -135,7 +135,7 @@ def alt(self): def test_classcell_in_namespace(): """Tests a historical issue where super() triggers Python to add - `__classcell__` to the namespace passed to the metaclass __new__. + `__classcell__` to the namespace passed to the metaclass `__new__`. """ # Test that the class is created without wrongly raising a TypeError diff --git a/cirq-google/cirq_google/study/finite_random_variable_test.py b/cirq-google/cirq_google/study/finite_random_variable_test.py index 8dd7a47f9d..8c6602b16d 100644 --- a/cirq-google/cirq_google/study/finite_random_variable_test.py +++ b/cirq-google/cirq_google/study/finite_random_variable_test.py @@ -115,7 +115,7 @@ def test_rng_state_is_restored(): def test_equality(): - """Tests the __eq__ method for comparing sweep instances.""" + """Tests the `__eq__` method for comparing sweep instances.""" sweep1 = cirq_google.study.FiniteRandomVariable( key=KEY, distribution=DIST, seed=SEED, length=LENGTH ) From 4ef84d69f29d6b021556df00a0d1bbdaa2f73ec1 Mon Sep 17 00:00:00 2001 From: Bret Date: Sun, 8 Nov 2026 21:46:47 -0800 Subject: [PATCH 2/5] Fix unescaped underscore emphasis in grid_qubit docstring --- cirq-core/cirq/devices/grid_qubit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cirq-core/cirq/devices/grid_qubit.py b/cirq-core/cirq/devices/grid_qubit.py index 427596c371..f20dfa74b9 100644 --- a/cirq-core/cirq/devices/grid_qubit.py +++ b/cirq-core/cirq/devices/grid_qubit.py @@ -119,7 +119,7 @@ def is_adjacent(self, other: cirq.Qid) -> bool: def neighbors(self, qids: Iterable[ops.Qid] | None = None) -> set[_BaseGridQid]: """Returns qubits that are potential neighbors to this GridQid. - Note that this returns _potential_ neighbors. That is, if no arguments + Note that this returns *potential* neighbors. That is, if no arguments are given, this returns the qubits above, below, to the right and left of the Qid in the grid. It does not take into account any hardware device layout. From e7942bce53a41f6ec3a851b88b209b36a8e349e7 Mon Sep 17 00:00:00 2001 From: Bret Date: Sun, 8 Nov 2026 21:46:47 -0800 Subject: [PATCH 3/5] Add Pylint checker and test for unescaped underscore symbols in docstrings --- .../pylint_docstring_underscore_checker.py | 330 ++++++++++++++++++ ...ylint_docstring_underscore_checker_test.py | 272 +++++++++++++++ pyproject.toml | 2 + 3 files changed, 604 insertions(+) create mode 100644 dev_tools/pylint_docstring_underscore_checker.py create mode 100644 dev_tools/pylint_docstring_underscore_checker_test.py diff --git a/dev_tools/pylint_docstring_underscore_checker.py b/dev_tools/pylint_docstring_underscore_checker.py new file mode 100644 index 0000000000..9b485ecf4a --- /dev/null +++ b/dev_tools/pylint_docstring_underscore_checker.py @@ -0,0 +1,330 @@ +# Copyright 2026 The Cirq Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Pylint checker and helpers for detecting unescaped underscore symbols in docstrings.""" + +from __future__ import annotations + +import ast +import io +import pathlib +import re +import tokenize +from typing import TYPE_CHECKING + +from pylint.checkers import BaseRawFileChecker + +if TYPE_CHECKING: + from astroid import nodes + from pylint.lint import PyLinter + +# Matches unescaped symbols in docstring text that Markdown/DevSite renders as bold/italics: +# 1. Double-underscore (__foo__) or single-underscore-wrapped (_foo_) identifiers +# 2. Leading-underscore identifiers containing at least one more underscore (_foo_bar) +# 3. Trailing-underscore identifiers containing at least one internal underscore (foo_bar_) +UNESCAPED_UNDERSCORE_PATTERN = re.compile( + r"(?>>.*(?:\n[ \t]*\.\.\..*)*", flags=re.MULTILINE) +_EXAMPLE_OUTPUT_BLOCK_RE = re.compile( + r"^[ \t]*===\s*example output\s*===.*$", flags=re.IGNORECASE | re.DOTALL | re.MULTILINE +) +_DOUBLE_BACKTICK_RE = re.compile(r"``(?:(?!``)[^\n])+``") +_SINGLE_BACKTICK_RE = re.compile(r"`[^`\n]+`") +_URL_RE = re.compile(r"https?://\S+") +# Pandoc-style single-line inline math: opening $ followed by non-space/non-$, +# closing $ preceded by non-space/non-\ and not followed by a digit or $. +_INLINE_MATH_RE = re.compile(r"(? str: + """Replaces matched characters with spaces while preserving newlines for line numbers.""" + return re.sub(r"[^\n]", " ", match.group(0)) + + +def mask_docstring_code_and_math(docstring: str) -> str: + """Masks out code blocks, doctests, inline code, URLs, LaTeX math, and `Args:` names. + + Newlines and character positions within unmasked text are preserved so that + line numbers and column offsets remain accurate. + """ + lines = docstring.splitlines(keepends=True) + indents = [len(line) - len(line.lstrip(" ")) for line in lines[1:] if line.strip()] + base_indent = min(indents) if indents else 0 + + # 1. Mask multi-line / block constructs first. + masked = _FENCED_CODE_BLOCK_RE.sub(_mask_with_spaces, docstring) + masked = _DISPLAY_MATH_RE.sub(_mask_with_spaces, masked) + masked = _DOCTEST_BLOCK_RE.sub(_mask_with_spaces, masked) + masked = _EXAMPLE_OUTPUT_BLOCK_RE.sub(_mask_with_spaces, masked) + + # 2. Mask indented code blocks and parameter names inside Args/Attributes sections. + out_lines: list[str] = [] + in_indented_block = False + in_param_section = False + in_code_section = False + prev_ended_with_colon = False + saw_blank_since_colon = False + + for idx, line in enumerate(masked.splitlines(keepends=True)): + stripped = line.strip() + if not stripped: + if prev_ended_with_colon: + saw_blank_since_colon = True + out_lines.append(line) + continue + + indent = len(line) - len(line.lstrip(" ")) + rel_indent = indent - base_indent if idx > 0 else 0 + + if rel_indent <= 0: + in_indented_block = False + in_code_section = False + header_match = _SECTION_HEADER_RE.match(stripped) + if header_match: + section_name = header_match.group(1) + in_param_section = section_name in _PARAM_SECTION_NAMES + in_code_section = section_name in _CODE_SECTION_NAMES + prev_ended_with_colon = False + saw_blank_since_colon = False + out_lines.append(line) + continue + if stripped.endswith(":"): + in_param_section = False + + if in_indented_block: + if rel_indent >= 4: + out_lines.append(re.sub(r"[^\n]", " ", line)) + continue + in_indented_block = False + + if ( + in_code_section or (prev_ended_with_colon and saw_blank_since_colon) + ) and rel_indent >= 4: + in_indented_block = True + out_lines.append(re.sub(r"[^\n]", " ", line)) + continue + + if in_param_section and rel_indent >= 2 and _PARAM_DEF_RE.match(stripped): + colon_idx = line.index(":") + line = " " * colon_idx + line[colon_idx:] + + prev_ended_with_colon = stripped.endswith(":") + saw_blank_since_colon = False + out_lines.append(line) + + masked = "".join(out_lines) + + # 3. Mask single-line inline constructs. + masked = _DOUBLE_BACKTICK_RE.sub(_mask_with_spaces, masked) + masked = _SINGLE_BACKTICK_RE.sub(_mask_with_spaces, masked) + masked = _URL_RE.sub(_mask_with_spaces, masked) + masked = _INLINE_MATH_RE.sub(_mask_with_spaces, masked) + return masked + + +def find_unescaped_underscore_symbols_in_docstring( + docstring: str, line_map: list[tuple[int, int]] | None = None +) -> list[tuple[int, int, str]]: + """Finds unescaped underscore-delimited symbols in a docstring. + + Args: + docstring: The docstring text to inspect. + line_map: Optional list of `(source_lineno, source_col_offset)` for each line in + `docstring`. + + Returns: + A list of `(lineno, col_offset, symbol)` tuples for each unescaped symbol found. + """ + masked = mask_docstring_code_and_math(docstring) + results: list[tuple[int, int, str]] = [] + for line_idx, line in enumerate(masked.splitlines()): + if line_map is not None and line_idx < len(line_map): + lineno, col_base = line_map[line_idx] + else: + lineno = 1 + line_idx + col_base = 0 + for match in UNESCAPED_UNDERSCORE_PATTERN.finditer(line): + results.append((lineno, col_base + match.start(), match.group(1))) + return results + + +def _extract_docstring_nodes(tree: ast.AST) -> list[ast.Constant]: + """Extracts AST Constant string nodes for module/class/function/attribute docstrings and + `document()` calls. + """ + nodes: list[ast.Constant] = [] + for node in ast.walk(tree): + if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): + if ( + node.body + and isinstance(node.body[0], ast.Expr) + and isinstance(node.body[0].value, ast.Constant) + and isinstance(node.body[0].value.value, str) + ): + nodes.append(node.body[0].value) + if isinstance(node, (ast.Module, ast.ClassDef)): + for i in range(len(node.body) - 1): + stmt = node.body[i] + next_stmt = node.body[i + 1] + if ( + isinstance(stmt, (ast.Assign, ast.AnnAssign)) + and isinstance(next_stmt, ast.Expr) + and isinstance(next_stmt.value, ast.Constant) + and isinstance(next_stmt.value.value, str) + ): + nodes.append(next_stmt.value) + elif isinstance(node, ast.Call): + func_name = None + if isinstance(node.func, ast.Name): + func_name = node.func.id + elif isinstance(node.func, ast.Attribute): + func_name = node.func.attr + if func_name == "document": + if ( + len(node.args) >= 2 + and isinstance(node.args[1], ast.Constant) + and isinstance(node.args[1].value, str) + ): + nodes.append(node.args[1]) + for kw in node.keywords: + if ( + kw.arg == "doc_string" + and isinstance(kw.value, ast.Constant) + and isinstance(kw.value.value, str) + ): + nodes.append(kw.value) + return nodes + + +def _get_raw_docstring_and_line_map( + string_tokens: list[tokenize.TokenInfo], node: ast.Constant +) -> tuple[str, list[tuple[int, int]]]: + """Reconstructs raw text and `(source_lineno, source_col_offset)` per line for `node`.""" + start = (node.lineno, node.col_offset) + end = (node.end_lineno or node.lineno, node.end_col_offset or 0) + raw_parts: list[str] = [] + line_map: list[tuple[int, int]] = [] + + for tok in string_tokens: + if tok.start >= start and tok.end <= end: + m = _STRING_TOKEN_QUOTE_RE.match(tok.string) + if not m: # pragma: no cover + continue + prefix_len = len(m.group(1)) + inner = m.group(3) + inner_lines = inner.splitlines(keepends=True) + if not inner_lines: + inner_lines = [""] + for idx, line_text in enumerate(inner_lines): + src_line = tok.start[0] + idx + src_col = (tok.start[1] + prefix_len) if idx == 0 else 0 + if idx == 0 and raw_parts and not raw_parts[-1].endswith("\n"): + raw_parts.append("\n") + raw_parts.append(line_text) + line_map.append((src_line, src_col)) + + if not line_map: # pragma: no cover + return node.value, [(node.lineno, node.col_offset)] + return "".join(raw_parts), line_map + + +def find_unescaped_underscore_symbols_in_source(source: str) -> list[tuple[int, int, str]]: + """Parses Python source code and checks all docstrings and `document()` calls. + + Args: + source: The Python source code string. + + Returns: + A sorted list of `(lineno, col_offset, symbol)` tuples for each unescaped symbol found. + """ + tree = ast.parse(source) + string_tokens = [ + tok + for tok in tokenize.generate_tokens(io.StringIO(source).readline) + if tok.type == tokenize.STRING + ] + findings: list[tuple[int, int, str]] = [] + + for doc_node in _extract_docstring_nodes(tree): + raw_text, line_map = _get_raw_docstring_and_line_map(string_tokens, doc_node) + findings.extend(find_unescaped_underscore_symbols_in_docstring(raw_text, line_map=line_map)) + + findings.sort() + return findings + + +def find_unescaped_underscore_symbols_in_file( + filepath: str | pathlib.Path, +) -> list[tuple[int, int, str]]: + """Checks a Python file for unescaped underscore symbols in docstrings.""" + source = pathlib.Path(filepath).read_text(encoding="utf-8") + return find_unescaped_underscore_symbols_in_source(source) + + +class DocstringUnderscoreChecker(BaseRawFileChecker): + """Pylint checker for unescaped underscore-delimited symbols in docstrings. + + This check can be disabled with `# pylint: disable=unescaped-underscore-in-docstring`. + """ + + name = "unescaped-underscore-in-docstring" + msgs = { + "C0002": ( + "Unescaped symbol '%s' in docstring; wrap literal symbols in backticks", + "unescaped-underscore-in-docstring", + ( + "Wrap underscore-delimited symbols (such as `__init__`, `_unitary_`, or " + "`_private_helper`) in backticks in docstrings so Markdown does not render " + "them as italics or bold." + ), + ) + } + options = () + + def process_module(self, node: nodes.Module) -> None: + """Checks docstrings in the module for unescaped underscore symbols.""" + if not self.linter.is_message_enabled("unescaped-underscore-in-docstring"): + return + with node.stream() as stream: + source = stream.read().decode("utf-8", errors="replace") + try: + findings = find_unescaped_underscore_symbols_in_source(source) + except (SyntaxError, tokenize.TokenError): # pragma: no cover + return + for lineno, col_offset, symbol in findings: + self.add_message( + "unescaped-underscore-in-docstring", + line=lineno, + col_offset=col_offset, + args=(symbol,), + ) + + +def register(linter: PyLinter) -> None: + """Register this checker to pylint.""" + linter.register_checker(DocstringUnderscoreChecker(linter)) diff --git a/dev_tools/pylint_docstring_underscore_checker_test.py b/dev_tools/pylint_docstring_underscore_checker_test.py new file mode 100644 index 0000000000..dd4e1e54b1 --- /dev/null +++ b/dev_tools/pylint_docstring_underscore_checker_test.py @@ -0,0 +1,272 @@ +# Copyright 2026 The Cirq Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import pathlib +import tomllib + +import pytest +from astroid import parse +from pylint.lint import Run +from pylint.reporters import CollectingReporter +from pylint.testutils import CheckerTestCase, MessageTest + +from dev_tools.pylint_docstring_underscore_checker import ( + DocstringUnderscoreChecker, + find_unescaped_underscore_symbols_in_source, +) + +REPO_ROOT = pathlib.Path(__file__).parent.parent + + +class TestDocstringUnderscoreChecker(CheckerTestCase): + r"""Test the unescaped-underscore-in-docstring checker for Pylint.""" + + CHECKER_CLASS = DocstringUnderscoreChecker + + def test_unescaped_symbols_reported_with_exact_columns(self) -> None: + node = parse('"""Uses __init__ and _unitary_ without backticks."""\n') + with self.assertAddsMessages( + MessageTest( + msg_id="unescaped-underscore-in-docstring", line=1, col_offset=8, args=("__init__",) + ), + MessageTest( + msg_id="unescaped-underscore-in-docstring", + line=1, + col_offset=21, + args=("_unitary_",), + ), + ): + self.checker.process_module(node) + + def test_multiline_and_nested_definitions(self) -> None: + source = ( + 'class Outer:\n' + ' """Summary line.\n' + '\n' + ' Details calling _private_helper on line 4.\n' + ' """\n' + ' def inner(self) -> None:\n' + ' """Inner method referencing __call__."""\n' + ' "Bare non-docstring string with __ignored__ in body."\n' + ) + node = parse(source) + with self.assertAddsMessages( + MessageTest( + msg_id="unescaped-underscore-in-docstring", + line=4, + col_offset=20, + args=("_private_helper",), + ), + MessageTest( + msg_id="unescaped-underscore-in-docstring", + line=7, + col_offset=36, + args=("__call__",), + ), + ): + self.checker.process_module(node) + + def test_document_call_and_attribute_docstrings(self) -> None: + source = ( + 'X = 1\n' + '"""Attribute docstring with _attr_symbol_."""\n' + 'document(X, "Defaults to __debug__.")\n' + '_doc.document(X, doc_string="Keyword _unitary_.")\n' + ) + node = parse(source) + with self.assertAddsMessages( + MessageTest( + msg_id="unescaped-underscore-in-docstring", + line=2, + col_offset=28, + args=("_attr_symbol_",), + ), + MessageTest( + msg_id="unescaped-underscore-in-docstring", + line=3, + col_offset=25, + args=("__debug__",), + ), + MessageTest( + msg_id="unescaped-underscore-in-docstring", + line=4, + col_offset=37, + args=("_unitary_",), + ), + ): + self.checker.process_module(node) + + def test_stray_currency_dollar_does_not_mask_symbols(self) -> None: + source = '"""Price is $5 later _a_b_ and\nmore $6 after __init__."""\n' + node = parse(source) + with self.assertAddsMessages( + MessageTest( + msg_id="unescaped-underscore-in-docstring", line=1, col_offset=21, args=("_a_b_",) + ), + MessageTest( + msg_id="unescaped-underscore-in-docstring", + line=2, + col_offset=14, + args=("__init__",), + ), + ): + self.checker.process_module(node) + + def test_colon_in_prose_outside_args_is_flagged(self) -> None: + source = '"""Note:\n __init__: initializes the instance.\n"""\n' + node = parse(source) + with self.assertAddsMessages( + MessageTest( + msg_id="unescaped-underscore-in-docstring", line=2, col_offset=4, args=("__init__",) + ) + ): + self.checker.process_module(node) + + def test_leading_ellipsis_in_prose_is_flagged(self) -> None: + source = '"""Summary.\n\n..._foo_bar_ at the start of a prose line.\n"""\n' + node = parse(source) + with self.assertAddsMessages( + MessageTest( + msg_id="unescaped-underscore-in-docstring", + line=3, + col_offset=3, + args=("_foo_bar_",), + ) + ): + self.checker.process_module(node) + + @pytest.mark.parametrize( + "source", + [ + pytest.param( + '"""Uses `__init__`, `_unitary_`, and ``_private_helper`` in backticks."""\n', + id="inline-backticks", + ), + pytest.param( + '"""Code block:\n\n' + '```python\ndef __init__(self):\n self._unitary_()\n```\n"""\n', + id="fenced-code-block", + ), + pytest.param( + '"""Doctest:\n>>> obj._unitary_(\n... __init__\n... )\n"""\n', + id="doctest-with-continuation", + ), + pytest.param( + '"""Indented code block:\n\n' + ' Usage:\n' + ' def _default_do_a_using_b(self):\n' + ' pass\n"""\n', + id="indented-code-block", + ), + pytest.param( + '"""Program output:\n\n' + '=== Example output ===\n' + 'Bases match: X_XXXXX_\n' + 'a: _1___1_1_1111_\n"""\n', + id="example-output-block", + ), + pytest.param( + '"""Math expression $a_{i_1} + b_{j_2}$ and display $$x_{a_b}$$."""\n', + id="latex-math", + ), + pytest.param( + '"""URL https://example.com/_foo_bar/__init__ and domain b_c_.com and 1_000_."""\n', + id="urls-domains-and-numeric-literals", + ), + pytest.param( + 'r"""Backslash-escaped \\_unitary\\_ and \\__init\\__ in raw docstring."""\n', + id="backslash-escaped", + ), + pytest.param( + '"""Summary.\n\n' + 'Args:\n' + ' _flatten_contents: If True, flattens.\n' + ' _typed_arg (bool): Typed parameter.\n' + ' **_extra_kwargs: Extra args.\n"""\n', + id="args-section-parameter-names", + ), + ], + ) + def test_valid_contexts_not_flagged(self, source: str) -> None: + node = parse(source) + with self.assertNoMessages(): + self.checker.process_module(node) + + def test_disabled_message_early_return(self, monkeypatch: pytest.MonkeyPatch) -> None: + node = parse('"""Docstring with __init__."""\n') + monkeypatch.setattr(self.linter, "is_message_enabled", lambda *_args, **_kwargs: False) + with self.assertNoMessages(): + self.checker.process_module(node) + + +def _lint(tmp_path: pathlib.Path, source: str) -> list[str]: + path = tmp_path / "mod.py" + path.write_text(source, encoding="utf-8") + reporter = CollectingReporter() + Run( + [ + str(path), + "--persistent=n", + "--disable=all", + "--enable=unescaped-underscore-in-docstring,useless-suppression", + "--load-plugins=dev_tools.pylint_docstring_underscore_checker", + ], + reporter=reporter, + exit=False, + ) + return [m.symbol for m in reporter.messages] + + +@pytest.mark.parametrize( + "source", + [ + pytest.param( + '# pylint: disable=unescaped-underscore-in-docstring\n"""Uses __init__."""\n', id="file" + ), + pytest.param( + 'def f():\n' + ' # pylint: disable=unescaped-underscore-in-docstring\n' + ' """Uses __init__."""\n', + id="scope", + ), + pytest.param( + 'class K:\n' + ' # pylint: disable-next=unescaped-underscore-in-docstring\n' + ' """Uses __init__."""\n', + id="next", + ), + ], +) +def test_pragma_suppresses_cleanly(tmp_path: pathlib.Path, source: str) -> None: + assert _lint(tmp_path, source) == [] + + +def test_unsuppressed_docstring_is_reported(tmp_path: pathlib.Path) -> None: + assert _lint(tmp_path, '"""Uses __init__."""\n') == ["unescaped-underscore-in-docstring"] + + +def test_pyproject_enables_unescaped_underscore_checker() -> None: + """Verify the plugin is loaded and enabled in pyproject.toml.""" + pyproject = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + pylint_cfg = pyproject["tool"]["pylint"] + assert "dev_tools.pylint_docstring_underscore_checker" in pylint_cfg["main"]["load-plugins"] + assert "unescaped-underscore-in-docstring" in pylint_cfg["messages_control"]["enable"] + + +def test_find_unescaped_underscore_symbols_in_source_offsets() -> None: + assert find_unescaped_underscore_symbols_in_source( + '"""Trailing symbol_name_ in prose."""\n' + ) == [(1, 12, "symbol_name_")] diff --git a/pyproject.toml b/pyproject.toml index 97341dfd76..f68f74a0b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -164,6 +164,7 @@ ignore-paths=[ ignore-patterns = [".*_pb2\\.py"] load-plugins = [ "dev_tools.pylint_copyright_checker", + "dev_tools.pylint_docstring_underscore_checker", "pylint.extensions.docparams", "pylint.extensions.docstyle", ] @@ -203,6 +204,7 @@ enable= [ "simplifiable-if-statement", "too-many-function-args", "trailing-whitespace", + "unescaped-underscore-in-docstring", "unhashable-dict-key", "unnecessary-semicolon", "unreachable", From 66cfdb18b6930d73faad7d2bad951f1048bdcbc3 Mon Sep 17 00:00:00 2001 From: Bret Mishler Date: Tue, 10 Nov 2026 01:14:56 +0000 Subject: [PATCH 4/5] Fix mypy return-value error and achieve 100% coverage in docstring checker --- .../pylint_docstring_underscore_checker.py | 1 + ...ylint_docstring_underscore_checker_test.py | 53 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/dev_tools/pylint_docstring_underscore_checker.py b/dev_tools/pylint_docstring_underscore_checker.py index 9b485ecf4a..c02bf9758a 100644 --- a/dev_tools/pylint_docstring_underscore_checker.py +++ b/dev_tools/pylint_docstring_underscore_checker.py @@ -249,6 +249,7 @@ def _get_raw_docstring_and_line_map( line_map.append((src_line, src_col)) if not line_map: # pragma: no cover + assert isinstance(node.value, str) return node.value, [(node.lineno, node.col_offset)] return "".join(raw_parts), line_map diff --git a/dev_tools/pylint_docstring_underscore_checker_test.py b/dev_tools/pylint_docstring_underscore_checker_test.py index dd4e1e54b1..42382f0125 100644 --- a/dev_tools/pylint_docstring_underscore_checker_test.py +++ b/dev_tools/pylint_docstring_underscore_checker_test.py @@ -25,6 +25,8 @@ from dev_tools.pylint_docstring_underscore_checker import ( DocstringUnderscoreChecker, + find_unescaped_underscore_symbols_in_docstring, + find_unescaped_underscore_symbols_in_file, find_unescaped_underscore_symbols_in_source, ) @@ -148,9 +150,48 @@ def test_leading_ellipsis_in_prose_is_flagged(self) -> None: ): self.checker.process_module(node) + def test_prose_after_indented_code_block_is_flagged(self) -> None: + source = ( + '"""Summary:\n\n' + ' x = _in_code_block_\n' + ' Prose after code block referencing __init__.\n' + ' Base indent line.\n' + '"""\n' + ) + node = parse(source) + with self.assertAddsMessages( + MessageTest( + msg_id="unescaped-underscore-in-docstring", + line=4, + col_offset=41, + args=("__init__",), + ) + ): + self.checker.process_module(node) + + def test_implicitly_concatenated_docstring_tokens(self) -> None: + source = ( + 'def f():\n' + ' (\n' + ' "First literal part "\n' + ' "second part referencing __init__."\n' + ' )\n' + ) + node = parse(source) + with self.assertAddsMessages( + MessageTest( + msg_id="unescaped-underscore-in-docstring", + line=4, + col_offset=33, + args=("__init__",), + ) + ): + self.checker.process_module(node) + @pytest.mark.parametrize( "source", [ + pytest.param('def f():\n """"""\n', id="empty-docstring"), pytest.param( '"""Uses `__init__`, `_unitary_`, and ``_private_helper`` in backticks."""\n', id="inline-backticks", @@ -270,3 +311,15 @@ def test_find_unescaped_underscore_symbols_in_source_offsets() -> None: assert find_unescaped_underscore_symbols_in_source( '"""Trailing symbol_name_ in prose."""\n' ) == [(1, 12, "symbol_name_")] + + +def test_find_unescaped_underscore_symbols_in_docstring_without_line_map() -> None: + assert find_unescaped_underscore_symbols_in_docstring( + "Summary.\nCalls _unitary_ directly." + ) == [(2, 6, "_unitary_")] + + +def test_find_unescaped_underscore_symbols_in_file(tmp_path: pathlib.Path) -> None: + path = tmp_path / "sample.py" + path.write_text('"""Module docstring referencing _private_helper."""\n', encoding="utf-8") + assert find_unescaped_underscore_symbols_in_file(path) == [(1, 32, "_private_helper")] From 435059aa908b0e4b08baf4426246c6637f2e1299 Mon Sep 17 00:00:00 2001 From: Bret Mishler Date: Tue, 10 Nov 2026 22:00:32 +0000 Subject: [PATCH 5/5] Harden docstring underscore checker patterns, masking, and line mapping --- .../cirq/protocols/kraus_protocol_test.py | 4 +- .../pylint_docstring_underscore_checker.py | 93 +++++++++------ ...ylint_docstring_underscore_checker_test.py | 107 ++++++++++++++---- examples/bb84.py | 2 + 4 files changed, 144 insertions(+), 62 deletions(-) diff --git a/cirq-core/cirq/protocols/kraus_protocol_test.py b/cirq-core/cirq/protocols/kraus_protocol_test.py index 4904aa8585..7426300248 100644 --- a/cirq-core/cirq/protocols/kraus_protocol_test.py +++ b/cirq-core/cirq/protocols/kraus_protocol_test.py @@ -202,9 +202,7 @@ def _apply_channel_(self, args): ], ) def test_kraus_fallback_to_apply_channel(channel_cls, params) -> None: - """Kraus protocol falls back to `_apply_channel_` when no `_kraus_`, `_mixture_`, - or `_unitary_`. - """ + """Falls back to `_apply_channel_` without `_kraus_`, `_mixture_`, or `_unitary_`.""" # Create the expected channel and get its Kraus operators expected_channel = channel_cls(*params) expected_kraus = cirq.kraus(expected_channel) diff --git a/dev_tools/pylint_docstring_underscore_checker.py b/dev_tools/pylint_docstring_underscore_checker.py index c02bf9758a..6edac7295c 100644 --- a/dev_tools/pylint_docstring_underscore_checker.py +++ b/dev_tools/pylint_docstring_underscore_checker.py @@ -18,7 +18,6 @@ import ast import io -import pathlib import re import tokenize from typing import TYPE_CHECKING @@ -33,18 +32,19 @@ # 1. Double-underscore (__foo__) or single-underscore-wrapped (_foo_) identifiers # 2. Leading-underscore identifiers containing at least one more underscore (_foo_bar) # 3. Trailing-underscore identifiers containing at least one internal underscore (foo_bar_) +# A trailing `.attr` / `.ext` is skipped only when the matched symbol does not end in `_` +# (CommonMark still renders `__init__.py` and `_private_.attr` with bold/italics). UNESCAPED_UNDERSCORE_PATTERN = re.compile( r"(?>>.*(?:\n[ \t]*\.\.\..*)*", flags=re.MULTILINE) -_EXAMPLE_OUTPUT_BLOCK_RE = re.compile( - r"^[ \t]*===\s*example output\s*===.*$", flags=re.IGNORECASE | re.DOTALL | re.MULTILINE -) +# tensorflow_docs renders everything from `>>>` up to the next blank line as a code block, +# including `...` continuation lines and expected output lines. +_DOCTEST_BLOCK_RE = re.compile(r"^[ \t]*>>>.*(?:\n(?![ \t]*$).+)*", flags=re.MULTILINE) _DOUBLE_BACKTICK_RE = re.compile(r"``(?:(?!``)[^\n])+``") _SINGLE_BACKTICK_RE = re.compile(r"`[^`\n]+`") _URL_RE = re.compile(r"https?://\S+") @@ -56,11 +56,28 @@ r"^(Args|Arguments|Attributes|Parameters|Returns|Yields|Raises|Example|Examples|Usage):\s*$" ) _PARAM_SECTION_NAMES = {"Args", "Arguments", "Attributes", "Parameters"} +# Note: Google-style `Example:`/`Examples:`/`Usage:` sections often contain un-fenced +# 4-space-indented code directly under the header. Masking all 4-space-indented lines in +# these sections avoids false positives on such code, at the cost of also masking prose in +# those sections (which `tensorflow_docs` renders as normal Markdown). _CODE_SECTION_NAMES = {"Example", "Examples", "Usage"} _PARAM_DEF_RE = re.compile(r"^\*{0,2}[a-zA-Z_][a-zA-Z0-9_]*(?:\s*\([^)]*\))?\s*:") _STRING_TOKEN_QUOTE_RE = re.compile(r"^([rRuUbBfF]*(\"\"\"|\'\'\'|\"|\'))(.*)\2$", flags=re.DOTALL) +def _splitlines_lf(text: str, *, keepends: bool = False) -> list[str]: + r"""Like `str.splitlines`, but splits only on `\n` to match Python `ast`/`tokenize`.""" + if not text: + return [] + parts = text.split("\n") + if parts[-1] == "": + parts.pop() + return [p + "\n" for p in parts] if keepends else parts + if keepends: + return [p + "\n" for p in parts[:-1]] + [parts[-1]] + return parts + + def _mask_with_spaces(match: re.Match[str]) -> str: """Replaces matched characters with spaces while preserving newlines for line numbers.""" return re.sub(r"[^\n]", " ", match.group(0)) @@ -72,7 +89,7 @@ def mask_docstring_code_and_math(docstring: str) -> str: Newlines and character positions within unmasked text are preserved so that line numbers and column offsets remain accurate. """ - lines = docstring.splitlines(keepends=True) + lines = _splitlines_lf(docstring, keepends=True) indents = [len(line) - len(line.lstrip(" ")) for line in lines[1:] if line.strip()] base_indent = min(indents) if indents else 0 @@ -80,7 +97,6 @@ def mask_docstring_code_and_math(docstring: str) -> str: masked = _FENCED_CODE_BLOCK_RE.sub(_mask_with_spaces, docstring) masked = _DISPLAY_MATH_RE.sub(_mask_with_spaces, masked) masked = _DOCTEST_BLOCK_RE.sub(_mask_with_spaces, masked) - masked = _EXAMPLE_OUTPUT_BLOCK_RE.sub(_mask_with_spaces, masked) # 2. Mask indented code blocks and parameter names inside Args/Attributes sections. out_lines: list[str] = [] @@ -90,7 +106,7 @@ def mask_docstring_code_and_math(docstring: str) -> str: prev_ended_with_colon = False saw_blank_since_colon = False - for idx, line in enumerate(masked.splitlines(keepends=True)): + for idx, line in enumerate(_splitlines_lf(masked, keepends=True)): stripped = line.strip() if not stripped: if prev_ended_with_colon: @@ -148,26 +164,21 @@ def mask_docstring_code_and_math(docstring: str) -> str: def find_unescaped_underscore_symbols_in_docstring( - docstring: str, line_map: list[tuple[int, int]] | None = None + docstring: str, line_map: list[tuple[int, int]] ) -> list[tuple[int, int, str]]: """Finds unescaped underscore-delimited symbols in a docstring. Args: docstring: The docstring text to inspect. - line_map: Optional list of `(source_lineno, source_col_offset)` for each line in - `docstring`. + line_map: List of `(source_lineno, source_col_offset)` for each line in `docstring`. Returns: A list of `(lineno, col_offset, symbol)` tuples for each unescaped symbol found. """ masked = mask_docstring_code_and_math(docstring) results: list[tuple[int, int, str]] = [] - for line_idx, line in enumerate(masked.splitlines()): - if line_map is not None and line_idx < len(line_map): - lineno, col_base = line_map[line_idx] - else: - lineno = 1 + line_idx - col_base = 0 + for line_idx, line in enumerate(_splitlines_lf(masked)): + lineno, col_base = line_map[line_idx] for match in UNESCAPED_UNDERSCORE_PATTERN.finditer(line): results.append((lineno, col_base + match.start(), match.group(1))) return results @@ -221,12 +232,23 @@ def _extract_docstring_nodes(tree: ast.AST) -> list[ast.Constant]: return nodes +def _byte_col_to_char_col(line: str, byte_col: int) -> int: + """Converts an AST UTF-8 byte column offset to a character offset matching `tokenize`.""" + if line.isascii(): + return byte_col + return len(line.encode("utf-8")[:byte_col].decode("utf-8")) + + def _get_raw_docstring_and_line_map( - string_tokens: list[tokenize.TokenInfo], node: ast.Constant + source_lines: list[str], string_tokens: list[tokenize.TokenInfo], node: ast.Constant ) -> tuple[str, list[tuple[int, int]]]: """Reconstructs raw text and `(source_lineno, source_col_offset)` per line for `node`.""" - start = (node.lineno, node.col_offset) - end = (node.end_lineno or node.lineno, node.end_col_offset or 0) + start = (node.lineno, _byte_col_to_char_col(source_lines[node.lineno - 1], node.col_offset)) + end_lineno = node.end_lineno or node.lineno + end = ( + end_lineno, + _byte_col_to_char_col(source_lines[end_lineno - 1], node.end_col_offset or 0), + ) raw_parts: list[str] = [] line_map: list[tuple[int, int]] = [] @@ -237,7 +259,7 @@ def _get_raw_docstring_and_line_map( continue prefix_len = len(m.group(1)) inner = m.group(3) - inner_lines = inner.splitlines(keepends=True) + inner_lines = _splitlines_lf(inner, keepends=True) if not inner_lines: inner_lines = [""] for idx, line_text in enumerate(inner_lines): @@ -250,7 +272,8 @@ def _get_raw_docstring_and_line_map( if not line_map: # pragma: no cover assert isinstance(node.value, str) - return node.value, [(node.lineno, node.col_offset)] + num_lines = max(1, len(_splitlines_lf(node.value))) + return node.value, [(start[0] + i, start[1] if i == 0 else 0) for i in range(num_lines)] return "".join(raw_parts), line_map @@ -264,6 +287,7 @@ def find_unescaped_underscore_symbols_in_source(source: str) -> list[tuple[int, A sorted list of `(lineno, col_offset, symbol)` tuples for each unescaped symbol found. """ tree = ast.parse(source) + source_lines = source.split("\n") string_tokens = [ tok for tok in tokenize.generate_tokens(io.StringIO(source).readline) @@ -272,21 +296,13 @@ def find_unescaped_underscore_symbols_in_source(source: str) -> list[tuple[int, findings: list[tuple[int, int, str]] = [] for doc_node in _extract_docstring_nodes(tree): - raw_text, line_map = _get_raw_docstring_and_line_map(string_tokens, doc_node) - findings.extend(find_unescaped_underscore_symbols_in_docstring(raw_text, line_map=line_map)) + raw_text, line_map = _get_raw_docstring_and_line_map(source_lines, string_tokens, doc_node) + findings.extend(find_unescaped_underscore_symbols_in_docstring(raw_text, line_map)) findings.sort() return findings -def find_unescaped_underscore_symbols_in_file( - filepath: str | pathlib.Path, -) -> list[tuple[int, int, str]]: - """Checks a Python file for unescaped underscore symbols in docstrings.""" - source = pathlib.Path(filepath).read_text(encoding="utf-8") - return find_unescaped_underscore_symbols_in_source(source) - - class DocstringUnderscoreChecker(BaseRawFileChecker): """Pylint checker for unescaped underscore-delimited symbols in docstrings. @@ -296,12 +312,15 @@ class DocstringUnderscoreChecker(BaseRawFileChecker): name = "unescaped-underscore-in-docstring" msgs = { "C0002": ( - "Unescaped symbol '%s' in docstring; wrap literal symbols in backticks", + ( + "Unescaped symbol '%s' in docstring; wrap code symbols in backticks or use " + "*emphasis*" + ), "unescaped-underscore-in-docstring", ( "Wrap underscore-delimited symbols (such as `__init__`, `_unitary_`, or " "`_private_helper`) in backticks in docstrings so Markdown does not render " - "them as italics or bold." + "them as italics or bold, or use `*...*` / `**...**` when emphasis is intended." ), ) } diff --git a/dev_tools/pylint_docstring_underscore_checker_test.py b/dev_tools/pylint_docstring_underscore_checker_test.py index 42382f0125..fb80056c87 100644 --- a/dev_tools/pylint_docstring_underscore_checker_test.py +++ b/dev_tools/pylint_docstring_underscore_checker_test.py @@ -25,8 +25,6 @@ from dev_tools.pylint_docstring_underscore_checker import ( DocstringUnderscoreChecker, - find_unescaped_underscore_symbols_in_docstring, - find_unescaped_underscore_symbols_in_file, find_unescaped_underscore_symbols_in_source, ) @@ -53,6 +51,21 @@ def test_unescaped_symbols_reported_with_exact_columns(self) -> None: ): self.checker.process_module(node) + def test_dotted_underscore_wrapped_symbols_are_flagged(self) -> None: + node = parse('"""See __init__.py and _private_.attr for details."""\n') + with self.assertAddsMessages( + MessageTest( + msg_id="unescaped-underscore-in-docstring", line=1, col_offset=7, args=("__init__",) + ), + MessageTest( + msg_id="unescaped-underscore-in-docstring", + line=1, + col_offset=23, + args=("_private_",), + ), + ): + self.checker.process_module(node) + def test_multiline_and_nested_definitions(self) -> None: source = ( 'class Outer:\n' @@ -111,6 +124,31 @@ def test_document_call_and_attribute_docstrings(self) -> None: ): self.checker.process_module(node) + def test_non_ascii_document_call_reports_exact_char_offsets(self) -> None: + source = ( + 'θ = 1\n' + 'document(θ, """First line.\n' + 'Second line with __init__ and single line.\n' + '""")\n' + 'document(θ, "Inline __call__.")\n' + ) + node = parse(source) + with self.assertAddsMessages( + MessageTest( + msg_id="unescaped-underscore-in-docstring", + line=3, + col_offset=17, + args=("__init__",), + ), + MessageTest( + msg_id="unescaped-underscore-in-docstring", + line=5, + col_offset=20, + args=("__call__",), + ), + ): + self.checker.process_module(node) + def test_stray_currency_dollar_does_not_mask_symbols(self) -> None: source = '"""Price is $5 later _a_b_ and\nmore $6 after __init__."""\n' node = parse(source) @@ -150,6 +188,26 @@ def test_leading_ellipsis_in_prose_is_flagged(self) -> None: ): self.checker.process_module(node) + def test_doctest_masks_output_until_blank_line_only(self) -> None: + source = ( + '"""Summary.\n\n' + '>>> get_name()\n' + '_helper_fn_\n' + '\n' + 'Prose after doctest referencing __init__.\n' + '"""\n' + ) + node = parse(source) + with self.assertAddsMessages( + MessageTest( + msg_id="unescaped-underscore-in-docstring", + line=6, + col_offset=32, + args=("__init__",), + ) + ): + self.checker.process_module(node) + def test_prose_after_indented_code_block_is_flagged(self) -> None: source = ( '"""Summary:\n\n' @@ -199,11 +257,16 @@ def test_implicitly_concatenated_docstring_tokens(self) -> None: pytest.param( '"""Code block:\n\n' '```python\ndef __init__(self):\n self._unitary_()\n```\n"""\n', - id="fenced-code-block", + id="fenced-backtick-code-block", ), pytest.param( - '"""Doctest:\n>>> obj._unitary_(\n... __init__\n... )\n"""\n', - id="doctest-with-continuation", + '"""Tilde code block:\n\n' + '~~~python\ndef __init__(self):\n self._unitary_()\n~~~\n"""\n', + id="fenced-tilde-code-block", + ), + pytest.param( + '"""Doctest:\n>>> obj._unitary_(\n... __init__\n... )\n_helper_fn_\n"""\n', + id="doctest-with-continuation-and-output", ), pytest.param( '"""Indented code block:\n\n' @@ -212,20 +275,14 @@ def test_implicitly_concatenated_docstring_tokens(self) -> None: ' pass\n"""\n', id="indented-code-block", ), - pytest.param( - '"""Program output:\n\n' - '=== Example output ===\n' - 'Bases match: X_XXXXX_\n' - 'a: _1___1_1_1111_\n"""\n', - id="example-output-block", - ), pytest.param( '"""Math expression $a_{i_1} + b_{j_2}$ and display $$x_{a_b}$$."""\n', id="latex-math", ), pytest.param( - '"""URL https://example.com/_foo_bar/__init__ and domain b_c_.com and 1_000_."""\n', - id="urls-domains-and-numeric-literals", + '"""URL https://example.com/_foo_bar/__init__.py and _module_name.attr and ' + '1_000_."""\n', + id="urls-dotted-prefixes-and-numeric-literals", ), pytest.param( 'r"""Backslash-escaped \\_unitary\\_ and \\__init\\__ in raw docstring."""\n', @@ -256,10 +313,13 @@ def test_disabled_message_early_return(self, monkeypatch: pytest.MonkeyPatch) -> def _lint(tmp_path: pathlib.Path, source: str) -> list[str]: path = tmp_path / "mod.py" path.write_text(source, encoding="utf-8") + rcfile = tmp_path / "pylintrc" + rcfile.write_text("", encoding="utf-8") reporter = CollectingReporter() Run( [ str(path), + f"--rcfile={rcfile}", "--persistent=n", "--disable=all", "--enable=unescaped-underscore-in-docstring,useless-suppression", @@ -313,13 +373,16 @@ def test_find_unescaped_underscore_symbols_in_source_offsets() -> None: ) == [(1, 12, "symbol_name_")] -def test_find_unescaped_underscore_symbols_in_docstring_without_line_map() -> None: - assert find_unescaped_underscore_symbols_in_docstring( - "Summary.\nCalls _unitary_ directly." - ) == [(2, 6, "_unitary_")] +def test_long_underscore_run_does_not_backtrack_exponentially() -> None: + assert find_unescaped_underscore_symbols_in_source('"""_a' + '_' * 40 + '-x."""\n') == [] -def test_find_unescaped_underscore_symbols_in_file(tmp_path: pathlib.Path) -> None: - path = tmp_path / "sample.py" - path.write_text('"""Module docstring referencing _private_helper."""\n', encoding="utf-8") - assert find_unescaped_underscore_symbols_in_file(path) == [(1, 32, "_private_helper")] +def test_non_lf_line_separators_do_not_shift_line_numbers() -> None: + source = ( + '# comment with form feed \x0c on line 1\n' + 'θ = 1\n' + 'document(θ, """First\x0csame line.\n' + 'Second __init__.\n' + '""")\n' + ) + assert find_unescaped_underscore_symbols_in_source(source) == [(4, 7, "__init__")] diff --git a/examples/bb84.py b/examples/bb84.py index 44f1ae067a..60cc10032c 100644 --- a/examples/bb84.py +++ b/examples/bb84.py @@ -55,6 +55,7 @@ === Example output === +``` Simulating non-eavesdropped protocol 0: ───X───M─────────── @@ -104,6 +105,7 @@ Bases match:: X_XXXXX_ Expected key: 010010 Actual key: 111011 +``` """