diff --git a/.github/workflows/python_lint.yml b/.github/workflows/python_lint.yml index 8cddeeebe..40fbbe624 100644 --- a/.github/workflows/python_lint.yml +++ b/.github/workflows/python_lint.yml @@ -93,3 +93,17 @@ jobs: # Job-specific step(s): - name: Check public API imports run: uv run pytest tests/unit_tests/test_public_api_imports.py -m linting + + docstring-markdown-check: + name: Python Docstring Format Check + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + - name: Install uv + uses: astral-sh/setup-uv@f06b870e0a91d23284a3013acc55e6f88ab4b904 # v7 + with: + version: "latest" + + - name: Check docstring formatting + run: ./scripts/check_docstring_markdown.uv diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aab6973c3..c3e533cd0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,6 +9,7 @@ Use **Markdown** formatting in all docstrings — not reStructuredText (reST). - Use single backticks for inline code: `` `MyClass` ``, not double backticks (` ``MyClass`` `). - Reference methods as `` `get_column_info()` ``, not `:meth:\`get_column_info\``. - Use standard Markdown for emphasis, lists, and links. +- The `Python Docstring Format Check` CI job validates this style; run `poe lint-docstrings` locally or invoke the standalone `./scripts/check_docstring_markdown.uv` script with only uv installed. ## MCP Tools Are a Presentation Layer diff --git a/airbyte/_util/api_util.py b/airbyte/_util/api_util.py index c69f9660f..e92b9c8ee 100644 --- a/airbyte/_util/api_util.py +++ b/airbyte/_util/api_util.py @@ -3582,10 +3582,10 @@ def replace_connection_state( perform per-stream deduplication or merging on the backend. Callers that need to update a single stream must first fetch the current state, merge the desired stream change into the full state object, and then send the complete state back. - See ``CloudConnection.set_stream_state()`` for this fetch-modify-push pattern. + See `CloudConnection.set_stream_state()` for this fetch-modify-push pattern. - The provided ``connection_id`` is injected into both the outer request - wrapper and the inner ``connection_state`` payload to ensure consistency. + The provided `connection_id` is injected into both the outer request + wrapper and the inner `connection_state` payload to ensure consistency. Args: connection_id: The connection ID to update state for. @@ -3685,7 +3685,7 @@ def replace_connection_catalog( Args: connection_id: The connection ID to update catalog for. - configured_catalog_dict: The configured catalog dict (``{"streams": [...]}``) to set. + configured_catalog_dict: The configured catalog dict (`{"streams": [...]}`) to set. api_root: The API root URL. client_id: OAuth client ID. client_secret: OAuth client secret. diff --git a/airbyte/cli/smoke_test_source/source.py b/airbyte/cli/smoke_test_source/source.py index 46abc56a8..7ce15f5d9 100644 --- a/airbyte/cli/smoke_test_source/source.py +++ b/airbyte/cli/smoke_test_source/source.py @@ -6,7 +6,7 @@ schema variations, and batch size variations. Predefined scenarios are always available. Additional scenarios can be -injected dynamically via the ``custom_scenarios`` config field. +injected dynamically via the `custom_scenarios` config field. .. warning:: This module is experimental and subject to change without notice. @@ -75,7 +75,7 @@ class SourceSmokeTest(Source): Generates synthetic data across predefined scenarios that cover common destination failure patterns. Supports dynamic injection - of additional scenarios via the ``custom_scenarios`` config field. + of additional scenarios via the `custom_scenarios` config field. """ def spec( @@ -197,11 +197,11 @@ def _get_all_scenarios( """Combine predefined and custom scenarios. Selection logic: - 1. Boolean flags control groups: ``all_fast_streams`` + 1. Boolean flags control groups: `all_fast_streams` (default true) enables non-high-volume scenarios, - ``all_slow_streams`` (default false) enables + `all_slow_streams` (default false) enables high-volume scenarios. - 2. ``scenario_filter`` names are unioned with the boolean sets. + 2. `scenario_filter` names are unioned with the boolean sets. 3. Custom scenarios are always included. 4. The final list is deduplicated by name. """ diff --git a/pyproject.toml b/pyproject.toml index 07f3c5cb5..e4a006c1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -171,7 +171,8 @@ coverage-report = { shell = "coverage report" } coverage-html = { shell = "coverage html -d htmlcov && open htmlcov/index.html" } coverage-reset = { shell = "coverage erase" } -check = { shell = "ruff check . && pyrefly check && pytest --collect-only -qq" } +check = { shell = "ruff check . && pyrefly check && pytest --collect-only -qq && ./scripts/check_docstring_markdown.uv" } +lint-docstrings = { cmd = "./scripts/check_docstring_markdown.uv", help = "Fail on reStructuredText syntax in docstrings (pdoc renders Markdown)." } docs-generate = {env = {PDOC_ALLOW_EXEC = "1"}, cmd = "python -m docs.generate run"} docs-preview = {shell = "poe docs-generate && open docs/generated/index.html"} diff --git a/scripts/check_docstring_markdown.uv b/scripts/check_docstring_markdown.uv new file mode 100755 index 000000000..8ed124982 --- /dev/null +++ b/scripts/check_docstring_markdown.uv @@ -0,0 +1,114 @@ +#!/usr/bin/env -S uv run --script --python 3.13 +# /// script +# requires-python = ">=3.11,<3.14" +# dependencies = [] +# /// +"""Fail on reStructuredText syntax in docstrings. + +pdoc renders docstrings as Markdown, so RST reaches readers verbatim: ``Foo`` +shows up with literal backticks and `:meth:` roles as literal text. + +Only `*.py` files are checked, so standalone `*.uv` scripts -- this one +included -- are exempt and may use RST syntax in their own docstrings. + +Standalone: uv resolves the interpreter from the metadata below and no +dependencies beyond the standard library are needed, so this runs without the +project environment. + +```bash +./scripts/check_docstring_markdown.uv # defaults to _DEFAULT_PACKAGE +./scripts/check_docstring_markdown.uv my_package/errors.py +uv run scripts/check_docstring_markdown.uv # same, without the executable bit +``` + +Exits 1 with a `file:line: message` report per violation. +""" + +from __future__ import annotations + +import ast +import re +import sys +from pathlib import Path + +# Package scanned when no path argument is given, relative to the project root +# (this script's parent directory's parent). Adapt per repo. +_DEFAULT_PACKAGE = "airbyte" + +# Double backticks, excluding a run of three or more (fence delimiters). +_DOUBLE_BACKTICK = re.compile(r"(? list[ast.Constant]: + """Every docstring node in *tree*, module and definitions alike.""" + nodes: list[ast.Constant] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Module | ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef): + continue + body = node.body + if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) and isinstance(body[0].value.value, str): + nodes.append(body[0].value) + return nodes + + +def _offset_to_line(text: str, offset: int, start_line: int) -> int: + return start_line + text.count("\n", 0, offset) + + +def check_file(path: Path) -> list[str]: + """Violations in *path*, formatted as `file:line: message`.""" + source = path.read_text(encoding="utf-8") + try: + tree = ast.parse(source, filename=str(path)) + except SyntaxError as exc: + return [f"{path}:{exc.lineno or 0}: could not parse file ({exc.msg})"] + + seen: set[tuple[int, str]] = set() + for node in _docstrings(tree): + # Constant.lineno is the opening quote; the body starts on that line. + for pattern, message in _CHECKS: + for match in pattern.finditer(node.value): + # One report per line and rule: a backtick pair matches twice. + seen.add((_offset_to_line(node.value, match.start(), node.lineno), message)) + return [f"{path}:{line}: {message}" for line, message in sorted(seen)] + + +def iter_python_files(targets: list[Path]) -> list[Path]: + files: list[Path] = [] + for target in targets: + if target.is_dir(): + files.extend(sorted(p for p in target.rglob("*.py") if "generated" not in p.parts)) + elif target.suffix == ".py": + files.append(target) + return files + + +def main(argv: list[str]) -> int: + root = Path(__file__).resolve().parent.parent + targets = [Path(arg) for arg in argv] or [root / _DEFAULT_PACKAGE] + violations = [v for path in iter_python_files(targets) for v in check_file(path)] + + if violations: + print(f"Found {len(violations)} reStructuredText docstring violation(s):", file=sys.stderr) + for violation in violations: + print(violation, file=sys.stderr) + print( + "\nDocstrings are rendered as Markdown by pdoc. See CONTRIBUTING.md.", + file=sys.stderr, + ) + return 1 + + print("✓ no reStructuredText syntax found in docstrings") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:]))